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

This commit is contained in:
2026-09-07 16:43:11 +09:00
352 changed files with 19801 additions and 3730 deletions
+7
View File
@@ -251,6 +251,13 @@ const SHELL_CSS = `
align-items: center;
gap: var(--spacing-8);
}
/* 사용자명·로그아웃(또는 로그인·회원가입) 버튼이 서로 붙어 보이던 것을 띄운다
(2026-09-06 사용자 지적) — 이 칸은 나중에 채워지므로 자체 간격이 필요하다. */
.app-actions__auth {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
.app-outlet {
min-height: calc(100vh - 64px - 56px);
}
+61
View File
@@ -111,6 +111,67 @@ export async function purgeOtherProjects(projectId: string): Promise<void> {
});
}
/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null.
* 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */
export async function readCachedBytes(projectId: string, url: string): Promise<ArrayBuffer | null> {
try {
const cached = await readAsset(projectId, url);
return cached?.body ?? null;
} catch {
return null;
}
}
/** 받아 둔 바이트를 보관함에 담는다. 실패해도 화면은 그대로 간다(용량 초과 등). */
export async function writeCachedBytes(
projectId: string,
url: string,
body: ArrayBuffer,
): Promise<void> {
try {
await writeAsset({
key: cacheKey(projectId, url),
projectId,
url,
etag: null,
savedAt: Date.now(),
body,
});
} catch {
// 담지 못해도 다음에 다시 받으면 된다 — 막지 않는다.
}
}
/**
* 한 프로젝트 안에서 **주소 앞머리가 같은 옛 보관본**을 지운다.
*
* 3D 코리도처럼 주소에 열쇠(해시)가 박히는 자료는 정본이 바뀔 때마다 새 주소가 되어
* 옛 보관본이 그대로 쌓인다. 하나가 17MB 대라 두어 벌만 남아도 브라우저 보관함을 크게
* 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06).
*/
export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise<void> {
const db = await openDatabase();
if (!db) return;
await new Promise<void>((resolve) => {
try {
const transaction = db.transaction(STORE, "readwrite");
const store = transaction.objectStore(STORE);
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete();
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
}
export interface CachedFetchOptions {
/** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */
onProgress?: (ratio: number | null) => void;
+466
View File
@@ -0,0 +1,466 @@
/* =============================================================================
* A00_Common/b_page_state.ts
* 화면 상태 보관소 **한 곳** — B05·B06 이 한 페이지처럼 움직이기 위한 뼈대
* (2026-09-06 사용자 지시).
*
* 왜 필요한가 — 세션 접근이 19개 파일 64곳에 흩어져 있었고 키 이름이 네 갈래
* (`b05:`, `b06:`, `b05-route-`, `aislo:`)로 제각각이었다. 같은 설계값인데 키가
* 페이지 이름으로 갈려 두 화면이 서로의 값을 못 보는 자리도 있었다. 값마다 **어느 통에
* 속하는지**를 아래 등록표에 한 줄로 적어 두고, 저장·복원·비우기가 전부 그 표만 보고
* 움직이게 한다. 나중에 캐시↔즉시를 바꿀 때도 표 한 줄만 고치면 된다.
*
* 통은 넷이다.
* ① pref 화면 취향 — 패널 열림·높이, 보기 토글. **계정**에 붙는다(다른 PC에서도 같은
* 배치). 세션은 그 값의 사본이라 서버가 없어도 화면은 돈다.
* ② draft 설계 초안 — 사용자가 만진 설계값. [저장]·[확정] 때만 서버로 간다.
* ③ (즉시) 누르는 순간 다시 계산을 부르는 명령. 여기 쌓지 않는다 — 표에도 없다.
* ④ result 계산 결과 — 서버·브라우저가 만들어 낸 값. 저장 대상이 아니고, 입력이
* 바뀌면 버리고 다시 만든다. 페이지를 오갈 때 다시 부르지 않으려고 둔다.
*
* 키 형식은 `aislo:{통}:{이름}:{프로젝트}[:{노선}]` 하나다. **페이지 이름을 키에 넣지
* 않는다** — ②·④는 두 화면이 같은 키를 쓴다.
* ========================================================================== */
/** 값이 속한 통. `pref`·`draft`·`result` 셋만 저장소를 쓴다(즉시 반영은 표에 없다). */
export type StateBucket = "pref" | "draft" | "result";
/** 값을 가르는 범위 — 키에 무엇을 덧붙일지 정한다. */
export type StateScope = "global" | "project" | "route";
export interface StateEntry {
bucket: StateBucket;
scope: StateScope;
/** 형식이 바뀌면 올린다 — 옛 값을 읽지 않고 기본값으로 시작한다. */
version?: number;
/** 옛 키(있으면 한 번 읽어 옮기고 지운다). `project`·`route` 범위는 함수로 받는다. */
legacy?: (projectId?: string, routeId?: number | string) => string;
}
/**
* 등록표 — **새 값은 여기 줄부터 넣고 코드를 쓴다.**
*
* `scope: "global"` 은 프로젝트를 가리지 않는 화면 취향이다(패널 높이 등). 설계값은
* 반드시 `project` 또는 `route` 범위를 쓴다 — 프로젝트를 옮겼는데 앞 프로젝트의 조작이
* 남으면 안 된다.
*/
export const STATE_REGISTRY = {
/* ── ① 화면 취향 ─────────────────────────────────────────────────────── */
"profile-collapsed": {
bucket: "pref",
scope: "global",
legacy: () => "b05-route-profile-collapsed",
},
"profile-height": { bucket: "pref", scope: "global", legacy: () => "b05-route-profile-height" },
"drainage-collapsed": {
bucket: "pref",
scope: "global",
legacy: () => "b05-route-drainage-collapsed",
},
"drainage-width": { bucket: "pref", scope: "global", legacy: () => "b05-route-drainage-width" },
"masshaul-open": {
bucket: "pref",
scope: "global",
legacy: () => "b05-route-profile-masshaul-open",
},
"masshaul-height": {
bucket: "pref",
scope: "global",
legacy: () => "b05-route-profile-masshaul-height",
},
/** 유토곡선 범례 — B05·B06 이 같은 값을 본다(예전에도 키를 공유했다). */
"masshaul-visible": {
bucket: "pref",
scope: "global",
version: 5,
legacy: () => "b06:masshaul-visible-v4",
},
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
"table-height": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:height" },
"section-panel-collapsed": {
bucket: "pref",
scope: "global",
legacy: () => "b06:profile-panel-collapsed",
},
"section-panel-height": {
bucket: "pref",
scope: "global",
legacy: () => "b06:profile-panel-height",
},
/* ── ② 설계 초안 ─────────────────────────────────────────────────────── */
/** 아직 정본에 안 넣은 구조물 목록. B05 에서 만들고 B06 [저장]이 내보낸다. */
structures: { bucket: "draft", scope: "project", legacy: (p) => `b05:structures:${p}` },
/** 3D 램프로 바꾼 측점 상단측(측구 방향). */
uphill: { bucket: "draft", scope: "project", legacy: (p) => `b05:uphill:${p}` },
/** B05 배수유역도에서 고친 관 목록(추가·이동·삭제) — 2026-09-06 되살림.
* 예전에는 패널 메모리에만 있어 B06 으로 넘어가면 편집이 사라졌다(대응표 조사).
* 정본은 `pipe_points.json` 이고, 이 초안은 [저장]·[확정]에서 `flushPendingPipes` 가
* 내보낸 뒤 비운다. */
pipes: { bucket: "draft", scope: "project", legacy: (p) => `b05:pipes:${p}` },
/** B05 에서 고른 구조물을 B06 이 이어받는 자리 — 예전 `aislo:structure-pick:*`. */
"structure-pick": {
bucket: "draft",
scope: "project",
legacy: (p) => `aislo:structure-pick:${p}`,
},
/** 배수관·암거 조정창에서 예약한 옵션 값. */
culvertopt: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertopt:${p}:${r}` },
/** 배수관 이동(측점 옮김) 예약. */
culvertmove: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:culvertmove:${p}:${r}` },
/** 암 경계선 오프셋(측점별). */
rockb: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:rockb:${p}:${r}` },
/** 소단 제원(측점키 → {width_m, interval_m, slope_deg}) — 계획서 3-9.
* 사용자가 구간에 놓은 값이라 재계산에 함께 실어 보내야 한다. 안 실으면 계획선을
* 고치는 순간 계단이 사라진다(암 경계선이 옛 키를 보던 것과 같은 자리). */
berm: { bucket: "draft", scope: "route" },
/** 표준 횡단면 설정 패널의 편집값.
*
* ⚠ **[저장]·[확정] 뒤에도 지우지 않는다**(2026-09-07 확인). 다른 초안과 달리 이 값은
* 브라우저가 가진 **유일한** 사용자 표준단면이다 — `sections/context` 가 주는
* `standard_cross_section` 은 저장분이 아니라 **config 기본값**이고(`B06_Section_Router.py:132`),
* 저장분(`stored_standard_cross_section`)은 서버 안에서만 쓰인다. 그래서 비우면 브라우저
* 횡단 계산이 그 순간 config 기본값으로 되돌아간다. [초기화]에서만 버린다
* (`clearStandardCrossSession`). */
"std-cross": { bucket: "draft", scope: "project", legacy: (p) => `b06:std-cross:${p}` },
/** 표시 반폭 — 사용자가 고른 값이라 초안이되, 바꾸면 횡단 재생성(③)을 함께 부른다. */
"cross-display": {
bucket: "draft",
scope: "route",
legacy: (p, r) => `b06:cross-display:${p}:${r}`,
},
/* 측점 조정창이 쌓는 4축·단수·연동 값 — 전부 노선 단위 초안이고 [저장]에서 함께 나간다.
옛 키는 `b06:{이름}:{프로젝트}:{노선}` 한 규칙이었다. */
crossw: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:crossw:${p}:${r}` },
revetx: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetx:${p}:${r}` },
revetlink: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:revetlink:${p}:${r}` },
basinadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:basinadjust:${p}:${r}` },
inletstruct: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:inletstruct:${p}:${r}` },
extrawall: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extrawall:${p}:${r}` },
fordadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:fordadjust:${p}:${r}` },
boxadjust: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:boxadjust:${p}:${r}` },
extraspan: { bucket: "draft", scope: "route", legacy: (p, r) => `b06:extraspan:${p}:${r}` },
/** 횡단 카드 버튼(지반유형·단면유형·측구·포장·2단 비탈) 선택 — 2026-09-06.
* 예전에는 버튼을 누를 때마다 서버가 계산해 **바로 저장**했다. 조작은 캐시에 쌓고
* [저장]·[확정]에서만 정본으로 나가야 한다(사용자 확정). */
crossdesign: { bucket: "draft", scope: "route" },
/* ── ④ 계산 결과 ─────────────────────────────────────────────────────── */
/** 노선·종단 최신 응답. 페이지를 오갈 때 이 값으로 먼저 그린다. */
latest: { bucket: "result", scope: "project", legacy: (p) => `b05:latest:${p}` },
/** 종횡단 설정값(config 상수 + 프로젝트 임도 종류 + 노선 id). B05·B06 이 같은 값을
* 받아 쓰므로 화면을 오갈 때마다 다시 묻지 않는다(2026-09-06 호출 정리).
* 노선이 바뀌면 `latest` 와 함께 버린다. */
"section-context": { bucket: "result", scope: "project" },
/** 횡단 상세 응답 — 예전에는 메모리에만 있어 페이지를 떠나면 사라졌다. */
"section-detail": { bucket: "result", scope: "route" },
/** 서버가 준 규격 횡단 기본값 — 사용자가 만든 값이 아니라 **기억해 둔 서버 값**이다. */
"std-cross-default": {
bucket: "result",
scope: "project",
legacy: (p) => `b06:std-cross-default:${p}`,
},
/** 서버가 준 암 경계선 기본 오프셋 — 위와 같은 성격. */
"rock-boundary-default": {
bucket: "result",
scope: "project",
legacy: (p) => `b06:rock-boundary-default:${p}`,
},
} as const satisfies Record<string, StateEntry>;
export type StateName = keyof typeof STATE_REGISTRY;
const warnedNames = new Set<string>();
/**
* 등록표에서 한 줄을 꺼낸다. 표에 없는 이름이면 **화면을 죽이지 않고** null 을 돌려준다 —
* 그 값만 저장이 안 될 뿐 페이지는 그대로 선다(2026-09-06: `extraspan` 이 빠져 B06 이
* 통째로 안 뜬 적이 있다). 대신 콘솔에 한 번 알려 표에 줄을 넣게 한다.
*/
function entryOf(name: StateName): StateEntry | null {
const entry = STATE_REGISTRY[name] as StateEntry | undefined;
if (entry) return entry;
if (!warnedNames.has(name)) {
warnedNames.add(name);
console.warn(`[page-state] 등록표에 없는 이름입니다 — 저장하지 않습니다: ${name}`);
}
return null;
}
/**
* 저장소 키. 범위가 요구하는 값이 없으면 `null` — 부를 쪽은 그때 저장을 건너뛴다
* (프로젝트를 아직 모를 때 전역 키에 적으면 다음 프로젝트가 그 값을 물려받는다).
*/
export function stateKey(
name: StateName,
projectId?: string | null,
routeId?: number | string | null,
): string | null {
const entry = entryOf(name);
if (!entry) return null;
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const head = `aislo:${entry.bucket}:${name}${suffix}`;
if (entry.scope === "global") return head;
if (!projectId) return null;
if (entry.scope === "project") return `${head}:${projectId}`;
if (routeId === null || routeId === undefined) return null;
return `${head}:${projectId}:${routeId}`;
}
/**
* 어느 저장소에 둘지 — **화면 취향(`pref`)만 `localStorage`**, 나머지는 세션이다.
*
* 2026-09-07 확인 — 취향까지 세션에 있어 **탭을 새로 열거나 브라우저를 껐다 켤 때마다**
* 패널 높이·접힘이 초기화됐다(계획서에는 「PC 를 바꾸면」으로 적혀 있었으나 실제 불편은
* 훨씬 잦았다). 취향은 설계 데이터가 아니므로 5장 「캐시=세션」 규칙에 걸리지 않는다 —
* **설계값·초안·계산 결과는 세션 그대로** 둔다(그 경계를 흐리지 말 것).
*/
function storageFor(key: string): Storage {
return key.startsWith("aislo:pref:") ? window.localStorage : window.sessionStorage;
}
/**
* **키만 아는 자리**를 위한 읽기·쓰기 창구 — 등록표 이름 대신 완성된 키를 든 곳이 쓴다
* (`ui_template_resizer`·`_overlay`·`_overlay_drag`, 조정창 저장소).
*
* 저장소를 직접 고르게 두면 두 가지가 샜다 — 취향이 세션에 남아 탭마다 초기화됐고
* (2026-09-07), 계정 올려보내기도 안 걸렸다. **저장소 선택과 올려보내기를 여기 한 곳**에 둔다.
*/
export function readByKey(key: string): string | null {
return readRaw(key);
}
export function writeByKey(key: string, value: string | null): void {
writeRaw(key, value);
}
/**
* 세션에 남아 있던 옛 취향을 **한 번에 브라우저 저장소로 옮긴다**(2026-09-07).
*
* 취향을 쓰는 화면 부품(높이 조절자·접이식 패널)은 키만 들고 저장소를 직접 만지므로
* 읽는 자리마다 건져 올리는 방식으로는 **높이 값이 안 옮겨졌다**(실측: 접힘 4개는 옮겨지고
* 높이 3개는 세션에 남음). 그래서 첫 로드 때 한 번 쓸어 옮긴다 — 쓰던 패널 크기가
* 이 변경 때문에 초기화되지 않게 한다. 여러 번 불려도 안전하다.
*/
function liftPrefsToLocalStorage(): void {
try {
const stale = Object.keys(window.sessionStorage).filter((key) => key.startsWith("aislo:pref:"));
for (const key of stale) {
const value = window.sessionStorage.getItem(key);
if (value !== null && window.localStorage.getItem(key) === null) {
window.localStorage.setItem(key, value);
}
window.sessionStorage.removeItem(key);
}
} catch {
/* 저장소가 막힌 브라우저 — 취향은 이번 세션 동안 화면 메모리로만 산다. */
}
}
liftPrefsToLocalStorage();
/* ── 계정에 붙는 화면 취향 (2026-09-07 사용자 승인) ─────────────────────────────
* 브라우저에 남긴 값 위에 **계정 값을 얹는다** — PC 를 바꿔도 같은 배치가 되게.
* 규칙 셋:
* · 서버가 없거나 못 읽으면 **로컬 값으로 그대로 돈다**(계획서 원문).
* · 서버에 있는 키만 덮는다 — 서버가 비었다고 로컬을 지우지 않는다.
* · 취향이 바뀌면 잠시 모아 한 번에 올린다. **설계값은 여기 오지 않는다** —
* 자동저장 금지(CLAUDE.md 5장)는 설계값 규칙이고, 화면 배치는 [저장] 단추가 없다.
* ------------------------------------------------------------------------- */
const PREFS_ENDPOINT = "/api/dashboard/me/ui-prefs";
const PREFS_PUSH_DELAY_MS = 1500;
let applyingServerPrefs = false;
let prefsPushTimer: ReturnType<typeof setTimeout> | null = null;
function localPrefs(): Record<string, string> {
const out: Record<string, string> = {};
try {
for (const key of Object.keys(window.localStorage)) {
if (!key.startsWith("aislo:pref:")) continue;
const value = window.localStorage.getItem(key);
if (value !== null) out[key] = value;
}
} catch {
/* 저장소가 막힌 브라우저 — 올릴 것이 없다. */
}
return out;
}
/** 취향이 바뀌면 잠시 모아 한 번에 올린다. 실패는 조용히 넘긴다(다음 변경 때 다시 올라간다). */
function pushUiPrefs(): void {
if (applyingServerPrefs) return;
if (prefsPushTimer) clearTimeout(prefsPushTimer);
prefsPushTimer = setTimeout(() => {
prefsPushTimer = null;
void fetch(PREFS_ENDPOINT, {
method: "PUT",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ prefs: localPrefs() }),
}).catch(() => undefined);
}, PREFS_PUSH_DELAY_MS);
}
/** 계정에 저장된 취향을 받아 로컬에 얹는다 — 로그인 뒤 한 번. */
export async function syncUiPrefs(): Promise<void> {
let stored: Record<string, unknown> = {};
try {
const response = await fetch(PREFS_ENDPOINT, { credentials: "include" });
if (!response.ok) return;
const body = (await response.json()) as { prefs?: Record<string, unknown> };
stored = body.prefs ?? {};
} catch {
return; // 서버가 없으면 로컬 값으로 그대로 돈다.
}
applyingServerPrefs = true;
try {
for (const [key, value] of Object.entries(stored)) {
if (!key.startsWith("aislo:pref:") || typeof value !== "string") continue;
window.localStorage.setItem(key, value);
}
} catch {
/* 저장소가 막힌 브라우저 — 이번 세션은 화면 메모리로 돈다. */
} finally {
applyingServerPrefs = false;
}
}
/** 저장소 접근은 막힌 브라우저(사생활 보호)에서 던진다 — 전부 조용히 넘긴다. */
function readRaw(key: string): string | null {
try {
return storageFor(key).getItem(key);
} catch {
return null;
}
}
function writeRaw(key: string, value: string | null): void {
try {
const store = storageFor(key);
if (value === null) store.removeItem(key);
else store.setItem(key, value);
} catch {
/* 무시 — 값은 화면 메모리에 남는다. */
}
if (key.startsWith("aislo:pref:")) pushUiPrefs();
}
/** 옛 키에 남은 값을 새 키로 한 번 옮긴다(옮기고 나면 옛 키는 지운다). */
function migrate(
name: StateName,
key: string,
projectId?: string | null,
routeId?: number | string | null,
): void {
const legacy = entryOf(name)?.legacy?.(projectId ?? undefined, routeId ?? undefined);
if (!legacy || legacy === key) return;
const old = readRaw(legacy);
if (old !== null && readRaw(key) === null) writeRaw(key, old);
if (old !== null) writeRaw(legacy, null);
}
/** 문자열 그대로 읽는다(숫자·참거짓처럼 JSON 이 아닌 값). */
export function readStateRaw(
name: StateName,
projectId?: string | null,
routeId?: number | string | null,
): string | null {
const key = stateKey(name, projectId, routeId);
if (!key) return null;
migrate(name, key, projectId, routeId);
return readRaw(key);
}
export function writeStateRaw(
name: StateName,
value: string | null,
projectId?: string | null,
routeId?: number | string | null,
): void {
const key = stateKey(name, projectId, routeId);
if (key) writeRaw(key, value);
}
/** JSON 값을 읽는다. 없거나 손상됐으면 `null` — 부를 쪽이 기본값으로 시작한다. */
export function readState<T>(
name: StateName,
projectId?: string | null,
routeId?: number | string | null,
): T | null {
const raw = readStateRaw(name, projectId, routeId);
if (raw === null) return null;
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
export function writeState(
name: StateName,
value: unknown,
projectId?: string | null,
routeId?: number | string | null,
): void {
writeStateRaw(name, value === null ? null : JSON.stringify(value), projectId, routeId);
}
export function clearState(
name: StateName,
projectId?: string | null,
routeId?: number | string | null,
): void {
const entry = entryOf(name);
// 노선 범위인데 노선을 안 받았으면 그 프로젝트의 **모든 노선** 것을 쓸어 낸다.
// 노선을 다시 계산하면 번호가 새로 매겨져 부르는 쪽은 옛 번호를 모르고(`clearDrafts(projectId)`),
// 그러면 `stateKey` 가 null 을 내어 **아무것도 안 지워졌다** — 용화(5601e828)에 145·161·169
// 세 노선치가 그대로 쌓여 있었다(2026-09-07 실측). 옛 번호는 다시 불리지 않는다 —
// 노선 복원도 **새 route id** 를 발급하므로 그 키를 읽는 자리가 없다.
if (entry?.scope === "route" && projectId && (routeId === null || routeId === undefined)) {
const suffix = entry.version && entry.version > 1 ? `-v${entry.version}` : "";
const prefix = `aislo:${entry.bucket}:${name}${suffix}:${projectId}:`;
try {
const doomed: string[] = [];
for (let i = 0; i < window.sessionStorage.length; i += 1) {
const key = window.sessionStorage.key(i);
if (key && key.startsWith(prefix)) doomed.push(key);
}
doomed.forEach((key) => writeRaw(key, null));
} catch {
/* 무시 — 저장소가 막힌 브라우저 */
}
return;
}
writeStateRaw(name, null, projectId, routeId);
}
/** 등록표에서 한 통에 속한 이름만 고른다. */
export function namesInBucket(bucket: StateBucket): StateName[] {
return (Object.keys(STATE_REGISTRY) as StateName[]).filter(
(name) => entryOf(name)?.bucket === bucket,
);
}
/**
* 초안을 통째로 비운다 — **[저장]·[확정]·[초기화]·노선 변경 뒤 여기 한 곳**만 부른다
* (예전에는 파일마다 따로 지웠다). 노선을 안 주면 그 프로젝트의 **모든 노선** 초안이 나간다
* (노선 변경 자리가 그렇게 부른다 — 새 번호를 아직 모르기 때문).
*/
export function clearDrafts(projectId: string | null, routeId?: number | string | null): void {
namesInBucket("draft").forEach((name) => clearState(name, projectId, routeId));
}
/** 계산 결과를 통째로 버린다 — 입력(설계값·노선)이 바뀌어 다시 만들어야 할 때. */
export function clearResults(projectId: string | null, routeId?: number | string | null): void {
namesInBucket("result").forEach((name) => clearState(name, projectId, routeId));
}
/**
* 지금 이 브라우저에 쌓인 초안이 있는가 — [저장] 버튼의 미저장 표시에 쓴다.
* 값이 빈 객체(`{}`)면 없는 것으로 본다.
*/
export function hasDrafts(projectId: string | null, routeId?: number | string | null): boolean {
return namesInBucket("draft").some((name) => {
const raw = readStateRaw(name, projectId, routeId);
return raw !== null && raw !== "{}" && raw !== "[]" && raw !== "";
});
}
+33 -7
View File
@@ -30,15 +30,39 @@ export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [
ROUTES.B09_ESTIMATION,
];
/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 —
실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다.
단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */
const WORKFLOW_CACHE_MS = 5000;
let workflowCache: { at: number; projectId: string; value: Promise<WorkflowState> } | null = null;
/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */
export function clearWorkflowStateCache(): void {
workflowCache = null;
}
export async function fetchWorkflowState(projectId: string): Promise<WorkflowState> {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`Workflow state request failed: ${response.status}`);
const now = Date.now();
if (
workflowCache &&
workflowCache.projectId === projectId &&
now - workflowCache.at < WORKFLOW_CACHE_MS
) {
return workflowCache.value;
}
const data = await response.json();
return data.workflow_state ?? data;
const value = (async (): Promise<WorkflowState> => {
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, {
credentials: "include",
});
if (!response.ok) {
throw new Error(`Workflow state request failed: ${response.status}`);
}
const data = await response.json();
return data.workflow_state ?? data;
})();
workflowCache = { at: now, projectId, value };
value.catch(() => clearWorkflowStateCache());
return value;
}
/**
@@ -52,6 +76,8 @@ const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, RO
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
clearWorkflowStateCache();
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
navigateTo(route);
return;
+44 -10
View File
@@ -1,4 +1,5 @@
import { API_BASE_URL } from "@config/config_frontend";
import { syncUiPrefs } from "../A00_Common/b_page_state";
export interface ApiResult {
status: string;
@@ -32,25 +33,58 @@ export function requestLogin(email: string, password: string): Promise<ApiResult
}
export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
clearSessionCache();
return post("/auth/login/verify", { email, otp_code: otpCode });
}
/* 세션 조회는 한 화면을 여는 동안 라우터 가드·상단 바·워크플로 확인이 각각 부른다 —
실측 B05 진입에서 `GET /auth/session` 이 네 번 나갔다(2026-09-06). 같은 답을 네 번
받을 이유가 없으므로 **짧은 시간 동안 한 번만** 부르고 나눠 쓴다. 로그인·로그아웃
에서는 즉시 버려 다음 조회가 서버를 다시 본다. */
const SESSION_CACHE_MS = 5000;
let sessionCache: { at: number; value: Promise<SessionUser | null> } | null = null;
/** 계정 취향 받아오기는 한 번만 — 세션 조회는 화면마다 여러 번 불린다. */
let uiPrefsSynced = false;
/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */
export function clearSessionCache(): void {
sessionCache = null;
// 로그인·로그아웃이면 다음 세션에서 취향을 다시 받아 온다(다른 계정일 수 있다).
uiPrefsSynced = false;
}
function sessionOnce(): Promise<SessionUser | null> {
const now = Date.now();
if (sessionCache && now - sessionCache.at < SESSION_CACHE_MS) return sessionCache.value;
const value = (async (): Promise<SessionUser | null> => {
try {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
if (!response.ok) return null;
const data = (await response.json()) as { status: string; user?: SessionUser };
// 로그인이 확인된 첫 순간에 계정 취향을 한 번 받아 온다 — PC 를 바꿔도 같은 배치가
// 되게(2026-09-07). 실패해도 화면은 브라우저에 남은 값으로 그대로 돈다.
if (data.user && !uiPrefsSynced) {
uiPrefsSynced = true;
void syncUiPrefs();
}
return data.user ?? null;
} catch {
return null;
}
})();
sessionCache = { at: now, value };
return value;
}
export async function fetchSession(): Promise<boolean> {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
return response.ok;
return (await sessionOnce()) !== null;
}
export async function fetchSessionUser(): Promise<SessionUser | null> {
try {
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
if (!response.ok) return null;
const data = (await response.json()) as { status: string; user?: SessionUser };
return data.user ?? null;
} catch {
return null;
}
return sessionOnce();
}
export function logout(): Promise<ApiResult> {
clearSessionCache();
return post("/auth/logout");
}
+51 -7
View File
@@ -59,9 +59,10 @@ export interface ProjectItem {
designer_user_id?: number | null;
logo_asset_id?: number | null;
signature_asset_id?: number | null;
/** 참여자 (2026-09-06 사용자 확정) — 여기 든 사람은 일반 사용자여도 수정할 수 있다. */
member_user_ids?: number[];
owner_name?: string | null;
workflow_stage: number;
progress_percent: number;
workflow_state?: WorkflowState;
updated_at?: string | null;
}
@@ -116,6 +117,9 @@ export interface AuditLog {
action: string;
resource_type?: string | null;
resource_id?: number | null;
/** 대상 식별자 문자열 — 프로젝트는 UUID 라 숫자 칸에 못 담는다 (2026-09-06). */
resource_ref?: string | null;
ip_address?: string | null;
timestamp: string;
}
@@ -171,6 +175,7 @@ export interface UpdateProjectRequest {
designer_user_id?: number | null;
logo_asset_id?: number | null;
signature_asset_id?: number | null;
member_user_ids?: number[] | null;
}
export interface AdminUpdateUserRequest extends UpdateUserRequest {
@@ -306,17 +311,56 @@ export function deleteCompanyAsset(assetId: number): Promise<unknown> {
export const companyAssetFileUrl = (assetId: number): string =>
`${API_BASE_URL}/dashboard/company/assets/${assetId}/file`;
/** 이름을 함께 주면 계정이 없는 사람도 그 자리에서 만든다 (2026-09-02 사용자 확정). */
export function addCompanyMember(
email: string,
profile?: { name?: string; position?: string | null; department?: string | null },
): Promise<{ member: Member }> {
/** 주소를 좌표로 바꾼다 (회사 주소 지도 미리보기). */
export async function geocodeAddress(address: string): Promise<{ lat: number; lon: number }> {
return request(`/dashboard/company/geocode?address=${encodeURIComponent(address)}`);
}
/** 회사 정보 수정 — 시스템관리자만 companyId 로 남의 회사를 지정한다. */
export function updateCompany(
payload: {
name: string;
business_registration_number: string;
business_address: string | null;
business_owner: string | null;
},
companyId?: number | null,
): Promise<unknown> {
return request(`/dashboard/company${companyQuery(companyId)}`, {
method: "PUT",
body: body(payload),
});
}
/** 팀원으로 부를 수 있는 사람 — 소속 없는 가입자만 (2026-09-06 사용자 확정). */
export async function searchMemberCandidates(query: string): Promise<Member[]> {
const data = await request<{ users: Member[] }>(
`/dashboard/admin/members/candidates?q=${encodeURIComponent(query)}`,
);
return data.users;
}
/** 이미 가입한 사람을 회사에 붙인다 — 계정을 대신 만들지 않는다. */
export function addCompanyMember(userId: number): Promise<{ member: Member }> {
return request("/dashboard/admin/members", {
method: "POST",
body: body({ email, ...profile }),
body: body({ user_id: userId }),
});
}
/** 아직 가입하지 않은 사람에게 가입 안내 메일만 보낸다. */
export function inviteMember(email: string, name?: string | null): Promise<unknown> {
return request("/dashboard/admin/members/invite", {
method: "POST",
body: body({ email, name: name || null }),
});
}
/** 계정 삭제 (회사에서 빼기가 아니라 계정 자체). 회사 관리자는 자기 회사 사람만. */
export function deleteDashboardUser(userId: number): Promise<unknown> {
return request(`/dashboard/admin/users/${userId}`, { method: "DELETE" });
}
/** 회사 대표 로고 지정·변경. 프로젝트가 따로 안 고르면 도면이 이 로고를 쓴다. */
export function setCompanyLogo(
logoAssetId: number | null,
+59
View File
@@ -0,0 +1,59 @@
"""회사 주소 지도 — VWorld 주소검색·배경지도 타일 (2026-09-06 사용자 지시).
이미 쓰던 VWorld 키를 그대로 쓴다. 키가 화면으로 새지 않게 타일도 서버가 받아 넘긴다.
"""
from __future__ import annotations
import asyncio
import json
import urllib.parse
import urllib.request
from typing import Any
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
_GEOCODE_URL = "https://api.vworld.kr/req/address"
_TILE_URL = "http://api.vworld.kr/req/wmts/1.0.0/{key}/Base/{z}/{y}/{x}.png"
def _fetch(url: str, timeout: int = 5) -> bytes:
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
with urllib.request.urlopen(request, timeout=timeout) as response:
return response.read()
def _geocode_sync(address: str) -> dict[str, Any] | None:
"""도로명으로 먼저 찾고, 없으면 지번으로 다시 찾는다."""
for address_type in ("ROAD", "PARCEL"):
query = urllib.parse.urlencode(
{
"service": "address",
"request": "getcoord",
"version": "2.0",
"crs": "epsg:4326",
"address": address,
"refine": "true",
"simple": "false",
"format": "json",
"type": address_type,
"key": VWORLD_API_KEY,
}
)
try:
body = json.loads(_fetch(f"{_GEOCODE_URL}?{query}").decode("utf-8"))
except Exception:
continue
point = (body.get("response") or {}).get("result", {}).get("point")
if point:
return {"lon": float(point["x"]), "lat": float(point["y"])}
return None
async def geocode_address(address: str) -> dict[str, Any] | None:
return await asyncio.to_thread(_geocode_sync, address.strip())
async def fetch_base_tile(z: int, x: int, y: int) -> bytes:
url = _TILE_URL.format(key=VWORLD_API_KEY, z=z, y=y, x=x)
return await asyncio.to_thread(_fetch, url)
+127 -214
View File
@@ -10,18 +10,49 @@ from typing import Any
import aiomysql
import psutil
from common_util.common_util_audit import record_audit
from config.config_db import get_db_pool
from config.config_system import EMAIL_REVERIFY_DAYS
from config.config_system import ADMIN_EMAIL, EMAIL_REVERIFY_DAYS
def _role(value: str | None) -> str:
return {"MASTER": "ADMIN", "MEMBER": "USER"}.get(value or "USER", value or "USER")
def _stage_from_status(status: str | None) -> tuple[int, int]:
async def get_system_company_id() -> int | None:
"""시스템 관리 회사 = `.env` 관리자 계정이 속한 회사 (2026-09-06 사용자 확정).
개발사 자기 회사 한 곳뿐이라 따로 표시 칸을 두지 않고 이 한 줄로 판정한다.
"""
if not ADMIN_EMAIL:
return None
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"SELECT company_id FROM users WHERE email = %s AND deleted_at IS NULL",
(ADMIN_EMAIL.lower(),),
)
row = await cursor.fetchone()
return int(row["company_id"]) if row and row["company_id"] else None
async def role_for_company(company_id: int | None) -> str:
"""회사에 들어갈 때 받는 역할 — 시스템 회사면 시스템관리자, 아니면 일반사용자."""
if company_id is not None and int(company_id) == (await get_system_company_id() or 0):
return "SYSTEM_ADMIN"
return "USER"
def _stage_from_status(status: str | None) -> int:
"""프로젝트 상태 문자열에서 워크플로 단계만 뽑는다.
진행도(%)는 내지 않는다 (2026-09-06 사용자 지시) — 화면은 워크플로 배지로 보여 주고,
배지는 `project_workflow_stages` 표를 근거로 삼는다. 상태 문자열로 따로 세면 근거가
둘이 되어 배지와 숫자가 어긋났다.
"""
value = status or "NEW"
if value in {"WF1_ANALYZING", "WF1_FAILED"}:
return 1, round(1 / 7 * 100)
return 1
order = [
("FILE_UPLOADED", 1),
("WF1_COMPLETE", 2),
@@ -37,20 +68,24 @@ def _stage_from_status(status: str | None) -> tuple[int, int]:
for token, idx in order:
if token in value:
stage = max(stage, idx)
return stage, round(stage / 7 * 100)
return stage
def _project_row(row: dict[str, Any]) -> dict[str, Any]:
stage, progress = _stage_from_status(row.get("status"))
return {**row, "workflow_stage": stage, "progress_percent": progress}
return {**row, "workflow_stage": _stage_from_status(row.get("status"))}
async def _project_rows(cursor: Any, rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
from .B01_Dashboard_Repository_Members import list_project_member_ids
states = await get_workflow_states_for_projects(cursor, [r["id"] for r in rows])
members = await list_project_member_ids(cursor, [r["id"] for r in rows])
result = []
for r in rows:
p_row = _project_row(r)
p_row["workflow_state"] = states.get(r["id"], {"current_stage": 0, "stages": []})
# 참여자는 일반 사용자여도 그 프로젝트를 수정할 수 있다 (2026-09-06 사용자 확정).
p_row["member_user_ids"] = members.get(str(r["id"]), [])
result.append(p_row)
return result
@@ -222,7 +257,9 @@ async def get_project(project_id: str) -> dict[str, Any] | None:
return await cursor.fetchone()
async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -> bool:
async def update_project(
project_id: str, data: dict[str, Any], actor_id: int, request: Any | None = None
) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
@@ -258,17 +295,27 @@ async def update_project(project_id: str, data: dict[str, Any], actor_id: int) -
),
)
changed = cursor.rowcount > 0
if changed:
if not changed:
# 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 프로젝트가 없는 것과는 다르다
# (참여자만 바꿀 때 「찾을 수 없습니다」로 튕기던 자리, 2026-09-06).
await cursor.execute(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
VALUES (%s, 'PROJECT_UPDATE', 'project', NULL)""",
(actor_id,),
"SELECT 1 FROM projects WHERE id = %s AND deleted_at IS NULL", (project_id,)
)
changed = await cursor.fetchone() is not None
else:
await record_audit(
cursor,
actor_id=actor_id,
action="PROJECT_UPDATE",
resource_type="project",
resource_ref=project_id,
request=request,
)
await connection.commit()
return changed
async def soft_delete_project(project_id: str, actor_id: int) -> bool:
async def soft_delete_project(project_id: str, actor_id: int, request: Any | None = None) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
@@ -279,211 +326,18 @@ async def soft_delete_project(project_id: str, actor_id: int) -> bool:
)
changed = cursor.rowcount > 0
if changed:
await cursor.execute(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
VALUES (%s, 'PROJECT_DELETE', 'project', NULL)""",
(actor_id,),
await record_audit(
cursor,
actor_id=actor_id,
action="PROJECT_DELETE",
resource_type="project",
resource_ref=project_id,
request=request,
)
await connection.commit()
return changed
async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
if company_id is None:
return None
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
c.business_owner, c.business_status, c.logo_asset_id,
COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
WHERE c.id = %s AND c.deleted_at IS NULL
GROUP BY c.id""",
(company_id,),
)
return await cursor.fetchone()
async def search_companies(query: str) -> list[dict[str, Any]]:
pool = get_db_pool()
pattern = f"%{query}%"
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, name, business_registration_number, business_status
FROM companies
WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s)
ORDER BY name LIMIT 20""",
(pattern, pattern),
)
return list(await cursor.fetchall())
async def create_company(user_id: int, data: dict[str, Any]) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO companies
(name, business_registration_number, business_address, business_owner,
business_status, master_user_id, created_by, status)
VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""",
(
data["name"],
data["business_registration_number"],
data.get("business_address"),
data.get("business_owner"),
user_id,
user_id,
),
)
company_id = cursor.lastrowid
await cursor.execute(
"""UPDATE users
SET company_id = %s, role = 'ADMIN', is_master = TRUE,
status = 'ACTIVE'
WHERE id = %s""",
(company_id, user_id),
)
await cursor.execute(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
(user_id, company_id),
)
await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"}
except Exception:
await connection.rollback()
raise
async def create_system_company(actor_id: int, data: dict[str, Any]) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO companies
(name, business_registration_number, business_address, business_owner,
business_status, created_by, status)
VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""",
(
data["name"],
data["business_registration_number"],
data.get("business_address"),
data.get("business_owner"),
actor_id,
),
)
company_id = cursor.lastrowid
await cursor.execute(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
VALUES (%s, 'COMPANY_CREATE', 'company', %s)""",
(actor_id, company_id),
)
await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"}
except Exception:
await connection.rollback()
raise
async def join_company(user_id: int, company_id: int) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO join_requests (user_id, company_id, status)
VALUES (%s, %s, 'PENDING')
ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP,
reviewed_by = NULL, reviewed_at = NULL""",
(user_id, company_id),
)
request_id = cursor.lastrowid
await cursor.execute(
"UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,)
)
await connection.commit()
return {"join_request_id": request_id, "status": "PENDING"}
except Exception:
await connection.rollback()
raise
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
where = "WHERE jr.company_id = %s" if company_id else ""
params = (company_id,) if company_id else ()
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status,
u.email AS user_email, u.name AS user_name, c.name AS company_name
FROM join_requests jr
JOIN users u ON u.id = jr.user_id
JOIN companies c ON c.id = jr.company_id
{where}
ORDER BY jr.requested_at DESC LIMIT 100""",
params,
)
return list(await cursor.fetchall())
async def process_join_request(
request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None
) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await connection.begin()
where = "id = %s AND status = 'PENDING'"
params: tuple[Any, ...] = (request_id,)
if company_id is not None:
where += " AND company_id = %s"
params = (request_id, company_id)
await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params)
row = await cursor.fetchone()
if not row:
await connection.rollback()
return False
status = "APPROVED" if approved else "REJECTED"
await cursor.execute(
"""UPDATE join_requests SET status = %s, reviewed_by = %s,
reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""",
(status, reviewer_id, request_id),
)
if approved:
await cursor.execute(
"""UPDATE users SET company_id = %s, status = 'ACTIVE'
WHERE id = %s""",
(row["company_id"], row["user_id"]),
)
else:
await cursor.execute(
"UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],)
)
await connection.commit()
return True
async def list_all_companies() -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC"""
)
return list(await cursor.fetchall())
async def list_all_users() -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
@@ -503,7 +357,11 @@ async def list_all_users() -> list[dict[str, Any]]:
async def change_user_role(user_id: int, role: str) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("UPDATE users SET role = %s WHERE id = %s", (role, user_id))
# is_master 도 권한 판정에 쓰이므로 역할과 어긋나지 않게 함께 맞춘다.
await cursor.execute(
"UPDATE users SET role = %s, is_master = %s WHERE id = %s",
(role, role in ("ADMIN", "SYSTEM_ADMIN"), user_id),
)
changed = cursor.rowcount > 0
await connection.commit()
return changed
@@ -558,6 +416,26 @@ async def count_company_admins(company_id: int) -> int:
return int(row["cnt"] if row else 0)
async def soft_delete_user(user_id: int) -> bool:
"""사용자 계정을 지운다 (2026-09-06 사용자 확정 — 회사 관리자도 자기 회사 사람은 삭제).
지운 표시만 남기고 소속을 푼다. 상태가 INACTIVE 라 다음 요청에서 세션이 끊긴다.
이메일은 그대로 둔다 — 같은 주소로 다시 가입하려면 시스템 관리자가 되살려야 한다.
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE users
SET deleted_at = CURRENT_TIMESTAMP, status = 'INACTIVE',
company_id = NULL, is_master = FALSE
WHERE id = %s AND deleted_at IS NULL""",
(user_id,),
)
changed = cursor.rowcount > 0
await connection.commit()
return changed
async def assign_user_company(user_id: int, company_id: int | None) -> bool:
status = "ACTIVE" if company_id else "NO_COMPANY"
pool = get_db_pool()
@@ -578,7 +456,7 @@ async def list_audit_logs(limit: int, offset: int) -> dict[str, Any]:
total = (await cursor.fetchone())["total"]
await cursor.execute(
"""SELECT l.id, l.user_id, u.email, l.action, l.resource_type,
l.resource_id, l.timestamp
l.resource_id, l.resource_ref, l.ip_address, l.timestamp
FROM system_audit_logs l LEFT JOIN users u ON u.id = l.user_id
ORDER BY l.timestamp DESC LIMIT %s OFFSET %s""",
(limit, offset),
@@ -642,3 +520,38 @@ async def get_system_resources(days: int = 30) -> dict[str, Any]:
(bucket_seconds, bucket_seconds, since, bucket_seconds),
)
return {"current": current, "history": list(await cursor.fetchall()), "stats": current}
async def get_user_ui_prefs(user_id: int) -> dict[str, Any]:
"""계정에 붙은 화면 취향(배치·표시). 없으면 빈 묶음.
설계값이 아니라 **패널 높이·접힘 같은 배치 값**만 담는다(표 `user_ui_prefs` 주석 참조).
읽기 실패는 비치명 — 화면은 브라우저에 남은 값으로 그대로 돈다.
"""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute("SELECT prefs FROM user_ui_prefs WHERE user_id = %s", (user_id,))
row = await cursor.fetchone()
if not row or not row[0]:
return {}
raw = row[0]
if isinstance(raw, (bytes, bytearray)):
raw = raw.decode("utf-8")
if isinstance(raw, str):
try:
raw = json.loads(raw)
except json.JSONDecodeError:
return {}
return raw if isinstance(raw, dict) else {}
async def save_user_ui_prefs(user_id: int, prefs: dict[str, Any]) -> None:
"""화면 취향을 통째로 덮어쓴다 — 사용자당 한 줄."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""INSERT INTO user_ui_prefs (user_id, prefs) VALUES (%s, %s)
ON DUPLICATE KEY UPDATE prefs = VALUES(prefs)""",
(user_id, json.dumps(prefs, ensure_ascii=False)),
)
await connection.commit()
@@ -0,0 +1,264 @@
"""회사·가입 신청 저장소 (B01_Dashboard_Repository 에서 분리, 700줄 제한).
회사를 만들고 고치고 찾는 일, 그리고 그 회사에 들어가겠다는 신청을 다루는 곳이다.
사용자·프로젝트 쪽은 `B01_Dashboard_Repository` 에 남는다.
"""
from __future__ import annotations
from typing import Any
import aiomysql
from common_util.common_util_audit import record_audit
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import role_for_company
async def get_user_company(company_id: int | None) -> dict[str, Any] | None:
if company_id is None:
return None
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_address,
c.business_owner, c.business_status, c.logo_asset_id,
COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
WHERE c.id = %s AND c.deleted_at IS NULL
GROUP BY c.id""",
(company_id,),
)
return await cursor.fetchone()
async def update_company(company_id: int, data: dict[str, Any]) -> bool:
"""회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"""UPDATE companies
SET name = %s, business_registration_number = %s,
business_address = %s, business_owner = %s
WHERE id = %s AND deleted_at IS NULL""",
(
data["name"],
data["business_registration_number"],
data.get("business_address"),
data.get("business_owner"),
company_id,
),
)
changed = cursor.rowcount > 0
if not changed:
# 값이 하나도 안 바뀌면 rowcount 가 0 이다 — 회사가 없는 것과는 다르다.
await cursor.execute(
"SELECT 1 FROM companies WHERE id = %s AND deleted_at IS NULL", (company_id,)
)
changed = await cursor.fetchone() is not None
await connection.commit()
return changed
async def search_companies(query: str) -> list[dict[str, Any]]:
pool = get_db_pool()
pattern = f"%{query}%"
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, name, business_registration_number, business_status
FROM companies
WHERE deleted_at IS NULL AND (name LIKE %s OR business_registration_number LIKE %s)
ORDER BY name LIMIT 20""",
(pattern, pattern),
)
return list(await cursor.fetchall())
async def create_company(
user_id: int, data: dict[str, Any], request: Any | None = None
) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO companies
(name, business_registration_number, business_address, business_owner,
business_status, master_user_id, created_by, status)
VALUES (%s, %s, %s, %s, '활동중', %s, %s, 'ACTIVE')""",
(
data["name"],
data["business_registration_number"],
data.get("business_address"),
data.get("business_owner"),
user_id,
user_id,
),
)
company_id = cursor.lastrowid
await cursor.execute(
"""UPDATE users
SET company_id = %s, role = 'ADMIN', is_master = TRUE,
status = 'ACTIVE'
WHERE id = %s""",
(company_id, user_id),
)
await record_audit(
cursor,
actor_id=user_id,
action="COMPANY_CREATE",
resource_type="company",
resource_ref=company_id,
request=request,
)
await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"}
except Exception:
await connection.rollback()
raise
async def create_system_company(
actor_id: int, data: dict[str, Any], request: Any | None = None
) -> dict[str, Any]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
await cursor.execute(
"""INSERT INTO companies
(name, business_registration_number, business_address, business_owner,
business_status, created_by, status)
VALUES (%s, %s, %s, %s, '활동중', %s, 'ACTIVE')""",
(
data["name"],
data["business_registration_number"],
data.get("business_address"),
data.get("business_owner"),
actor_id,
),
)
company_id = cursor.lastrowid
await record_audit(
cursor,
actor_id=actor_id,
action="COMPANY_CREATE",
resource_type="company",
resource_ref=company_id,
request=request,
)
await connection.commit()
return {"company_id": company_id, "status": "ACTIVE"}
except Exception:
await connection.rollback()
raise
async def join_company(user_id: int, company_id: int) -> dict[str, Any] | None:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
try:
await connection.begin()
# 이미 소속이 있는 사람의 재신청은 막는다 (2026-09-06 사용자 지시) —
# 예전에는 신청 즉시 소속이 풀리고 승인대기로 떨어져 회사를 잃었다.
await cursor.execute(
"SELECT company_id FROM users WHERE id = %s AND deleted_at IS NULL FOR UPDATE",
(user_id,),
)
current = await cursor.fetchone()
if current and current[0]:
await connection.rollback()
return None
await cursor.execute(
"""INSERT INTO join_requests (user_id, company_id, status)
VALUES (%s, %s, 'PENDING')
ON DUPLICATE KEY UPDATE status = 'PENDING', requested_at = CURRENT_TIMESTAMP,
reviewed_by = NULL, reviewed_at = NULL""",
(user_id, company_id),
)
request_id = cursor.lastrowid
await cursor.execute(
"UPDATE users SET status = 'PENDING', company_id = NULL WHERE id = %s", (user_id,)
)
await connection.commit()
return {"join_request_id": request_id, "status": "PENDING"}
except Exception:
await connection.rollback()
raise
async def list_join_requests(company_id: int | None = None) -> list[dict[str, Any]]:
# 처리 끝난 신청은 목록에 남기지 않는다 (2026-09-06 사용자 지시) — 승인된 사람은
# 사용자 관리 목록에 이미 있어 같은 사람이 두 번 보였다.
where = "WHERE jr.status = 'PENDING'" + (" AND jr.company_id = %s" if company_id else "")
params = (company_id,) if company_id else ()
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
f"""SELECT jr.id, jr.user_id, jr.company_id, jr.requested_at, jr.status,
u.email AS user_email, u.name AS user_name, c.name AS company_name
FROM join_requests jr
JOIN users u ON u.id = jr.user_id
JOIN companies c ON c.id = jr.company_id
{where}
ORDER BY jr.requested_at DESC LIMIT 100""",
params,
)
return list(await cursor.fetchall())
async def process_join_request(
request_id: int, reviewer_id: int, approved: bool, company_id: int | None = None
) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await connection.begin()
where = "id = %s AND status = 'PENDING'"
params: tuple[Any, ...] = (request_id,)
if company_id is not None:
where += " AND company_id = %s"
params = (request_id, company_id)
await cursor.execute(f"SELECT * FROM join_requests WHERE {where} FOR UPDATE", params)
row = await cursor.fetchone()
if not row:
await connection.rollback()
return False
status = "APPROVED" if approved else "REJECTED"
await cursor.execute(
"""UPDATE join_requests SET status = %s, reviewed_by = %s,
reviewed_at = CURRENT_TIMESTAMP WHERE id = %s""",
(status, reviewer_id, request_id),
)
if approved:
# 시스템 회사로 들어오면 역할도 함께 올린다 (2026-09-06 사용자 확정).
role = await role_for_company(int(row["company_id"]))
await cursor.execute(
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s
WHERE id = %s""",
(row["company_id"], role, row["user_id"]),
)
else:
await cursor.execute(
"UPDATE users SET status = 'REJECTED' WHERE id = %s", (row["user_id"],)
)
await connection.commit()
return True
async def list_all_companies() -> list[dict[str, Any]]:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT c.id, c.name, c.business_registration_number, c.business_status,
c.logo_asset_id, c.created_at, COUNT(DISTINCT u.id) AS user_count,
COUNT(DISTINCT p.id) AS project_count
FROM companies c
LEFT JOIN users u ON u.company_id = c.id AND u.deleted_at IS NULL
LEFT JOIN projects p ON p.company_id = c.id AND p.deleted_at IS NULL
WHERE c.deleted_at IS NULL GROUP BY c.id ORDER BY c.created_at DESC"""
)
return list(await cursor.fetchall())
@@ -6,16 +6,14 @@
from __future__ import annotations
import secrets
from typing import Any
import aiomysql
from fastapi import HTTPException
from common_util.common_util_auth import hash_password
from config.config_db import get_db_pool
from .B01_Dashboard_Repository import _role, get_dashboard_me
from .B01_Dashboard_Repository import _role, get_dashboard_me, role_for_company
from .B01_Dashboard_Repository_Assets import list_company_assets
@@ -34,53 +32,56 @@ async def list_company_members(company_id: int) -> list[dict[str, Any]]:
return rows
async def add_company_member(
company_id: int,
email: str,
profile: dict[str, Any] | None = None,
) -> dict[str, Any] | None:
"""회사에 사람을 붙인다.
async def list_unassigned_users(query: str) -> list[dict[str, Any]]:
"""소속이 없는 가입자만 찾는다 (2026-09-06 사용자 확정).
`profile["name"]` 이 있으면 **계정이 없는 사람도 그 자리에서 만든다**
(2026-09-02 사용자 확정 — 담당자 선택의 「신규 등록…」). 새 계정은 로그인할 수 없는
비밀번호로 서고(`status='PENDING'`), 본인이 비밀번호를 세우면 그때 쓰인다.
다른 회사 소속자는 보이지 않는다 — 팀원 등록은 「이미 가입한 사람을 고르는」 일이다.
"""
like = f"%{query.strip()}%"
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await cursor.execute(
"""SELECT id, name, position, email, department, status
FROM users
WHERE company_id IS NULL AND deleted_at IS NULL
AND status IN ('NO_COMPANY', 'PENDING')
AND (name LIKE %s OR email LIKE %s)
ORDER BY name, email LIMIT 20""",
(like, like),
)
return list(await cursor.fetchall())
async def attach_company_member(company_id: int, user_id: int) -> dict[str, Any] | None:
"""가입한 사람을 회사에 붙인다. 소속이 이미 있으면 붙이지 않는다."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor:
await connection.begin()
await cursor.execute(
"""SELECT id, company_id FROM users
WHERE email = %s AND deleted_at IS NULL FOR UPDATE""",
(email.lower(),),
WHERE id = %s AND deleted_at IS NULL FOR UPDATE""",
(user_id,),
)
user = await cursor.fetchone()
if not user and profile and profile.get("name"):
await cursor.execute(
"""INSERT INTO users (email, password_hash, name, position, department,
company_id, role, status)
VALUES (%s, %s, %s, %s, %s, %s, 'USER', 'PENDING')""",
(
email.lower(),
hash_password(secrets.token_urlsafe(32)), # 아무도 못 맞히는 비밀번호
profile["name"],
profile.get("position"),
profile.get("department"),
company_id,
),
)
await connection.commit()
return await get_dashboard_me(cursor.lastrowid)
if not user or user["company_id"] == company_id:
if not user or user["company_id"] is not None:
await connection.rollback()
return None
await cursor.execute(
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = 'USER',
"""UPDATE users SET company_id = %s, status = 'ACTIVE', role = %s,
is_master = FALSE
WHERE id = %s""",
(company_id, user["id"]),
(company_id, await role_for_company(company_id), user_id),
)
# 남아 있던 가입 신청은 닫는다 — 목록에 같은 사람이 두 번 보이지 않게 한다.
await cursor.execute(
"""UPDATE join_requests
SET status = CASE WHEN company_id = %s THEN 'APPROVED' ELSE 'REJECTED' END,
reviewed_at = CURRENT_TIMESTAMP
WHERE user_id = %s AND status = 'PENDING'""",
(company_id, user_id),
)
await connection.commit()
return await get_dashboard_me(user["id"])
return await get_dashboard_me(user_id)
async def remove_company_member(company_id: int, user_id: int) -> bool:
@@ -121,6 +122,8 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None:
user_ids = {
data.get(k) for k in ("pm_user_id", "field_lead_user_id", "designer_user_id") if data.get(k)
}
# 참여자도 같은 회사 사람이어야 한다 (2026-09-06 사용자 확정).
user_ids |= {int(uid) for uid in (data.get("member_user_ids") or [])}
if user_ids and not user_ids <= {m["id"] for m in await list_company_members(company_id)}:
raise HTTPException(status_code=400, detail="담당자는 같은 회사 구성원이어야 합니다.")
wanted = {
@@ -132,3 +135,45 @@ async def check_project_refs(company_id: int, data: dict[str, Any]) -> None:
kinds = {a["id"]: a["kind"] for a in await list_company_assets(company_id)}
if any(kinds.get(data[k]) != kind for k, kind in wanted.items()):
raise HTTPException(status_code=400, detail="로고·서명은 같은 회사 자산이어야 합니다.")
async def list_project_member_ids(cursor: Any, project_ids: list[str]) -> dict[str, list[int]]:
"""프로젝트별 참여자 id 목록 (2026-09-06 사용자 확정)."""
if not project_ids:
return {}
marks = ", ".join(["%s"] * len(project_ids))
await cursor.execute(
f"""SELECT project_id, user_id FROM project_members
WHERE project_id IN ({marks}) ORDER BY user_id""",
tuple(project_ids),
)
result: dict[str, list[int]] = {}
for row in await cursor.fetchall():
key = row["project_id"] if isinstance(row, dict) else row[0]
value = row["user_id"] if isinstance(row, dict) else row[1]
result.setdefault(str(key), []).append(int(value))
return result
async def set_project_members(project_id: str, user_ids: list[int]) -> None:
"""참여자 목록을 통째로 맞춘다. 만든 사람은 화면에서 늘 포함해 보낸다."""
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await connection.begin()
await cursor.execute("DELETE FROM project_members WHERE project_id = %s", (project_id,))
for user_id in dict.fromkeys(user_ids):
await cursor.execute(
"INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)",
(project_id, int(user_id)),
)
await connection.commit()
async def is_project_member(project_id: str, user_id: int) -> bool:
pool = get_db_pool()
async with pool.acquire() as connection, connection.cursor() as cursor:
await cursor.execute(
"SELECT 1 FROM project_members WHERE project_id = %s AND user_id = %s",
(project_id, user_id),
)
return await cursor.fetchone() is not None
+220 -38
View File
@@ -1,36 +1,48 @@
"""B01_Dashboard 역할별 대시보드 API."""
import html
import mimetypes
import os
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Response, UploadFile
from fastapi import (
APIRouter,
Depends,
File,
Form,
HTTPException,
Path,
Query,
Request,
Response,
UploadFile,
)
from pymysql.err import IntegrityError
from common_util.common_util_auth import require_company, require_system_admin, verify_session
from common_util.common_util_email import send_email
from common_util.common_util_project_delete import hard_delete_project
from common_util.common_util_storage import read_stored_asset
from config.config_system import PROJECT_DELETE_HARD_ENABLED
from config.config_system import APP_PUBLIC_BASE_URL, PROJECT_DELETE_HARD_ENABLED
from .B01_Dashboard_Map import fetch_base_tile, geocode_address
from .B01_Dashboard_Repository import (
assign_user_company,
change_user_role,
create_company,
count_company_admins,
get_dashboard_me,
get_project,
get_system_resources,
get_user_admin_target,
get_user_company,
join_company,
list_all_companies,
get_user_ui_prefs,
list_all_projects,
list_all_users,
list_audit_logs,
list_company_projects,
list_join_requests,
list_user_projects,
process_join_request,
search_companies,
save_user_ui_prefs,
soft_delete_project,
soft_delete_user,
update_admin_user,
update_project,
update_user_profile,
@@ -46,12 +58,25 @@ from .B01_Dashboard_Repository_Assets import (
update_company_asset,
write_company_asset_file,
)
from .B01_Dashboard_Repository_Company import (
create_company,
get_user_company,
join_company,
list_all_companies,
list_join_requests,
process_join_request,
search_companies,
update_company,
)
from .B01_Dashboard_Repository_Members import (
add_company_member,
attach_company_member,
check_project_refs,
is_project_member,
list_company_members,
list_unassigned_users,
remove_company_member,
set_company_logo,
set_project_members,
)
from .B01_Dashboard_Schema import (
AddMemberRequest,
@@ -59,8 +84,10 @@ from .B01_Dashboard_Schema import (
AssignCompanyRequest,
ChangeUserRoleRequest,
CreateCompanyRequest,
InviteMemberRequest,
JoinCompanyRequest,
ProcessJoinRequest,
UiPrefsRequest,
UpdateCompanyAssetRequest,
UpdateCompanyLogoRequest,
UpdateProjectRequest,
@@ -107,12 +134,15 @@ async def _company_asset(session: dict[str, Any], asset_id: int) -> dict[str, An
return asset
def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
async def _can_edit_project(session: dict[str, Any], project: dict[str, Any]) -> bool:
if session["role"] == "SYSTEM_ADMIN":
return True
if session["role"] == "ADMIN":
return _same_company(session, project.get("company_id"))
return False
# 참여자로 지정된 일반 사용자도 수정할 수 있다 (2026-09-06 사용자 확정).
return _same_company(session, project.get("company_id")) and await is_project_member(
str(project["id"]), int(session["user_id"])
)
def _can_edit_user(session: dict[str, Any], target: dict[str, Any]) -> bool:
@@ -142,9 +172,34 @@ async def patch_dashboard_me(
return {"status": "success", "user": user}
# ── 화면 취향 (2026-09-07 사용자 승인) ───────────────────────────────────────
# 패널 높이·접힘 같은 **배치 값**만 계정에 붙인다. 설계값은 여기 오지 않는다 — 초안·결과는
# 브라우저에 두고 [저장]·[확정]에서만 정본으로 간다(CLAUDE.md 5장).
# 브라우저는 이 값을 **로컬 사본 위에 병합**하고, 서버가 없거나 못 읽으면 로컬 값으로 돈다.
@router.get("/me/ui-prefs")
async def read_ui_prefs(session: dict[str, Any] = Depends(verify_session)):
return {"status": "success", "prefs": await get_user_ui_prefs(int(session["user_id"]))}
@router.put("/me/ui-prefs")
async def write_ui_prefs(
payload: UiPrefsRequest,
session: dict[str, Any] = Depends(verify_session),
):
await save_user_ui_prefs(int(session["user_id"]), payload.prefs)
return {"status": "success"}
@router.get("/user/projects")
async def user_projects(session: dict[str, Any] = Depends(verify_session)):
return {"status": "success", "projects": await list_user_projects(int(session["user_id"]))}
# 회사에 속하면 회사 프로젝트 전체를 본다 (2026-09-06 사용자 지시) — 수정 권한은 따로다.
company_id = session.get("company_id")
projects = (
await list_company_projects(int(company_id))
if company_id
else await list_user_projects(int(session["user_id"]))
)
return {"status": "success", "projects": projects}
@router.get("/user/company")
@@ -163,19 +218,74 @@ async def user_company_search(
@router.post("/user/company/create")
async def user_company_create(
request: Request,
payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(verify_session),
):
result = await create_company(int(session["user_id"]), payload.model_dump())
result = await create_company(int(session["user_id"]), payload.model_dump(), request)
return {"status": "success", **result}
@router.put("/company")
async def company_update(
payload: CreateCompanyRequest,
company_id: int | None = Query(None, gt=0),
session: dict[str, Any] = Depends(require_company_admin),
):
"""회사 정보 수정 — 시스템관리자만 남의 회사를 지정할 수 있다."""
try:
changed = await update_company(_scope_company(session, company_id), payload.model_dump())
except IntegrityError as exc:
raise HTTPException(
status_code=409, detail="같은 회사명 또는 사업자등록번호가 이미 있습니다."
) from exc
if not changed:
raise HTTPException(status_code=404, detail="회사를 찾을 수 없습니다.")
return {"status": "success"}
@router.get("/company/geocode")
async def company_geocode(
address: str = Query(min_length=2, max_length=500),
session: dict[str, Any] = Depends(verify_session),
):
"""주소를 좌표로 바꾼다 — 회사 등록·수정 화면의 지도 미리보기용."""
_ = session
point = await geocode_address(address)
if not point:
raise HTTPException(status_code=404, detail="주소를 찾지 못했습니다.")
return {"status": "success", **point}
@router.get("/map/tile/{z}/{x}/{y}")
async def map_tile(
z: int = Path(ge=0, le=19),
x: int = Path(ge=0),
y: int = Path(ge=0),
session: dict[str, Any] = Depends(verify_session),
):
"""배경지도 타일 — VWorld 키가 화면으로 새지 않게 서버가 받아 넘긴다."""
_ = session
if x >= 2**z or y >= 2**z:
raise HTTPException(status_code=400, detail="지도 타일 번호가 범위를 벗어났습니다.")
try:
tile = await fetch_base_tile(z, x, y)
except Exception as exc: # 지도 한 칸이 비는 것은 화면을 막을 일이 아니다
raise HTTPException(status_code=502, detail="지도를 불러오지 못했습니다.") from exc
return Response(content=tile, media_type="image/png", headers={"Cache-Control": "max-age=3600"})
@router.post("/user/company/join")
async def user_company_join(
payload: JoinCompanyRequest,
session: dict[str, Any] = Depends(verify_session),
):
result = await join_company(int(session["user_id"]), payload.company_id)
if result is None:
raise HTTPException(
status_code=409,
detail="이미 회사에 소속돼 있습니다. 소속을 옮기려면 회사 관리자에게 요청하십시오.",
)
return {"status": "success", **result}
@@ -193,25 +303,52 @@ async def admin_members(
}
@router.get("/admin/members/candidates")
async def admin_member_candidates(
q: str = Query(min_length=2, max_length=100),
session: dict[str, Any] = Depends(require_company_admin),
):
"""팀원으로 부를 수 있는 사람 — 소속이 없는 가입자만 (2026-09-06 사용자 확정)."""
_ = session
return {"status": "success", "users": await list_unassigned_users(q)}
@router.post("/admin/members")
async def admin_add_member(
payload: AddMemberRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
member = await add_company_member(
_require_company_id(session),
payload.email,
{
"name": payload.name,
"position": payload.position,
"department": payload.department,
},
)
member = await attach_company_member(_require_company_id(session), payload.user_id)
if not member:
raise HTTPException(status_code=409, detail="사용자를 찾을 수 없거나 이미 팀원입니다.")
raise HTTPException(
status_code=409, detail="이미 다른 회사 소속이거나 찾을 수 없는 사용자입니다."
)
return {"status": "success", "member": member}
@router.post("/admin/members/invite")
async def admin_invite_member(
payload: InviteMemberRequest,
session: dict[str, Any] = Depends(require_company_admin),
):
"""아직 가입하지 않은 사람에게 안내 메일만 보낸다 — 계정을 대신 만들지 않는다."""
company = await get_user_company(session.get("company_id"))
company_name = (company or {}).get("name") or "회사"
# 메일 본문에 사람이 넣은 글자가 그대로 들어가면 남의 편지함에 임의 HTML 을 보낼 수 있다.
safe_company = html.escape(company_name)
safe_name = html.escape(payload.name or "")
sent = await send_email(
payload.email,
f"[Aislo] {company_name} 팀원 등록 안내",
f"<p>{safe_name}님, {safe_company} 에서 Aislo 팀원으로 등록하려 합니다.</p>"
f"<p>아래 주소에서 가입한 뒤 알려 주시면 회사 관리자가 팀원으로 등록합니다.</p>"
f'<p><a href="{APP_PUBLIC_BASE_URL}">{APP_PUBLIC_BASE_URL}</a></p>',
)
if not sent:
raise HTTPException(status_code=502, detail="안내 메일을 보내지 못했습니다.")
return {"status": "success"}
@router.delete("/admin/members/{user_id}")
async def admin_remove_member(
user_id: int, session: dict[str, Any] = Depends(require_company_admin)
@@ -255,13 +392,14 @@ async def admin_projects(session: dict[str, Any] = Depends(require_company_admin
@router.put("/projects/{project_id}")
async def dashboard_update_project(
project_id: str,
request: Request,
payload: UpdateProjectRequest,
session: dict[str, Any] = Depends(verify_session),
):
project = await get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if not _can_edit_project(session, project):
if not await _can_edit_project(session, project):
raise HTTPException(status_code=403, detail="프로젝트 수정 권한이 없습니다.")
data = payload.model_dump()
# 시작이 종료보다 뒤면 남는 구간이 없다 — B02 등록과 같은 규칙 (2026-09-04 사용자 지시).
@@ -272,34 +410,35 @@ async def dashboard_update_project(
detail="노선 시작 누가거리는 종료 누가거리보다 작아야 합니다.",
)
await check_project_refs(int(project["company_id"]), data)
if not await update_project(project_id, data, int(session["user_id"])):
member_ids = data.pop("member_user_ids", None)
if not await update_project(project_id, data, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if member_ids is not None:
# 만든 사람은 늘 참여자로 남는다.
await set_project_members(project_id, [int(project["user_id"]), *member_ids])
return {"status": "success"}
@router.delete("/projects/{project_id}")
async def dashboard_delete_project(
project_id: str,
request: Request,
session: dict[str, Any] = Depends(verify_session),
):
project = await get_project(project_id)
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
can_del = False
if session["role"] == "SYSTEM_ADMIN":
can_del = True
# 나중에 ADMIN도 소유 프로젝트 삭제 허용할 수 있으므로 주석 처리
# elif session["role"] == "ADMIN":
# if int(project.get("user_id") or 0) == int(session["user_id"]):
# can_del = True
# 회사 관리자는 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정).
can_del = session["role"] == "SYSTEM_ADMIN" or (
session["role"] == "ADMIN" and _same_company(session, project.get("company_id"))
)
if not can_del:
raise HTTPException(status_code=403, detail="프로젝트 삭제 권한이 없습니다.")
# 개발 PC에서만 하드 삭제. 배포 기본값은 지금까지처럼 소프트 삭제다.
delete_project = hard_delete_project if PROJECT_DELETE_HARD_ENABLED else soft_delete_project
if not await delete_project(project_id, int(session["user_id"])):
if not await delete_project(project_id, int(session["user_id"]), request):
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
return {"status": "success"}
@@ -312,10 +451,11 @@ async def system_companies(session: dict[str, Any] = Depends(require_system_admi
@router.post("/admin/companies")
async def system_create_company(
request: Request,
payload: CreateCompanyRequest,
session: dict[str, Any] = Depends(require_system_admin),
):
result = await create_company(int(session["user_id"]), payload.model_dump())
result = await create_company(int(session["user_id"]), payload.model_dump(), request)
return {"status": "success", **result}
@@ -329,14 +469,29 @@ async def system_users(session: dict[str, Any] = Depends(require_system_admin)):
async def system_change_role(
user_id: int,
payload: ChangeUserRoleRequest,
session: dict[str, Any] = Depends(require_system_admin),
session: dict[str, Any] = Depends(require_company_admin),
):
target = await get_user_admin_target(user_id)
if not target:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
# 회사 관리자는 자기 회사 사람만 (2026-09-06 사용자 확정). 시스템관리자는 전역이다.
if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")):
raise HTTPException(status_code=403, detail="다른 회사 사용자는 바꿀 수 없습니다.")
if payload.role == "SYSTEM_ADMIN" or target["role"] == "SYSTEM_ADMIN":
raise HTTPException(
status_code=403, detail="시스템 관리자 역할은 API에서 변경할 수 없습니다."
status_code=403,
detail="시스템 관리자 역할은 시스템 관리 회사 소속 여부로 정해집니다.",
)
# 회사에 관리자가 하나도 남지 않게 되는 강등은 막는다 (본인 강등 포함).
if (
target["role"] == "ADMIN"
and payload.role != "ADMIN"
and target.get("company_id")
and await count_company_admins(int(target["company_id"])) <= 1
):
raise HTTPException(
status_code=409,
detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.",
)
if not await change_user_role(user_id, payload.role):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
@@ -362,6 +517,33 @@ async def admin_update_user(
return {"status": "success"}
@router.delete("/admin/users/{user_id}")
async def admin_delete_user(
user_id: int,
session: dict[str, Any] = Depends(require_company_admin),
):
target = await get_user_admin_target(user_id)
if not target:
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
if session["role"] != "SYSTEM_ADMIN" and not _same_company(session, target.get("company_id")):
raise HTTPException(status_code=403, detail="다른 회사 사용자는 지울 수 없습니다.")
if int(session["user_id"]) == user_id:
raise HTTPException(status_code=409, detail="본인 계정은 지울 수 없습니다.")
# 회사에 관리자가 하나도 남지 않게 되는 삭제는 막는다.
if (
target["role"] == "ADMIN"
and target.get("company_id")
and await count_company_admins(int(target["company_id"])) <= 1
):
raise HTTPException(
status_code=409,
detail="회사에 관리자가 한 명뿐입니다. 다른 관리자를 먼저 지정하십시오.",
)
if not await soft_delete_user(user_id):
raise HTTPException(status_code=404, detail="사용자를 찾을 수 없습니다.")
return {"status": "success"}
@router.patch("/admin/users/{user_id}/company")
async def system_assign_company(
user_id: int,
+21 -4
View File
@@ -12,6 +12,16 @@ class UpdateUserRequest(BaseModel):
phone: str | None = Field(default=None, max_length=50)
class UiPrefsRequest(BaseModel):
"""화면 취향 묶음 — 배치·표시 값만. 키는 브라우저 등록표(`b_page_state.ts`)가 정본이다.
값은 문자열 하나(높이는 `"438"`, 접힘은 `"true"`) 저장소에 넣던 모양 그대로다.
설계값이 섞여 들어오지 않게 **문자열만** 받는다.
"""
prefs: dict[str, str] = Field(default_factory=dict)
class CreateCompanyRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
business_registration_number: str = Field(min_length=1, max_length=20)
@@ -24,12 +34,16 @@ class JoinCompanyRequest(BaseModel):
class AddMemberRequest(BaseModel):
# 팀원 등록은 「이미 가입한 사람을 고르는」 일이다 (2026-09-06 사용자 확정) —
# 계정을 대신 만들지 않는다. 소속이 없는 가입자만 고를 수 있다.
user_id: int = Field(gt=0)
class InviteMemberRequest(BaseModel):
"""아직 가입하지 않은 사람에게 보내는 가입 안내 메일."""
email: str = Field(min_length=3, max_length=255)
# 계정이 아직 없는 사람도 담당자로 넣는다 (2026-09-02 사용자 확정) — 이름이 있으면
# 그 자리에서 계정을 만든다. 비어 있으면 기존 사용자를 회사에 붙이는 옛 동작이다.
name: str | None = Field(default=None, max_length=100)
position: str | None = Field(default=None, max_length=100)
department: str | None = Field(default=None, max_length=100)
class UpdateCompanyLogoRequest(BaseModel):
@@ -52,6 +66,9 @@ class AssignCompanyRequest(BaseModel):
class UpdateProjectRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
# 참여자 (2026-09-06 사용자 확정) — 도면 표제란 3역할과 별개로, 설계에 손대는 사람들.
# 참여자면 일반 사용자도 그 프로젝트를 수정할 수 있다. 비우면 지금 값을 그대로 둔다.
member_user_ids: list[int] | None = Field(default=None)
region: str | None = Field(default=None, max_length=100)
road_type: str | None = Field(default=None, max_length=100)
project_year: int | None = Field(default=None, ge=1900, le=2100)
+57 -6
View File
@@ -1,9 +1,19 @@
import { createButton } from "@ui/ui_template_elements";
import type { AuditLog, DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { canChangeRole } from "./B01_Dashboard_UI_Helper";
import { openChangeRoleModal, openEditUserModal } from "./B01_Dashboard_UI_Modals";
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openChangeRoleModal,
openDeleteUserModal,
openEditUserModal,
} from "./B01_Dashboard_UI_Modals";
import { table, text } from "@ui/ui_template_general_blocks";
import { DASHBOARD_VISIBLE_ROWS, formatDate, L } from "./B01_Dashboard_UI_Common";
import {
DASHBOARD_VISIBLE_ROWS,
formatDate,
formatTime,
L,
stackedCell,
} from "./B01_Dashboard_UI_Common";
export function userTable(users: DashboardUser[], currentUser: DashboardUser): HTMLElement {
return table(
@@ -39,6 +49,17 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
);
}
// 회사에서 빼기·계정 삭제 (2026-09-06 사용자 지시) — 범위는 백엔드가 다시 본다.
if (canDeleteUser(currentUser, user) && user.id !== currentUser.id) {
actionsEl.append(
createButton({
label: L("B01_Dashboard_DeleteUser"),
variant: "ghost",
onClick: () => openDeleteUserModal(user),
}),
);
}
return [
text(user.email),
text(user.name),
@@ -54,14 +75,44 @@ export function userTable(users: DashboardUser[], currentUser: DashboardUser): H
);
}
/** 무엇에 한 일인지 — 표에 저장된 대상 종류·번호를 사람이 읽는 말로. */
function auditTarget(log: AuditLog): string {
// 저장값은 소문자(`project`)로 들어온다 — 대문자로 맞춰 찾는다.
const key = (log.resource_type ?? "").toUpperCase();
const kind = TARGET_LABELS[key] ?? log.resource_type ?? "";
const reference = log.resource_ref ?? (log.resource_id ? String(log.resource_id) : "");
if (!kind) return reference || "-";
// 프로젝트 UUID 는 길어 앞 8자만 — 어느 프로젝트인지 가리기에는 충분하다.
const shortened = reference.length > 12 ? `${reference.slice(0, 8)}` : reference;
return shortened ? `${kind} ${shortened}` : kind;
}
const TARGET_LABELS: Record<string, string> = {
PROJECT: "프로젝트",
COMPANY: "회사",
USER: "사용자",
ASSET: "자산",
};
export function auditLogTable(logs: AuditLog[]): HTMLElement {
return table(
[
L("B01_Dashboard_Table_Email"),
L("B01_Dashboard_Table_Action"),
L("B01_Dashboard_Table_Updated"),
// 관리 버튼 열과 같은 말(「관리」)을 돌려 쓰던 것을 갈랐다 (2026-09-06 사용자 지적).
L("B01_Dashboard_Table_Event"),
L("B01_Dashboard_Table_Target"),
L("B01_Dashboard_Table_Origin"),
L("B01_Dashboard_Table_When"),
],
logs.map((log) => [text(log.email), text(log.action), text(formatDate(log.timestamp))]),
logs.map((log) => [
text(log.email),
text(log.action),
text(auditTarget(log)),
// 접속 주소 — 기록이 없는 옛 줄은 빈칸으로 남는다 (2026-09-06부터 기록).
text(log.ip_address ?? "-"),
// 날짜와 시각을 두 줄로 — 아랫줄이 작은 글씨라 행 높이는 그대로다.
stackedCell(formatDate(log.timestamp), formatTime(log.timestamp)),
]),
DASHBOARD_VISIBLE_ROWS,
);
}
@@ -303,13 +303,16 @@ function openAssetPickerModal(
mineBox.checked = owner !== null || kind === "SIGNATURE";
// 주인이 못박힌 칸(사용자 서명)은 그 사람에게만 물린다 — 체크를 풀 수 없다.
mineBox.disabled = owner !== null;
// 무슨 뜻인지 읽히게 고침 (2026-09-06 사용자 지시) — 체크를 풀면 회사 공용이 된다.
mine.append(
mineBox,
document.createTextNode(` ${owner ? owner.name : `내 계정(${user.name})`}에 물리기`),
document.createTextNode(
` 이 그림을 ${owner ? owner.name : `내 계정(${user.name})`}의 것으로 지정 (풀면 회사 공용)`,
),
);
const pad = kind === "SIGNATURE" ? createSignaturePad() : null;
const add = createButton({
label: "올리고 선택",
label: "파일 올리고 이 프로젝트에 쓰기",
onClick: async () => {
if (!label.input.value.trim()) return label.setError("이름을 넣어 주세요.");
const chosen = file.input.files?.[0] ?? (await pad?.toFile()) ?? null;
+95
View File
@@ -1,5 +1,6 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createInputField,
hideLoadingOverlay,
showConfirmDialog,
showLoadingOverlay,
@@ -39,6 +40,32 @@ export function formatDate(value?: string | null): string {
return value ? value.slice(0, 10) : "-";
}
/** 시:분 — 표에서 날짜 아래 줄에 붙인다 (2026-09-06 사용자 지시). */
export function formatTime(value?: string | null): string {
if (!value) return "";
const time = value.includes("T") ? value.split("T")[1] : value.slice(11);
return time ? time.slice(0, 5) : "";
}
/**
* · . ** **.
* / .
*/
export function stackedCell(top: string, bottom: string): HTMLElement {
const cell = document.createElement("div");
cell.className = "b01-dashboard__stacked";
const first = document.createElement("span");
first.textContent = top;
cell.append(first);
if (bottom) {
const second = document.createElement("span");
second.className = "b01-dashboard__stacked-sub";
second.textContent = bottom;
cell.append(second);
}
return cell;
}
/* -----------------------------------------------------------------------------
* (2026-09-04 )
*
@@ -142,3 +169,71 @@ export function attachModalDismiss(
return { isDirty, tryClose };
}
/**
*
* (2026-09-06 , 릿 ). > > > >
* .
*/
export function buildUserFields(
source: {
name: string;
email?: string;
position?: string | null;
department?: string | null;
phone?: string | null;
},
/** 본인 정보 화면에서는 「팀원 이메일」이 아니라 「이메일」이다 (2026-09-06 사용자 지시). */
options: { self?: boolean } = {},
): {
grid: HTMLElement;
validate: () => boolean;
values: () => {
name: string;
position: string | null;
department: string | null;
phone: string | null;
};
} {
const name = createInputField({
label: L("B01_Account_Field_Name"),
value: source.name,
required: true,
});
const position = createInputField({
label: L("B01_Dashboard_Table_Position"),
value: source.position ?? "",
});
const email = createInputField({
label: L(options.self ? "B01_Dashboard_Field_Email" : "B01_Dashboard_Field_MemberEmail"),
type: "email",
value: source.email ?? "",
});
email.input.disabled = true;
const department = createInputField({
label: L("B01_Dashboard_Table_Department"),
value: source.department ?? "",
});
const phone = createInputField({
label: L("B01_Account_Field_Phone"),
value: source.phone ?? "",
});
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(name.root, position.root, email.root, department.root, phone.root);
return {
grid,
validate: () => {
name.setError();
if (name.input.value.trim()) return true;
name.setError(L("Common_Msg_RequiredField"));
return false;
},
values: () => ({
name: name.input.value.trim(),
position: position.input.value.trim() || null,
department: department.input.value.trim() || null,
phone: phone.input.value.trim() || null,
}),
};
}
+20
View File
@@ -12,6 +12,7 @@ import { createAssetField, openAssetPicker } from "./B01_Dashboard_UI_AssetPicke
import { canChangeRole, canDeleteUser } from "./B01_Dashboard_UI_Helper";
import {
openChangeRoleModal,
openEditCompanyModal,
openCreateCompanyModal,
openDeleteUserModal,
openEditUserModal,
@@ -44,6 +45,17 @@ export function buildCompanyPanel(state: DashboardState): HTMLElement {
text(`${L("B01_Dashboard_Metric_ActiveUsers")}: ${state.company.user_count ?? 0}`),
text(`${L("B01_Dashboard_Projects")}: ${state.company.project_count ?? 0}`),
);
// 회사 정보 수정 (2026-09-06 사용자 지시) — 관리자만 보인다.
if (state.user.role !== "USER" && state.company) {
const company = state.company;
wrap.append(
createButton({
label: "회사 정보 수정",
variant: "ghost",
onClick: () => openEditCompanyModal(company),
}),
);
}
// 회사 대표 로고 — 등록 단계에서 받은 것을 여기서 바꾼다 (2026-09-02 사용자 확정).
// 프로젝트가 따로 고르지 않으면 도면이 이 로고를 쓴다.
if (state.user.role !== "USER") {
@@ -173,12 +185,20 @@ export function companyTable(companies: CompanyInfo[], user?: DashboardUser): HT
L("B01_Dashboard_Field_BusinessNumber"),
L("B01_Dashboard_Table_Status"),
"로고",
L("B01_Dashboard_Table_Action"),
],
companies.map((company) => [
text(company.name),
text(company.business_registration_number),
text(company.business_status),
logoCell(company),
user
? createButton({
label: L("Common_Btn_Edit"),
variant: "ghost",
onClick: () => openEditCompanyModal(company),
})
: text(""),
]),
DASHBOARD_VISIBLE_ROWS,
);
+8 -7
View File
@@ -1,15 +1,15 @@
import type { DashboardUser, ProjectItem, Member } from "./B01_Dashboard_Api_Fetch";
export function canEditProject(user: DashboardUser, _project: ProjectItem): boolean {
export function canEditProject(user: DashboardUser, project: ProjectItem): boolean {
if (user.role === "SYSTEM_ADMIN") return true;
if (user.role === "ADMIN") return user.company_id !== null;
return false; // USER는 수정 불가
// 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정).
return (project.member_user_ids ?? []).includes(user.id);
}
export function canDeleteProject(user: DashboardUser): boolean {
// SYSTEM_ADMIN만 가능 (ADMIN 프로젝트 삭제는 나중을 위해 주석 처리)
return user.role === "SYSTEM_ADMIN";
// return user.role === "SYSTEM_ADMIN" || user.role === "ADMIN";
// 회사 관리자도 자기 회사 프로젝트를 지운다 (2026-09-06 사용자 확정). 범위는 백엔드가 다시 본다.
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
}
export function canAddUser(user: DashboardUser): boolean {
@@ -17,8 +17,9 @@ export function canAddUser(user: DashboardUser): boolean {
}
export function canChangeRole(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
// 역할 변경은 오직 SYSTEM_ADMIN만 가능
return user.role === "SYSTEM_ADMIN";
// 회사 관리자도 자기 회사 안에서 역할을 바꾼다 (2026-09-06 사용자 확정).
// 마지막 관리자 이탈·시스템 관리자 변경은 백엔드가 막는다.
return user.role === "SYSTEM_ADMIN" || (user.role === "ADMIN" && user.company_id !== null);
}
export function canDeleteUser(user: DashboardUser, _targetUser: Member | DashboardUser): boolean {
@@ -0,0 +1,72 @@
import { API_BASE_URL } from "@config/config_frontend";
import { geocodeAddress } from "./B01_Dashboard_Api_Fetch";
/**
* (2026-09-06 ).
*
* 3×3 .
* · "이 주소가 여기 맞나" .
*/
const ZOOM = 15;
const TILE = 256;
const GRID = 3;
function tileIndex(lat: number, lon: number): { x: number; y: number; dx: number; dy: number } {
const n = 2 ** ZOOM;
const rad = (lat * Math.PI) / 180;
const fx = ((lon + 180) / 360) * n;
const fy = ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n;
return { x: Math.floor(fx), y: Math.floor(fy), dx: fx - Math.floor(fx), dy: fy - Math.floor(fy) };
}
export function buildAddressMap(): {
root: HTMLElement;
show: (address: string) => Promise<void>;
} {
const root = document.createElement("div");
root.className = "b01-dashboard__map";
const note = document.createElement("p");
note.className = "b01-dashboard__modal-text";
note.textContent = "주소를 넣고 「지도 확인」을 누르십시오.";
root.append(note);
const show = async (address: string): Promise<void> => {
root.innerHTML = "";
if (!address.trim()) {
note.textContent = "주소를 먼저 입력하십시오.";
root.append(note);
return;
}
const point = await geocodeAddress(address).catch(() => null);
if (!point) {
note.textContent = "그 주소를 찾지 못했습니다. 도로명 또는 지번 주소로 다시 넣으십시오.";
root.append(note);
return;
}
const center = tileIndex(point.lat, point.lon);
const grid = document.createElement("div");
grid.className = "b01-dashboard__map-grid";
const half = Math.floor(GRID / 2);
for (let row = -half; row <= half; row += 1) {
for (let col = -half; col <= half; col += 1) {
const img = document.createElement("img");
img.src = `${API_BASE_URL}/dashboard/map/tile/${ZOOM}/${center.x + col}/${center.y + row}`;
img.width = TILE;
img.height = TILE;
img.alt = "";
grid.append(img);
}
}
const marker = document.createElement("span");
marker.className = "b01-dashboard__map-marker";
// 타일 판은 CSS 에서 절반으로 줄여 붙이므로 표식 자리도 절반으로 잡는다.
marker.style.left = `${(half + center.dx) * TILE * 0.5}px`;
marker.style.top = `${(half + center.dy) * TILE * 0.5}px`;
const frame = document.createElement("div");
frame.className = "b01-dashboard__map-frame";
frame.append(grid, marker);
root.append(frame);
};
return { root, show };
}
+281 -122
View File
@@ -14,24 +14,31 @@ import {
updateDashboardUser,
removeCompanyMember,
createCompany,
updateCompany,
joinCompany,
searchCompanies,
addCompanyMember,
searchMemberCandidates,
inviteMember,
deleteDashboardUser,
fetchCompanyMembers,
fetchCompanyAssets,
fetchUserCompany,
updateCompanyAsset,
createCompanyAsset,
setCompanyLogo,
type CompanyInfo,
type DashboardUser,
type ProjectItem,
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__";
import { buildAddressMap } from "./B01_Dashboard_UI_MapPreview";
import {
attachModalDismiss,
buildUserFields,
type ModalDismissHandle,
} from "./B01_Dashboard_UI_Common";
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
@@ -89,7 +96,8 @@ export async function openEditProjectModal(
user: DashboardUser,
project: ProjectItem,
): Promise<void> {
const isUserOnly = user.role === "USER";
// 참여자로 지정된 일반 사용자는 수정할 수 있다 (2026-09-06 사용자 확정).
const isUserOnly = user.role === "USER" && !(project.member_user_ids ?? []).includes(user.id);
// 담당자는 회사 구성원에서, 로고·서명은 회사 공유 자산에서 고른다 (2026-09-02 사용자 확정).
// 회사 정보는 로고 기본 연결을 보이기 위해 함께 받는다 (2026-09-04 사용자 지시).
const [members, assets, company] = await Promise.all([
@@ -164,9 +172,10 @@ export async function openEditProjectModal(
const personOptions = [
{ value: "", text: "(미지정)" },
...members.map((member) => ({ value: String(member.id), text: memberText(member) })),
{ value: NEW_MEMBER, text: "+ 신규 등록…" },
];
const persons: HTMLSelectElement[] = [];
// 담당자는 이미 등록된 팀원 중에서만 고른다 (2026-09-06 사용자 확정) —
// 이 자리에서 계정을 만드는 「신규 등록…」은 없앴다.
const person = (label: string, current: number | null | undefined) => {
const field = createSelectField({
label,
@@ -174,25 +183,6 @@ export async function openEditProjectModal(
value: String(current ?? ""),
});
persons.push(field.select);
let last = field.select.value;
// 「신규 등록…」은 값이 아니라 동작이다 — 계정을 만들고 그 사람을 고른 상태로 되돌린다.
field.select.addEventListener("change", () => {
if (field.select.value !== NEW_MEMBER) {
last = field.select.value;
return;
}
field.select.value = last;
openAddMemberModal((member) => {
for (const select of persons) {
const option = document.createElement("option");
option.value = String(member.id);
option.textContent = memberText(member);
select.insertBefore(option, select.options[select.options.length - 1]);
}
field.select.value = String(member.id);
last = field.select.value;
});
});
return field;
};
const pm = person("과업책임자 (도면 표제란)", project.pm_user_id);
@@ -255,6 +245,27 @@ export async function openEditProjectModal(
designer.root,
logo.root,
);
// 참여자 — 도면 표제란 3역할과 별개로 설계에 손대는 사람들 (2026-09-06 사용자 확정).
const memberBox = document.createElement("div");
memberBox.className = "b01-dashboard__members";
const memberLabel = document.createElement("p");
memberLabel.className = "b01-dashboard__modal-text";
memberLabel.textContent = "참여자 (고른 사람은 이 프로젝트를 수정할 수 있음)";
memberBox.append(memberLabel);
const memberChecks: HTMLInputElement[] = [];
for (const member of members) {
const row = document.createElement("label");
row.className = "b01-dashboard__member-row";
const check = document.createElement("input");
check.type = "checkbox";
check.value = String(member.id);
check.checked = (project.member_user_ids ?? []).includes(member.id);
check.disabled = isUserOnly;
memberChecks.push(check);
row.append(check, document.createTextNode(` ${memberText(member)}`));
memberBox.append(row);
}
const userId = (select: HTMLSelectElement) => (select.value ? Number(select.value) : null);
// 로고는 입력칸이 아니라 고르기 모달로 바뀌므로 변경 판정에 따로 실어 준다.
@@ -262,7 +273,7 @@ export async function openEditProjectModal(
openModal(
L("B01_Dashboard_EditProject"),
[grid],
[grid, memberBox],
async () => {
await updateProject(project.id, {
name: name.input.value.trim(),
@@ -284,6 +295,7 @@ export async function openEditProjectModal(
logo_asset_id: logo.value(),
// 서명은 사람 계정에 붙는다 (2026-09-02 사용자 확정) — 프로젝트는 더 고르지 않는다.
signature_asset_id: null,
member_user_ids: memberChecks.filter((c) => c.checked).map((c) => Number(c.value)),
});
showToast(L("B01_Dashboard_Saved"), "success");
},
@@ -306,22 +318,25 @@ export function openDeleteProjectModal(user: DashboardUser, project: ProjectItem
}
export function openEditUserModal(user: DashboardUser, target: Member | DashboardUser): void {
const name = createInputField({
label: L("B01_Dashboard_Table_Name"),
value: target.name,
required: true,
// 기본정보 폼과 같은 칸 한 벌을 쓴다 (2026-09-06 사용자 지시).
const fields = buildUserFields({
name: target.name,
email: target.email,
position: target.position,
department: target.department,
phone: (target as DashboardUser).phone,
});
const position = createInputField({
label: L("B01_Dashboard_Table_Position"),
value: target.position ?? "",
const isAdmin = user.role === "SYSTEM_ADMIN" || user.role === "ADMIN";
// 관리자는 계정을 비활성화할 수 있다 (2026-09-06 사용자 지시) — 비활성 계정은 다음
// 요청에서 세션이 끊긴다.
const statusField = createSelectField({
label: L("B01_Dashboard_Table_Status"),
options: [
{ value: "ACTIVE", text: "활성" },
{ value: "INACTIVE", text: "비활성" },
],
value: target.status === "INACTIVE" ? "INACTIVE" : "ACTIVE",
});
const department = createInputField({
label: L("B01_Dashboard_Table_Department"),
value: target.department ?? "",
});
const phoneVal = (target as DashboardUser).phone || "";
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: phoneVal });
// 서명은 사람에게 붙는다 (2026-09-02 사용자 확정) — 도면 표제란이 이 사람 자리를
// 채울 때 그대로 실린다. 고르는 즉시 그 사람에게 물린다.
@@ -330,51 +345,30 @@ export function openEditUserModal(user: DashboardUser, target: Member | Dashboar
void fetchCompanyAssets(companyId).then((assets) => {
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === target.id);
signatureSlot.append(
createAssetField(
"서명 (도면 표제란)",
"SIGNATURE",
assets,
owned?.id ?? null,
companyId,
user,
{
owner: { id: target.id, name: target.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id });
},
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, companyId, user, {
owner: { id: target.id, name: target.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: target.id });
},
).root,
}).root,
);
});
if (user.role === "ADMIN") {
// ADMIN은 직책(position) / 부서(department)만 수정 정보 가능
name.input.disabled = true;
phone.input.disabled = true;
} else if (user.role === "USER" && user.id !== target.id) {
name.input.disabled = true;
position.input.disabled = true;
department.input.disabled = true;
phone.input.disabled = true;
}
openModal(
L("B01_Dashboard_EditUser"),
[name.root, position.root, department.root, phone.root, signatureSlot],
async () => {
await updateDashboardUser(target.id, {
name: name.input.value.trim(),
position: position.input.value.trim() || null,
department: department.input.value.trim() || null,
phone: phone.input.value.trim() || null,
status: target.status,
});
showToast(L("B01_Dashboard_Saved"), "success");
},
);
const rows = isAdmin
? [fields.grid, statusField.root, signatureSlot]
: [fields.grid, signatureSlot];
openModal(L("B01_Dashboard_EditUser"), rows, async () => {
if (!fields.validate()) return;
await updateDashboardUser(target.id, {
...fields.values(),
// 상태는 관리자만 바꾼다. 그 밖에는 지금 값을 그대로 되돌려 보낸다.
status: isAdmin ? statusField.select.value : target.status,
});
showToast(L("B01_Dashboard_Saved"), "success");
});
}
export function openChangeRoleModal(target: Member | DashboardUser): void {
@@ -394,45 +388,141 @@ export function openChangeRoleModal(target: Member | DashboardUser): void {
}
export function openDeleteUserModal(target: Member | DashboardUser): void {
// 두 가지가 다르다 — 회사에서 빼면 계정은 남고(무소속), 계정 삭제는 로그인 자체가 막힌다
// (2026-09-06 사용자 확정).
const mode = createSelectField({
label: "처리 방식",
options: [
{ value: "REMOVE", text: "회사에서 빼기 (계정은 남음)" },
{ value: "DELETE", text: "계정 삭제 (로그인 불가)" },
],
value: "REMOVE",
});
const warning = document.createElement("p");
warning.className = "b01-dashboard__modal-text";
warning.textContent = L("B01_Dashboard_Confirm_DeleteUser");
openModal(L("B01_Dashboard_DeleteUser"), [warning], async () => {
await removeCompanyMember(target.id);
openModal(L("B01_Dashboard_DeleteUser"), [mode.root, warning], async () => {
if (mode.select.value === "DELETE") await deleteDashboardUser(target.id);
else await removeCompanyMember(target.id);
showToast(L("B01_Dashboard_Saved"), "success");
});
}
export function openCreateCompanyModal(): void {
const name = createInputField({ label: L("B01_Dashboard_Table_Company"), required: true });
/** 회사 등록·수정이 함께 쓰는 입력칸 — 순서는 사업자등록번호 > 회사명 > 대표자명 > 로고 > 주소. */
function companyFields(company?: CompanyInfo): {
rows: HTMLElement[];
values: () => {
name: string;
business_registration_number: string;
business_address: string | null;
business_owner: string | null;
};
logoFile: () => File | undefined;
valid: () => boolean;
} {
const number = createInputField({
label: L("B01_Dashboard_Field_BusinessNumber"),
value: company?.business_registration_number ?? "",
required: true,
});
const address = createInputField({ label: L("B01_Dashboard_Field_Address") });
const owner = createInputField({ label: L("B01_Dashboard_Field_Owner") });
// 회사 로고는 등록 단계에서 받는다 (2026-09-02 사용자 확정). 나중에 회사 패널에서 바꾼다.
const name = createInputField({
label: L("B01_Dashboard_Table_Company"),
value: company?.name ?? "",
required: true,
});
const owner = createInputField({
label: L("B01_Dashboard_Field_Owner"),
value: company?.business_owner ?? "",
});
const logo = createInputField({ label: "회사 로고 (png·jpg·webp·svg, 2MB 이하)" });
logo.input.type = "file";
logo.input.accept = ".png,.jpg,.jpeg,.webp,.svg";
const address = createInputField({
label: L("B01_Dashboard_Field_Address"),
value: company?.business_address ?? "",
});
// 주소가 맞는 자리인지 지도로 확인한다 (2026-09-06 사용자 지시).
const map = buildAddressMap();
const mapBtn = createButton({
label: "지도 확인",
variant: "ghost",
onClick: async function onB01_Company_Map_Click() {
await map.show(address.input.value.trim());
},
});
const addressRow = document.createElement("div");
addressRow.append(address.root, mapBtn, map.root);
return {
rows: [number.root, name.root, owner.root, logo.root, addressRow],
values: () => ({
name: name.input.value.trim(),
business_registration_number: number.input.value.trim(),
business_address: address.input.value.trim() || null,
business_owner: owner.input.value.trim() || null,
}),
logoFile: () => logo.input.files?.[0],
valid: () => Boolean(name.input.value.trim() && number.input.value.trim()),
};
}
export function openCreateCompanyModal(): void {
const fields = companyFields();
// 같은 회사를 두 번 만들지 않게, 등록 전에 이미 있는 회사를 찾아 보여 준다
// (2026-09-06 사용자 지시).
const matches = document.createElement("div");
matches.className = "b01-dashboard__actions";
const findBtn = createButton({
label: "이미 있는 회사인지 찾기",
variant: "ghost",
onClick: async function onB01_Company_Duplicate_Click() {
matches.innerHTML = "";
const values = fields.values();
const query = values.business_registration_number || values.name;
if (!query) return;
const found = await searchCompanies(query);
if (found.length === 0) {
const empty = document.createElement("p");
empty.className = "b01-dashboard__modal-text";
empty.textContent = "같은 회사가 없습니다. 그대로 등록하십시오.";
matches.append(empty);
return;
}
for (const company of found) {
matches.append(
createButton({
label: `${company.name}${L("B01_Dashboard_JoinCompany")}`,
variant: "ghost",
onClick: async () => {
showLoadingOverlay();
try {
await joinCompany(company.id);
showToast(L("B01_Dashboard_Saved"), "success");
} catch {
showToast("요청 실패", "error");
} finally {
hideLoadingOverlay();
}
},
}),
);
}
},
});
openModal(
L("B01_Dashboard_Modal_CreateCompany"),
[name.root, number.root, address.root, owner.root, logo.root],
[...fields.rows, findBtn, matches],
async () => {
if (!name.input.value.trim() || !number.input.value.trim()) return;
const created = await createCompany({
name: name.input.value.trim(),
business_registration_number: number.input.value.trim(),
business_address: address.input.value.trim() || null,
business_owner: owner.input.value.trim() || null,
});
const file = logo.input.files?.[0];
if (!fields.valid()) return;
const values = fields.values();
const created = await createCompany(values);
const file = fields.logoFile();
if (file && created?.company_id) {
const form = new FormData();
form.append("kind", "LOGO");
form.append("label", `${name.input.value.trim()} 로고`);
form.append("label", `${values.name} 로고`);
form.append("file", file);
form.append("company_id", String(created.company_id));
const assetId = await createCompanyAsset(form);
@@ -443,6 +533,27 @@ export function openCreateCompanyModal(): void {
);
}
/** 회사 정보 수정 (2026-09-06 사용자 지시) — 회사 관리자는 자기 회사, 시스템관리자는 전체. */
export function openEditCompanyModal(company: CompanyInfo): void {
const fields = companyFields(company);
openModal("회사 정보 수정", fields.rows, async () => {
if (!fields.valid()) return;
const values = fields.values();
await updateCompany(values, company.id);
const file = fields.logoFile();
if (file) {
const form = new FormData();
form.append("kind", "LOGO");
form.append("label", `${values.name} 로고`);
form.append("file", file);
form.append("company_id", String(company.id));
const assetId = await createCompanyAsset(form);
await setCompanyLogo(assetId, company.id);
}
showToast(L("B01_Dashboard_Saved"), "success");
});
}
export function openFindCompanyModal(): void {
const query = createInputField({ label: L("B01_Dashboard_Field_Search"), required: true });
const results = document.createElement("div");
@@ -482,32 +593,80 @@ export function openFindCompanyModal(): void {
}
/**
* ** **
* (2026-09-02 ). .
* ** **
* (2026-09-06 ). .
* .
*/
// ponytail: 새 구성원은 로그인한 사람의 회사에 붙는다(백엔드 `_require_company_id`).
// 시스템관리자가 남의 회사 프로젝트에서 신규 등록할 일이 생기면 그때 company_id 를 넓힐 것.
export function openAddMemberModal(onCreated?: (member: Member) => void): void {
const email = createInputField({
label: L("B01_Dashboard_Field_MemberEmail"),
type: "email",
required: true,
const query = createInputField({
label: "이름 또는 이메일로 찾기",
placeholder: "두 글자 이상",
});
const name = createInputField({ label: L("B01_Dashboard_Table_Name") });
const position = createInputField({ label: L("B01_Dashboard_Table_Position") });
const department = createInputField({ label: L("B01_Dashboard_Table_Department") });
openModal(
L("B01_Dashboard_Modal_AddMember"),
[email.root, name.root, position.root, department.root],
async () => {
if (!email.input.value.trim()) return;
const created = await addCompanyMember(email.input.value.trim(), {
name: name.input.value.trim() || undefined,
position: position.input.value.trim() || null,
department: department.input.value.trim() || null,
});
showToast(L("B01_Dashboard_Saved"), "success");
if (created?.member) onCreated?.(created.member);
const results = document.createElement("div");
results.className = "b01-dashboard__actions";
let picked: Member | null = null;
const search = createButton({
label: L("Common_Btn_Search"),
variant: "ghost",
onClick: async function onB01_Member_Search_Click() {
results.innerHTML = "";
const text = query.input.value.trim();
if (text.length < 2) {
query.setError("두 글자 이상 입력하십시오.");
return;
}
query.setError();
const users = await searchMemberCandidates(text);
if (users.length === 0) {
const empty = document.createElement("p");
empty.className = "b01-dashboard__modal-text";
empty.textContent = "소속 없는 가입자가 없습니다. 아직 가입 전이면 안내 메일을 보내십시오.";
results.append(empty);
return;
}
for (const candidate of users) {
results.append(
createButton({
label: `${candidate.name} (${candidate.email})`,
variant: picked?.id === candidate.id ? "filled" : "ghost",
onClick: () => {
picked = candidate;
query.input.value = `${candidate.name} (${candidate.email})`;
results.innerHTML = "";
},
}),
);
}
},
);
});
const invite = createButton({
label: "가입 안내 메일 보내기",
variant: "ghost",
onClick: async function onB01_Member_Invite_Click() {
const text = query.input.value.trim();
if (!text.includes("@")) {
query.setError("메일을 보낼 이메일 주소를 입력하십시오.");
return;
}
query.setError();
await inviteMember(text);
showToast("안내 메일을 보냈습니다.", "success");
},
});
const bar = document.createElement("div");
bar.className = "b01-dashboard__actions";
bar.append(search, invite);
openModal(L("B01_Dashboard_Modal_AddMember"), [query.root, bar, results], async () => {
if (!picked) {
query.setError("등록할 사람을 먼저 고르십시오.");
throw new Error("등록할 사람을 먼저 고르십시오.");
}
const created = await addCompanyMember(picked.id);
showToast(L("B01_Dashboard_Saved"), "success");
if (created?.member) onCreated?.(created.member);
});
}
+19 -20
View File
@@ -7,6 +7,7 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { section } from "@ui/ui_template_general_blocks";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { navigateTo } from "../A00_Common/router";
import {
fetchAllCompanies,
@@ -130,9 +131,8 @@ async function loadRoleData(state: DashboardState): Promise<void> {
}
function buildPage(state: DashboardState): HTMLElement {
// 제목·여백은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — B02 등 다른 화면과 같은 모양.
const page = document.createElement("div");
page.className = "b01-dashboard";
page.append(buildHeader(state.user));
const grid = document.createElement("div");
grid.className = "b01-dashboard__grid";
@@ -151,7 +151,6 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Companies"), companyTable(state.allCompanies, state.user), true, [
createButton({ label: "+", onClick: () => openCreateCompanyModal() }),
]),
section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true),
);
} else if (state.user.role === "ADMIN") {
grid.append(
@@ -181,23 +180,23 @@ function buildPage(state: DashboardState): HTMLElement {
section(L("B01_Dashboard_Profile"), buildProfileForm(state.user)),
section(L("B01_Account_Section_Security"), buildSecurityForm()),
);
// 시스템 로그는 맨 아래 (2026-09-06 사용자 지시).
if (state.user.role === "SYSTEM_ADMIN") {
grid.append(section(L("B01_Dashboard_AuditLogs"), auditLogTable(state.auditLogs), true));
}
page.append(grid);
return page;
}
function buildHeader(user: DashboardUser): HTMLElement {
const header = document.createElement("header");
header.className = "b01-dashboard__header";
const text = document.createElement("div");
const title = document.createElement("h1");
title.className = "b01-dashboard__title";
title.textContent = L("B01_Dashboard_Title");
const subtitle = document.createElement("p");
subtitle.className = "b01-dashboard__subtitle";
subtitle.textContent = L("B01_Dashboard_Subtitle");
text.append(title, subtitle);
const tag = createTag(roleLabel(user.role), user.role === "SYSTEM_ADMIN" ? "accent" : "neutral");
const layout = createGeneralLayout({
pageClass: "b01-dashboard",
title: L("B01_Dashboard_Title"),
subtitle: L("B01_Dashboard_Subtitle"),
content: page,
});
// 역할 배지는 제목 줄 오른쪽에 둔다(자리는 CSS 격자가 잡는다).
const tag = createTag(
roleLabel(state.user.role),
state.user.role === "SYSTEM_ADMIN" ? "accent" : "neutral",
);
tag.classList.add("b01-dashboard__role");
header.append(text, tag);
return header;
layout.root.querySelector(".ui-general-layout__header")?.append(tag);
return layout.root;
}
+34 -33
View File
@@ -1,48 +1,49 @@
import { isBlank } from "@util/common_util_validate";
import { createButton, createInputField } from "@ui/ui_template_elements";
import { changePassword, updateUserProfile, type DashboardUser } from "./B01_Dashboard_Api_Fetch";
import { L, runRequest } from "./B01_Dashboard_UI_Common";
import {
changePassword,
fetchCompanyAssets,
updateCompanyAsset,
updateUserProfile,
type DashboardUser,
} from "./B01_Dashboard_Api_Fetch";
import { createAssetField } from "./B01_Dashboard_UI_AssetPicker";
import { buildUserFields, L, runRequest } from "./B01_Dashboard_UI_Common";
const PASSWORD_MIN_LENGTH = 8;
export function buildProfileForm(user: DashboardUser): HTMLElement {
const name = createInputField({
label: L("B01_Account_Field_Name"),
value: user.name,
required: true,
});
const position = createInputField({
label: L("B01_Dashboard_Table_Position"),
value: user.position ?? "",
});
const department = createInputField({
label: L("B01_Dashboard_Table_Department"),
value: user.department ?? "",
});
const phone = createInputField({ label: L("B01_Account_Field_Phone"), value: user.phone ?? "" });
const grid = document.createElement("div");
grid.className = "b01-dashboard__form-grid";
grid.append(name.root, position.root, department.root, phone.root);
// 로그인한 본인의 정보다 — 이메일 라벨도 「이메일」로 나간다 (2026-09-06 사용자 지시).
const fields = buildUserFields(user, { self: true });
const grid = fields.grid;
// 본인 서명 — 사용자 수정 모달과 같은 칸이다. 도면 표제란이 이 사람 자리를 채울 때
// 그대로 실리므로 본인이 여기서 바로 걸 수 있게 둔다 (2026-09-06 사용자 지시).
const signatureSlot = document.createElement("div");
if (user.company_id) {
void fetchCompanyAssets(user.company_id).then((assets) => {
const owned = assets.find((asset) => asset.kind === "SIGNATURE" && asset.user_id === user.id);
signatureSlot.append(
createAssetField("서명", "SIGNATURE", assets, owned?.id ?? null, user.company_id!, user, {
owner: { id: user.id, name: user.name },
onChange: async (assetId) => {
if (assetId === null) return;
const picked = assets.find((asset) => asset.id === assetId);
if (picked)
await updateCompanyAsset(assetId, { label: picked.label, user_id: user.id });
},
}).root,
);
});
}
const save = createButton({
label: L("B01_Dashboard_SaveProfile"),
onClick: async function onB01_Profile_Save_Click() {
name.setError();
if (isBlank(name.input.value)) {
name.setError(L("Common_Msg_RequiredField"));
return;
}
await runRequest(() =>
updateUserProfile({
name: name.input.value.trim(),
position: position.input.value.trim() || null,
department: department.input.value.trim() || null,
phone: phone.input.value.trim() || null,
}),
);
if (!fields.validate()) return;
await runRequest(() => updateUserProfile(fields.values()));
},
});
const wrap = document.createElement("div");
wrap.append(grid, save);
wrap.append(grid, signatureSlot, save);
return wrap;
}
+1 -2
View File
@@ -13,7 +13,7 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
[
L("B01_Dashboard_Table_Project"),
L("B01_Dashboard_Table_Region"),
L("B01_Dashboard_Table_Progress"),
// 진행도(%) 열은 없앴다 (2026-09-06 사용자 지시) — 워크플로 배지가 같은 것을 보여 준다.
L("B01_Dashboard_Table_Workflow"),
L("B01_Dashboard_Table_Updated"),
L("B01_Dashboard_Table_Action"),
@@ -44,7 +44,6 @@ export function projectTable(projects: ProjectItem[], currentUser: DashboardUser
return [
text(project.name),
text(project.region),
text(`${project.progress_percent}%`),
workflow(project),
text(formatDate(project.updated_at)),
actCell,
+80 -20
View File
@@ -1,28 +1,19 @@
.b01-dashboard {
max-width: var(--page-max-width);
margin: 0 auto;
padding: var(--spacing-40) var(--spacing-24) var(--spacing-64);
/* ·제목·여백은 공용 템플릿(ui_template_general_layout) 잡는다 (2026-09-06 사용자 지시).
여기서는 역할 배지를 제목 오른쪽에 세우는 것만 한다. */
.b01-dashboard .ui-general-layout__header {
display: grid;
grid-template-columns: 1fr auto;
align-items: end;
}
.b01-dashboard__header {
display: flex;
justify-content: space-between;
gap: var(--spacing-24);
align-items: flex-end;
margin-bottom: var(--spacing-32);
}
.b01-dashboard__title {
font-size: var(--text-heading);
}
.b01-dashboard__subtitle {
margin: var(--spacing-8) 0 0;
color: var(--color-text-secondary);
font-size: var(--text-body);
.b01-dashboard .ui-general-layout__title,
.b01-dashboard .ui-general-layout__subtitle {
grid-column: 1;
}
.b01-dashboard__role {
grid-column: 2;
grid-row: 1 / span 2;
flex-shrink: 0;
}
@@ -46,6 +37,32 @@
gap: var(--spacing-8);
}
/* 안의 관리 버튼은 ** 줄로** 세운다 (2026-09-06 사용자 지시) 줄로 접히면
높이가 배가 된다. 버튼을 작게 만들고 줄바꿈을 막되, 폭이 모자라면 표가
가로로 스크롤한다(`.b01-dashboard__table-wrap` 이미 그렇게 있다). */
.b01-dashboard__table .b01-dashboard__actions {
flex-wrap: nowrap;
gap: var(--spacing-4);
}
.b01-dashboard__table .b01-dashboard__actions .ui-btn {
padding: var(--spacing-4) var(--spacing-8);
font-size: var(--text-caption);
white-space: nowrap;
}
/* 한 칸에 두 줄(날짜/시각) — 아랫줄이 작아 행 높이는 한 줄일 때와 같다. */
.b01-dashboard__stacked {
display: flex;
flex-direction: column;
line-height: 1.2;
}
.b01-dashboard__stacked-sub {
color: var(--color-text-muted);
font-size: var(--text-caption);
}
.b01-dashboard__table-wrap {
overflow-x: auto;
border: 1px solid var(--color-border);
@@ -273,3 +290,46 @@
grid-template-columns: 1fr;
}
}
/* 회사 주소 지도 미리보기 — 타일 3×3 을 붙이고 가운데 표식을 찍는다 (2026-09-06). */
.b01-dashboard__map-frame {
position: relative;
width: 100%;
max-width: 384px;
aspect-ratio: 1;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 8px);
}
.b01-dashboard__map-grid {
display: grid;
grid-template-columns: repeat(3, 256px);
transform: scale(0.5);
transform-origin: top left;
}
.b01-dashboard__map-marker {
position: absolute;
width: 12px;
height: 12px;
margin: -6px 0 0 -6px;
border: 2px solid var(--color-surface, #fff);
border-radius: 50%;
background: var(--color-danger, #d33);
}
/* 프로젝트 참여자 고르기 (2026-09-06). */
.b01-dashboard__members {
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
max-height: 160px;
overflow-y: auto;
}
.b01-dashboard__member-row {
display: flex;
align-items: center;
gap: var(--spacing-8);
}
@@ -9,6 +9,7 @@ from uuid import uuid4
import aiomysql
from common_util.common_util_audit import record_audit
from common_util.common_util_json import atomic_write_json
from common_util.common_util_storage import PROJECT_STORAGE_LAYOUT_V2
from common_util.common_util_workflow import load_project_workflow
@@ -43,6 +44,7 @@ def _initialize_project_storage(project_root: Path, project_id: str) -> None:
async def create_project(
*,
request: Any | None = None,
user_id: int,
company_id: int,
name: str,
@@ -102,13 +104,21 @@ async def create_project(
fields.get("logo_asset_id"),
),
)
# 만든 사람은 곧 참여자다 (2026-09-06 사용자 확정) — 참여자는 수정 권한을 가진다.
await cursor.execute(
"INSERT IGNORE INTO project_members (project_id, user_id) VALUES (%s, %s)",
(project_id, user_id),
)
# 워크플로우 단계별 상태 초기화 시드
await initialize_project_stages(cursor, project_id)
await cursor.execute(
"""INSERT INTO system_audit_logs (user_id, action, resource_type, resource_id)
VALUES (%s, 'PROJECT_CREATE', 'project', NULL)""",
(user_id,),
await record_audit(
cursor,
actor_id=user_id,
action="PROJECT_CREATE",
resource_type="project",
resource_ref=project_id,
request=request,
)
_initialize_project_storage(project_root, project_id)
await connection.commit()
+3 -1
View File
@@ -2,7 +2,7 @@
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Request
from B01_Dashboard.B01_Dashboard_Repository_Members import check_project_refs
from common_util.common_util_auth import require_company
@@ -15,6 +15,7 @@ router = APIRouter(prefix="/api/b02", tags=["B02_ProjRegister"])
@router.post("/project", response_model=CreateProjectResponse)
async def post_project(
request: Request,
payload: CreateProjectRequest,
session: dict[str, Any] = Depends(require_company),
) -> CreateProjectResponse:
@@ -50,6 +51,7 @@ async def post_project(
try:
result = await create_project(
request=request,
user_id=int(session["user_id"]),
company_id=int(company_id),
name=payload.name.strip(),
@@ -0,0 +1,96 @@
/* =============================================================================
* B02_ProjRegister_UI_Name.ts
* ·· ,
* (2026-09-06 ).
*
* .
*
* (실측: 사업지역 ).
*
*
* - .
* - ** ** .
* (·) .
* - .
* ========================================================================== */
/** 이름을 이루는 자동 조각의 종류. 순서가 곧 이름에 놓이는 차례다. */
export type NameSlot = "year" | "region" | "type";
const SLOTS: readonly NameSlot[] = ["year", "region", "type"];
export type NameParts = Record<NameSlot, string>;
export interface NameComposer {
/** 지금 이름. */
text(): string;
/** 사용자가 이름 칸에 친 글을 반영한다. */
edit(next: string): void;
/** 위 항목이 바뀌었을 때 — 갈아 끼운 이름을 돌려준다. */
update(parts: NameParts): string;
}
export function createNameComposer(): NameComposer {
let text = "";
/** 글자마다 그 글자를 넣은 항목(사용자가 친 글자는 null). `text`와 길이가 같다. */
let owners: (NameSlot | null)[] = [];
let touched = false;
const fill = (slot: NameSlot | null, count: number): (NameSlot | null)[] =>
Array.from({ length: count }, () => slot);
const rebuild = (parts: NameParts): void => {
text = "";
owners = [];
for (const slot of SLOTS) {
const value = parts[slot].trim();
if (!value) continue;
if (text.length > 0) {
text += " ";
owners.push(null);
}
text += value;
owners.push(...fill(slot, value.length));
}
};
return {
text: () => text,
edit(next: string): void {
touched = true;
// 앞뒤로 그대로인 부분을 뺀 **바뀐 구간**만 계산한다 — 어디를 고쳤는지 알아야
// 나머지 글자의 주인이 밀리지 않는다.
let head = 0;
while (head < text.length && head < next.length && text[head] === next[head]) head += 1;
let tail = 0;
while (
tail < text.length - head &&
tail < next.length - head &&
text[text.length - 1 - tail] === next[next.length - 1 - tail]
) {
tail += 1;
}
const removed = text.length - tail - head;
const inserted = next.slice(head, next.length - tail);
owners.splice(head, removed, ...fill(null, inserted.length));
text = next;
},
update(parts: NameParts): string {
if (!touched) {
rebuild(parts);
return text;
}
for (const slot of SLOTS) {
const first = owners.indexOf(slot);
if (first < 0) continue; // 사용자가 지운 항목은 되살리지 않는다.
const last = owners.lastIndexOf(slot);
const value = parts[slot].trim();
text = text.slice(0, first) + value + text.slice(last + 1);
owners.splice(first, last - first + 1, ...fill(slot, value.length));
}
return text;
},
};
}
+91 -105
View File
@@ -19,15 +19,10 @@ import {
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createGeneralLayout } from "@ui/ui_template_general_layout";
import { isBlank } from "@util/common_util_validate";
import {
fetchCompanyAssets,
fetchCompanyMembers,
fetchDashboardMe,
fetchUserCompany,
} from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { createAssetField } from "../B01_Dashboard/B01_Dashboard_UI_AssetPicker";
import { openAddMemberModal } from "../B01_Dashboard/B01_Dashboard_UI_Modals";
import { fetchCompanyMembers, fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { createNameComposer, type NameParts } from "./B02_ProjRegister_UI_Name";
import { navigateTo } from "../A00_Common/router";
import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import "./B02_ProjRegister_UI_Style.css";
@@ -41,23 +36,13 @@ function L(key: keyof typeof ui_locales): string {
*
* -------------------------------------------------------------------------- */
export function renderB02ProjRegister(root: HTMLElement): void {
const page = document.createElement("div");
page.className = "b02-proj";
const header = document.createElement("div");
header.className = "b02-proj__header";
const title = document.createElement("h1");
title.className = "b02-proj__title";
title.textContent = L("B02_Proj_Title");
const subtitle = document.createElement("p");
subtitle.className = "b02-proj__subtitle";
subtitle.textContent = L("B02_Proj_Subtitle");
header.append(title, subtitle);
// 입력 필드
// 프로젝트명은 사업연도 + 사업지역 + 임도종류로 **자동으로 채워지되 고칠 수 있는 칸**이다
// (2026-09-06 사용자 지시 — 종전에는 미리보기 줄이었다). 사용자가 사이나 끝에 글을 넣어도
// 위 세 항목을 다시 바꾸면 그 조각만 갈아 끼워 손댄 글이 살아남는다.
const nameField = createInputField({
label: L("B02_Proj_Field_Name"),
placeholder: L("B02_Proj_Field_Name_Placeholder"),
label: "프로젝트명",
placeholder: "예: 2026 가리왕산 간선임도 1구간",
required: true,
});
const regionField = createInputField({
@@ -84,12 +69,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
min: 2000,
max: currentYear + 5,
});
const lengthField = createInputField({
label: L("B02_Proj_Field_Length"),
placeholder: L("B02_Proj_Field_Length_Placeholder"),
type: "number",
min: 0,
});
// 계획노선 자료가 공사지 전체일 수 있어 쓸 구간을 받는다 (2026-09-04 사용자 지시).
// 둘 다 비우면 전 구간을 쓴다.
const routeStartField = createInputField({
@@ -113,33 +92,22 @@ export function renderB02ProjRegister(root: HTMLElement): void {
// 도면 표제란·표지 값 — 프로젝트 수정 모달과 같은 항목을 등록 때부터 받는다
// (2026-09-02 사용자 지시). 비워 두면 도면에 빈칸으로 나간다.
const clientOrgField = createInputField({ label: "시행청 (도면 표제란)" });
const projectNumberField = createInputField({
label: "연도·기번 (표지)",
placeholder: "예: 2026년 간선임도(기번3-울진.대흥)",
});
const workAmountField = createInputField({ label: "사업량 (표지)", placeholder: "예: L=2.14km" });
// 「연도·기번」·「사업량」 칸은 없앴다 (2026-09-06 사용자 확정) — 프로젝트명·노선 연장과
// 같은 값이라 도면 표지에는 그 둘에서 끌어 쓴다.
const designDateField = createInputField({ label: "설계일자 (도면 표제란)", type: "date" });
const NEW_MEMBER = "__new__";
const person = (label: string) =>
createSelectField({ label, options: [{ value: "", text: "(미지정)" }] });
const pmField = person("과업책임자 (도면 표제란)");
const fieldLeadField = person("분야별책임자 (도면 표제란)");
const designerField = person("설계자 (도면 표제란)");
const personSelects = [pmField.select, fieldLeadField.select, designerField.select];
// 로고 칸은 회사 자산을 받아야 세울 수 있어 자리만 먼저 잡는다.
const logoSlot = document.createElement("div");
let logoValue: () => number | null = () => null;
// 로고 칸은 두지 않는다 (2026-09-06 사용자 지시) — 프로젝트가 이미 회사에 매여 있어
// 도면은 회사 로고를 그대로 쓴다(표제란 조회가 `COALESCE(프로젝트, 회사)`).
void (async () => {
const [me, company] = await Promise.all([
fetchDashboardMe(),
fetchUserCompany().catch(() => null),
]);
const [members, assets] = await Promise.all([
fetchCompanyMembers().catch(() => []),
fetchCompanyAssets().catch(() => []),
]);
const me = await fetchDashboardMe();
const members = await fetchCompanyMembers().catch(() => []);
const memberText = (member: (typeof members)[number]) =>
member.position ? `${member.name} (${member.position})` : member.name;
const addOption = (select: HTMLSelectElement, value: string, text: string) => {
@@ -150,68 +118,82 @@ export function renderB02ProjRegister(root: HTMLElement): void {
};
for (const select of personSelects) {
for (const member of members) addOption(select, String(member.id), memberText(member));
// 계정을 만드는 것은 회사 관리자 권한이라 일반 사용자에게는 보이지 않는다.
if (me.role !== "USER") addOption(select, NEW_MEMBER, "+ 신규 등록…");
}
for (const select of personSelects) {
let last = select.value;
select.addEventListener("change", () => {
if (select.value !== NEW_MEMBER) {
last = select.value;
return;
}
select.value = last;
openAddMemberModal((member) => {
for (const other of personSelects) {
const option = document.createElement("option");
option.value = String(member.id);
option.textContent = memberText(member);
other.insertBefore(option, other.options[other.options.length - 1]);
}
select.value = String(member.id);
last = select.value;
});
});
}
if (company) {
// 회사 대표 로고가 기본값 — 프로젝트마다 다른 로고를 쓰면 여기서 바꾼다.
const logo = createAssetField(
"회사 로고 (도면 표제란)",
"LOGO",
assets,
company.logo_asset_id ?? null,
company.id,
me,
);
logoValue = logo.value;
logoSlot.append(logo.root);
// 담당자 기본값은 만든 사람 (2026-09-06 사용자 확정). 계정을 그 자리에서 만드는
// 「신규 등록…」은 없앴다 — 팀원으로 등록한 뒤 고른다.
if (members.some((member) => member.id === me.id)) select.value = String(me.id);
}
})();
const roadTypeText = (): string =>
roadTypeField.select.options[roadTypeField.select.selectedIndex]?.textContent ?? "";
/** 이름 조합기 — 글자마다 주인을 기억해 사용자가 사이에 글을 넣어도 연동이 안 끊긴다. */
const nameComposer = createNameComposer();
const nameParts = (): NameParts => ({
year: yearField.input.value.trim(),
region: regionField.input.value.trim(),
type: roadTypeText(),
});
nameField.input.addEventListener("input", () => {
nameComposer.edit(nameField.input.value);
});
const syncName = (): void => {
nameField.input.value = nameComposer.update(nameParts());
};
const composedName = (): string => nameField.input.value.trim();
const routeLength = (): number | null => {
const start = isBlank(routeStartField.input.value)
? null
: Number.parseFloat(routeStartField.input.value);
const end = isBlank(routeEndField.input.value)
? null
: Number.parseFloat(routeEndField.input.value);
if (end === null || !Number.isFinite(end)) return null;
const from = start !== null && Number.isFinite(start) ? start : 0;
return end > from ? end - from : null;
};
const refresh = (): void => {
syncName();
const length = routeLength();
routeEndField.root.querySelector(".ui-field__label")!.textContent =
length === null
? "노선 종료 누가거리 (m)"
: `노선 종료 누가거리 (m) — 연장 ${length.toFixed(1)}m`;
};
// 이름 칸 자신은 여기서 제외한다 — 스스로 고치는 도중에 값을 되돌리면 안 된다.
for (const field of [regionField, yearField, routeStartField, routeEndField]) {
field.input.addEventListener("input", refresh);
}
roadTypeField.select.addEventListener("change", refresh);
refresh();
const submitBtn = createButton({
label: L("B02_Proj_Submit"),
variant: "filled",
onClick: onB02_Proj_Submit_Click,
});
submitBtn.classList.add("b02-proj__submit");
const cancelBtn = createButton({
label: L("Common_Btn_Cancel"),
variant: "ghost",
onClick: function onB02_Proj_Cancel_Click() {
navigateTo(ROUTES.B01_ACCOUNT);
},
});
const idOrNull = (select: HTMLSelectElement): number | null =>
select.value && select.value !== NEW_MEMBER ? Number(select.value) : null;
select.value ? Number(select.value) : null;
async function onB02_Proj_Submit_Click(): Promise<void> {
nameField.setError();
regionField.setError();
yearField.setError();
lengthField.setError();
routeStartField.setError();
routeEndField.setError();
// 1차 유효성: 필수값 검사
let hasError = false;
const projectYear = Number.parseInt(yearField.input.value, 10);
const estimatedLength = isBlank(lengthField.input.value)
? null
: Number.parseFloat(lengthField.input.value);
// 예상 연장은 따로 받지 않는다 — 노선 구간 길이가 곧 연장이다 (2026-09-06 사용자 확정).
const estimatedLength = routeLength();
if (isBlank(nameField.input.value)) {
nameField.setError(L("B02_Proj_Error_Required"));
hasError = true;
@@ -224,10 +206,6 @@ export function renderB02ProjRegister(root: HTMLElement): void {
yearField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
if (estimatedLength !== null && (!Number.isFinite(estimatedLength) || estimatedLength < 0)) {
lengthField.setError(L("Common_Validation_NumberRange"));
hasError = true;
}
const routeStart = isBlank(routeStartField.input.value)
? null
: Number.parseFloat(routeStartField.input.value);
@@ -256,7 +234,7 @@ export function renderB02ProjRegister(root: HTMLElement): void {
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
name: nameField.input.value.trim(),
name: composedName(),
region: regionField.input.value.trim(),
road_type: roadTypeField.select.value,
project_year: projectYear,
@@ -265,13 +243,14 @@ export function renderB02ProjRegister(root: HTMLElement): void {
route_end_m: routeEnd,
memo: memoField.input.value.trim() || null,
client_org: clientOrgField.input.value.trim() || null,
project_number: projectNumberField.input.value.trim() || null,
work_amount: workAmountField.input.value.trim() || null,
// 표지 값은 프로젝트명·연장에서 끌어 쓴다 (2026-09-06 사용자 확정).
project_number: composedName(),
work_amount:
estimatedLength === null ? null : `L=${(estimatedLength / 1000).toFixed(2)}km`,
design_date: designDateField.input.value || null,
pm_user_id: idOrNull(pmField.select),
field_lead_user_id: idOrNull(fieldLeadField.select),
designer_user_id: idOrNull(designerField.select),
logo_asset_id: logoValue(),
}),
});
@@ -301,25 +280,32 @@ export function renderB02ProjRegister(root: HTMLElement): void {
const grid = document.createElement("div");
grid.className = "b02-proj__grid";
grid.append(
nameField.root,
yearField.root,
regionField.root,
roadTypeField.root,
yearField.root,
lengthField.root,
nameField.root,
routeStartField.root,
routeEndField.root,
memoField.root,
clientOrgField.root,
projectNumberField.root,
workAmountField.root,
designDateField.root,
pmField.root,
fieldLeadField.root,
designerField.root,
logoSlot,
// 비고는 맨 끝 (2026-09-06 사용자 지시).
memoField.root,
);
const card = createCard({ body: [grid, submitBtn], raised: true });
page.append(header, card);
root.append(page);
// 제목·버튼 줄은 공용 템플릿을 따른다 (2026-09-06 사용자 지시) — 다른 화면과 같은
// 제목 크기·여백을 쓰고, 버튼 줄은 `ui-general-block__actions` 로 세운다.
const actions = document.createElement("div");
actions.className = "ui-general-block__actions b02-proj__actions";
actions.append(cancelBtn, submitBtn);
const card = createCard({ body: [grid, actions], raised: true });
const layout = createGeneralLayout({
pageClass: "b02-proj",
title: L("B02_Proj_Title"),
subtitle: L("B02_Proj_Subtitle"),
content: card,
});
root.append(layout.root);
}
+12 -28
View File
@@ -3,30 +3,13 @@
* 프로젝트 등록 페이지 전용 스타일 (theme.css 변수만 사용)
* ========================================================================== */
.b02-proj {
/* 제목·여백·버튼 줄은 공용 템플릿(ui_template_general_layout) 쓴다 여기서는
화면에만 필요한 (·2열 그리드·셀렉트 화살표·버튼 오른쪽 정렬) 둔다. */
/* 입력 줄짜리 폼이라 종전처럼 720px 좁혀 가운데 둔다 (2026-09-06 사용자 지시
템플릿을 쓰되 폭은 바꾸지 않는다). */
.b02-proj .ui-general-layout__inner {
max-width: 720px;
margin: 0 auto;
padding: var(--spacing-40) var(--spacing-24);
display: flex;
flex-direction: column;
gap: var(--spacing-24);
}
.b02-proj__header {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
}
.b02-proj__title {
font-family: var(--font-display);
font-size: var(--text-heading);
color: var(--color-plum-velvet);
}
.b02-proj__subtitle {
font-size: var(--text-body-sm);
color: var(--color-text-muted);
}
/* 2열 필드 그리드 (좁은 화면에서는 1열) */
@@ -53,13 +36,14 @@
padding-right: var(--spacing-32);
}
.b02-proj__submit {
align-self: flex-start;
margin-top: var(--spacing-8);
}
@media (max-width: 640px) {
.b02-proj__grid {
grid-template-columns: 1fr;
}
}
/* 취소·등록 버튼 줄 — 나머지는 공용 `.ui-general-block__actions` 가 맡는다. */
.b02-proj__actions {
justify-content: flex-end;
margin-top: var(--spacing-16);
}
+11 -5
View File
@@ -94,6 +94,9 @@ from B03_FileInput.B03_FileInput_Router_Helpers import (
from B03_FileInput.B03_FileInput_Router_Helpers import (
_write_stage_metadata as _write_stage_metadata,
)
from B03_FileInput.B03_FileInput_Router_Helpers import (
upload_file_types as upload_file_types,
)
from B03_FileInput.B03_FileInput_Schema import (
FileUploadDescriptor,
FileUploadResponse,
@@ -156,12 +159,13 @@ async def upload_project_files(
"message": "LAS 없이 설계를 켠 상태에서는 LAS/LAZ 파일을 올릴 수 없습니다.",
},
)
if not las_free and las_count != 1:
# 지형 파일은 도엽별로 여러 장이 올 수 있다 — 합쳐서 전처리한다(2026-09-06 사용자 확정).
if not las_free and las_count < 1:
return JSONResponse(
status_code=400,
content={
"status": "error",
"message": "LAS 또는 LAZ 파일을 정확히 1개 포함해야 합니다.",
"message": "LAS 또는 LAZ 파일을 1개 이상 포함해야 합니다.",
},
)
# 계획노선은 shapefile 또는 CSV 한 벌이다 (2026-08-31) — 문구도 그렇게 맞춘다
@@ -175,7 +179,7 @@ async def upload_project_files(
"message": "계획노선 파일(shapefile 의 .shp 또는 .csv)을 정확히 1개 포함해야 합니다.",
},
)
request_file_types = {Path(filename).suffix.lower().lstrip(".") for filename in filenames}
request_file_types = upload_file_types(filenames)
missing_required = _missing_required_file_types(request_file_types, las_free)
if missing_required:
return JSONResponse(
@@ -350,8 +354,10 @@ async def get_project_upload_overview(
)
for row in sessions
],
required_complete=_REQUIRED_FILE_TYPES <= file_types
and (point_cloud_id is not None or stage0_complete),
# stage 0 을 마쳤으면 서버 필수검사를 이미 통과한 것이다 — LAS 없이 설계는
# 지형 한 벌(prj·tfw)이 아예 없으므로 여기서 다시 세면 B04 이동이 막힌다.
required_complete=stage0_complete
or (_REQUIRED_FILE_TYPES <= file_types and point_cloud_id is not None),
analysis_complete=analysis_complete,
)
except Exception:
+31 -2
View File
@@ -62,14 +62,34 @@ def _is_point_cloud_result(result: UploadedFileResult) -> bool:
return result.file_type.lower() in _POINT_CLOUD_FILE_TYPES
def upload_file_types(filenames: list[str]) -> set[str]:
"""업로드 요청의 파일명을 **완료 검사와 같은 기준**으로 유형화한다.
확장자만 세면 노선 세트의 `.prj` 지형 PRJ로 오인돼, 요청 검사는 통과하고 저장
완료 검사에서 누락으로 갈린다(2026-09-06 실측 LAS 없이 설계가 어긋남으로 막혔다).
노선 도형(`.shp`) basename이 같은 PRJ는 화면의 카드 배정과 같은 규칙으로 `route_prj`.
"""
route_stem = next(
(Path(name).stem for name in filenames if Path(name).suffix.lower() == ".shp"), None
)
types: set[str] = set()
for name in filenames:
suffix = Path(name).suffix.lower().lstrip(".")
if suffix == "prj" and route_stem is not None and Path(name).stem == route_stem:
suffix = "route_prj"
types.add(suffix)
return types
def _missing_required_file_types(file_types: set[str], las_free: bool = False) -> list[str]:
missing = sorted(_REQUIRED_FILE_TYPES - file_types)
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 지형 한 벌(포인트클라우드·지형 PRJ·
# TFW)을 통째로 받지 않는다 — 화면도 그 카드들을 필수에서 뺀다.
missing = [] if las_free else sorted(_REQUIRED_FILE_TYPES - file_types)
if not file_types.intersection(_ROUTE_FILE_TYPES):
missing.append("csv/shp")
# shapefile로 왔으면 형제 파일이 다 있어야 노선을 읽는다.
if "shp" in file_types:
missing.extend(sorted(_SHAPEFILE_REQUIRED_TYPES - file_types))
# LAS 없는 설계(도엽등고선 기반, 2026-08-30)는 LAS 필수를 면제한다.
if not las_free and not file_types.intersection(_POINT_CLOUD_FILE_TYPES):
missing.append("las/laz")
return missing
@@ -143,6 +163,15 @@ async def _complete_file_input_if_ready(
# 아직 다시 만들어지지 않은 뒷단계 화면이 옛 결과를 새 자료 것인 양 보여준다.
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 지형 파일 여러 장은 합쳐서 전처리한다 — 다른 사업지 파일이 섞이면 합친 범위가
# 통째로 어긋나므로 여기서 막는다(2026-09-06 사용자 지시). 머리글만 읽어 즉시 끝난다.
from B04_PreProcess.B04_PreProcess_Engine_Structurize import merge_gap_error
from B04_PreProcess.B04_PreProcess_Repository import list_project_point_cloud_paths
terrain_paths = await list_project_point_cloud_paths(connection, project_id, project_root)
gap_message = merge_gap_error(terrain_paths)
if gap_message:
raise ValueError(gap_message)
clear_designing(project_root)
discard_initial_snapshot(project_root)
await purge_project_outputs(connection, str(project_id), project_root)
+148 -35
View File
@@ -16,6 +16,7 @@ WF1(지표면 분석·자동 확정)이 끝나면 사용자가 화면에 없어
"""
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -25,6 +26,18 @@ from fastapi.responses import JSONResponse
logger = logging.getLogger(__name__)
def _log_steps(title: str, marks: list[tuple[str, float]]) -> None:
"""단계별 경과시간을 한 줄로 남긴다 — 어디서 시간을 쓰는지 보려는 계측용."""
total = (marks[-1][1] - marks[0][1]) or 1e-9
parts = [
f"{name} {marks[i][1] - marks[i - 1][1]:.1f}s"
f"({(marks[i][1] - marks[i - 1][1]) / total * 100:.0f}%)"
for i, (name, _) in enumerate(marks)
if i
]
logger.info("[계측] %s%.1fs = %s", title, total, " | ".join(parts))
def _planned_route_points_in_project_crs(
project_root: Path,
surface: dict[str, Any] | None = None,
@@ -70,6 +83,7 @@ async def _prepare_drainage_pipes_and_reprofile(
from config.config_db import get_db_pool
from config.config_system import SECTION_CROSS_HALF_WIDTH_M
marks = [("시작", time.perf_counter())]
try:
# 1) 배수유역 분석(30초 내외). 저장분이 있으면 그대로 쓴다.
# 저장분은 노선이 바뀌었는지 보지 않으므로, 노선을 다시 푼 뒤에는 refresh로 부른다.
@@ -82,6 +96,8 @@ async def _prepare_drainage_pipes_and_reprofile(
)
return False
marks.append(("배수유역 분석", time.perf_counter()))
# 2) 기본 관(도로 × 상류 세류선) + 최대 간격 자동 보충을 정본으로 저장한다.
pipes = await put_pipe_points(project_id, None)
if isinstance(pipes, JSONResponse):
@@ -93,6 +109,8 @@ async def _prepare_drainage_pipes_and_reprofile(
return False
pipe_count = int(pipes.get("saved_count") or 0)
marks.append(("관 지점 확정", time.perf_counter()))
# 3) 저장된 관 자리를 물려 계획선을 다시 산출한다. 반폭은 이미 쓰던 값을 유지한다.
pool = get_db_pool()
async with pool.acquire() as connection:
@@ -110,6 +128,8 @@ async def _prepare_drainage_pipes_and_reprofile(
regenerated.status_code,
)
return False
marks.append(("종횡단 측점 재생성", time.perf_counter()))
_log_steps("배수·관·측점", marks)
logger.info(
"자동 설계 체인 배수유역·배관 정착 계획선 완료: project_id=%s route_id=%s 관=%d",
project_id,
@@ -148,6 +168,10 @@ async def run_auto_design_chain(
mark_designing,
save_initial_snapshot,
)
from common_util.common_util_route_geometry import (
expected_route_csv_path,
write_route_csv,
)
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from config.config_db import get_db_pool
@@ -195,12 +219,36 @@ async def run_auto_design_chain(
mark_design_failed(project_root, "계획노선이 없어 초기 노선을 세울 수 없습니다.")
return None
# 2.5) 예상노선(원본) 정본을 남긴다 — 초기값 스냅샷과 달리 재확정 체인이 지우지
# 않는 자리라, 노선 초기화가 언제나 이 값으로 돌아갈 수 있다(2026-09-06 PLAN 0-7).
expected_path = expected_route_csv_path(project_root)
if not expected_path.is_file():
write_route_csv(expected_path, points)
# 2.6) 계획노선 **초기 폴리라인**도 여기서 세운다(2026-09-06 PLAN 0-10).
# 예상노선은 점 묶음이라 그대로는 설계선이 못 된다 — 지식DB 의 R 기준으로
# 곡선을 끼운 폴리라인이 불변 초기 데이터다. 화면이 처음 열릴 때 만들면
# 「화면을 안 열면 값이 없다」가 되므로 초기값은 서버가 낸다(0-4 원칙).
await _ensure_initial_polyline(project_id, project_root, points)
# 2.7) 노선의 기준을 **초기 폴리라인**으로 갈아 끼운다(2026-09-06 사용자 지시).
# 다시 읽으면 `load_design_route` 가 방금 만든 초기 폴리라인을 집는다
# (읽는 순서: 수정본 → 초기 폴리라인 → 예상노선). 종횡단 측점·유토곡선·
# 3D 코리도가 전부 이 노선에서 나오므로, 여기서 갈아 끼우지 않으면 화면에는
# 곡선 없는 원본 점군(용화: 3.3m 간격 331점)이 그대로 선다.
polyline_points = _planned_route_points_in_project_crs(project_root, defaults, route_range)
if polyline_points and len(polyline_points) >= 2:
points = polyline_points
# 3) B05 경로 계산
request = RouteSolveRequest(
filter_key=str(defaults["source_filter"]),
method=str(defaults["method"]),
smooth=bool(defaults["smooth"]),
surface_model_id=surface_model_id,
# 계획노선을 **그대로** 쓴다 — 격자 재탐색은 이 선을 자기 제약으로 다시 풀어
# 곡선을 뭉개거나 통째로 막는다(재확정 체인과 같은 이유, 0-10).
algorithm="as_planned",
bp=RoutePoint(**points[0]),
ep=RoutePoint(**points[-1]),
cp=[
@@ -280,6 +328,17 @@ async def run_auto_design_chain(
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
# 구조물 폐회로 면적 + 유토곡선 — 화면이 쓰는 TS 를 서버에서 한 번 돌려 정본에
# 얹는다(2026-09-06). 이걸 빼면 B06 을 안 연 프로젝트의 수량이 구조물을 모르는
# 표준값으로 남고 유토곡선은 아예 없다. 초기값 스냅샷 **앞**이라 되돌릴
# 기준선에도 보정이 담긴다.
try:
from B06_Section.B06_Section_Server_Calc_Prebuild import recompute_server_side
await recompute_server_side(project_id, route_id)
except Exception: # noqa: BLE001 — 재계산 실패가 체인을 막지는 않는다
logger.exception("횡단 서버 재계산 실패: project_id=%s", project_id)
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
@@ -306,11 +365,35 @@ async def run_auto_design_chain(
clear_designing(project_root)
async def _ensure_initial_polyline(
project_id: UUID, project_root: Path, points: list[dict[str, float]]
) -> None:
"""계획노선 초기 폴리라인을 세운다 — 이미 있으면 그대로 둔다. 실패는 비치명적."""
import asyncio
from B05_Profile.B05_Profile_Router_Replan import _ensure_planned_initial, _min_plan_radius_m
try:
radius_m = await _min_plan_radius_m(project_id)
summary = await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
if summary:
logger.info(
"초기 계획노선 폴리라인: project_id=%s 노드 %d · 곡선 %d · 위반 %d (R %.1fm)",
project_id,
summary["nodes"],
summary["curves"],
summary["violations"],
radius_m,
)
except Exception: # noqa: BLE001 — 없으면 화면이 처음 열릴 때 만든다
logger.exception("초기 계획노선 폴리라인 생성 실패(계속 진행): %s", project_id)
async def run_redesign_chain(
project_id: UUID,
surface_model_id: int,
selection: dict[str, Any],
) -> None:
) -> str | None:
"""B04 재확정 후 — **사용자 입력을 유지한 채** 새 지표면 기준으로 B05·B06 재계산·저장.
관리자가 B04에서 다른 지표면 모델로 재확정하면(2026-08-04 사용자 확정) 값을
@@ -331,8 +414,8 @@ async def run_redesign_chain(
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_longitudinal_section,
update_cross_section_design,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Confirm import confirm_sections
from B06_Section.B06_Section_Schema import SectionConfirmRequest
from common_util.common_util_initial_snapshot import (
@@ -346,6 +429,7 @@ async def run_redesign_chain(
pool = get_db_pool()
project_root: Path | None = None
marks = [("시작", time.perf_counter())]
try:
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
@@ -384,7 +468,9 @@ async def run_redesign_chain(
logger.warning(
"재확정 체인 중단(stage 2 params에 제어점 없음): project_id=%s", project_id
)
return
return "노선 제어점(BP·EP)이 없어 재계산할 수 없습니다."
marks.append(("준비·사용자입력 회수", time.perf_counter()))
# 2) B05 재계산 — 지표면 관련 값만 새 확정 선택으로 교체, 나머지는 사용자 저장분.
request = RouteSolveRequest(
@@ -392,7 +478,11 @@ async def run_redesign_chain(
method=str(selection.get("method") or params.get("method") or "dtm"),
smooth=bool(selection.get("smooth", params.get("smooth", False))),
surface_model_id=surface_model_id,
algorithm=str(params.get("algorithm") or "dijkstra"),
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀면 탐색 제약(종단경사·
# 최소곡선반지름·회피지역)에 걸려 체인이 통째로 멈춘다(2026-09-06 실측:
# 「세그먼트 1 (BP → CP1) 경로 탐색 실패」로 종횡단만 옛 노선에 남았음).
# PLAN 0-3 에서 자동탐색은 접기로 했고 노선은 사용자가 직접 고친다.
algorithm="as_planned",
bp=points["bp"],
ep=points["ep"],
cp=points.get("cp") or [],
@@ -422,13 +512,21 @@ async def run_redesign_chain(
)
solve_result: Any = await solve_route(project_id, request)
if isinstance(solve_result, JSONResponse):
# 사유까지 남긴다 — 상태 코드만으로는 무엇이 막았는지 못 짚는다(2026-09-06
# 노선 편집 [확인]이 조용히 400 으로 끊겨 종횡단만 옛 노선에 남았음).
try:
reason = bytes(solve_result.body).decode("utf-8", "replace")[:300]
except Exception: # noqa: BLE001 — 로그용이라 실패해도 흐름을 막지 않는다
reason = "(본문 없음)"
logger.error(
"재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s",
"재확정 체인 중단(B05 재계산 실패): project_id=%s status=%s reason=%s",
project_id,
solve_result.status_code,
reason,
)
return
return f"노선 재계산 실패({solve_result.status_code}): {reason}"
new_route_id = int(solve_result.route_id)
marks.append(("노선 재계산(solve)", time.perf_counter()))
logger.info(
"재확정 체인 B05 재계산 완료: project_id=%s %s%s",
project_id,
@@ -436,33 +534,11 @@ async def run_redesign_chain(
new_route_id,
)
# 3) 옛 측점별 사용자 설계를 chainage 매칭으로 새 경로에 이월(비치명적).
# 3) 옛 측점별 사용자 설계 이월은 **측점을 다시 만든 뒤**(아래 5) 한다.
# 여기서 하면 `_prepare_drainage_pipes_and_reprofile(refresh=True)` 가 측점을
# 새로 만들며 덮어써 값이 사라진다(2026-09-06 실측: 다단 구간값 `extra_spans` 가
# 노선 변경 뒤 0건이 됐음). 옛 설계는 `old_designs` 로 이미 손에 있다.
carried = 0
try:
async with pool.acquire() as connection:
await connection.begin()
try:
for record in old_designs:
design = record.get("design")
if not isinstance(design, dict):
continue
await update_cross_section_design(
connection,
route_id=new_route_id,
chainage_m=float(record["chainage_m"]),
design=design,
project_id=project_id,
)
carried += 1
await connection.commit()
except Exception:
await connection.rollback()
raise
except Exception:
logger.exception(
"재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id
)
logger.info("재확정 체인 설계 이월: project_id=%s %d", project_id, carried)
# 4) B05 확정 → B06 확정(옛 표준단면 설정 이월, 미지정 측점 기본값 채움).
# 재확정 후에도 stage 2·3은 IN_PROGRESS로 남겨 사용자 재검토를 받는다.
@@ -473,11 +549,44 @@ async def run_redesign_chain(
project_id,
confirm_result.status_code,
)
return
return f"노선 확정 실패({confirm_result.status_code})"
marks.append(("B05 확정", time.perf_counter()))
# 노선이 바뀌었으므로 배수유역·관 지점도 새 노선 기준으로 다시 만든다 — 옛 관 자리로
# 계획선을 앉히면 전부 어긋나고, 저장분은 노선 변경을 스스로 알지 못한다.
await _prepare_drainage_pipes_and_reprofile(project_id, new_route_id, refresh=True)
marks.append(("배수·관·측점 재생성", time.perf_counter()))
# 5) 이제 측점이 새 노선 기준으로 다 섰다 — 옛 사용자 설계를 **누가거리로** 얹는다.
# 묶어 쓰므로 행 수와 무관하게 왕복 두 번이다(`merge_cross_section_designs`).
try:
entries = [
(float(record["chainage_m"]), record["design"])
for record in old_designs
if isinstance(record.get("design"), dict)
]
async with pool.acquire() as connection:
await connection.begin()
try:
carried = await merge_cross_section_designs(
connection,
route_id=new_route_id,
entries=entries,
replace=True,
project_id=project_id,
)
await connection.commit()
except Exception:
await connection.rollback()
raise
except Exception:
logger.exception(
"재확정 체인 — 옛 설계 이월 실패(계속 진행): project_id=%s", project_id
)
logger.info("재확정 체인 설계 이월: project_id=%s %d", project_id, carried)
marks.append(("설계 이월", time.perf_counter()))
old_options = ((old_longitudinal or {}).get("data") or {}).get("options") or {}
section_request = (
SectionConfirmRequest(standard_cross_section=old_options["standard_cross_section"])
@@ -493,7 +602,9 @@ async def run_redesign_chain(
project_id,
sections_result.status_code,
)
return
return f"종횡단 확정 실패({sections_result.status_code})"
marks.append(("B06 확정(서버 재계산 포함)", time.perf_counter()))
_log_steps("재확정 체인", marks)
logger.info(
"재확정 체인 완료: project_id=%s route %s%s (설계 %d건 이월)",
project_id,
@@ -501,8 +612,10 @@ async def run_redesign_chain(
new_route_id,
carried,
)
except Exception:
return None
except Exception as exc:
logger.exception("재확정 체인 실패: project_id=%s", project_id)
return f"재계산 중 오류: {exc}"
finally:
if project_root is not None:
clear_designing(project_root)
+10 -3
View File
@@ -79,11 +79,18 @@ async def trigger_wf1_analysis_and_email(
await connection.commit()
stored_path = await get_project_storage_relative_path(connection, project_id)
project_info = await _get_project_notification_info(connection, project_id)
from B04_PreProcess.B04_PreProcess_Repository import get_input_file
from B04_PreProcess.B04_PreProcess_Repository import (
get_input_file,
list_project_point_cloud_paths,
)
input_file = await get_input_file(connection, project_id, input_file_id)
project_root = Path(resolve_stored_project_path(stored_path))
# 지형 파일이 여러 장이면 합쳐서 한 벌로 전처리한다(2026-09-06 사용자 확정).
terrain_paths = await list_project_point_cloud_paths(
connection, project_id, project_root
)
project_root = Path(resolve_stored_project_path(stored_path))
source_path = project_root / Path(str(input_file["raw_file_path"]))
# LAS 없는 설계(2026-08-30): 입력이 계획노선 CSV면 도엽등고선 서피스 분석으로 간다.
las_free = str(input_file.get("file_type") or "").lower() not in {"las", "laz"}
@@ -118,7 +125,7 @@ async def trigger_wf1_analysis_and_email(
analysis_result = await asyncio.to_thread(
run_surface_analysis,
project_root,
source_path,
terrain_paths or [source_path],
source_filters=None,
methods=methods,
force=False,
+14 -30
View File
@@ -24,15 +24,11 @@ import { createUploadFlow } from "./B03_FileInput_UI_Page_Flow";
import {
isSlotRequired,
slotForOverviewFile,
terrainCoverage,
validateFileForSlot,
validateSlots,
} from "./B03_FileInput_UI_Page_Rules";
import {
readCrsLabel,
readExtent,
renderSlotPreview,
type PreviewExtent,
} from "./B03_FileInput_UI_Preview";
import { readCrsLabel, renderSlotPreview } from "./B03_FileInput_UI_Preview";
import { confirmReplaceUpload } from "./B03_FileInput_UI_Upload";
import {
createFileCardTemplate,
@@ -41,9 +37,11 @@ import {
initializeSlots,
makeSessionKey,
planSlotAssignments,
pushExtraFile,
ROUTE_SLOTS,
SHAPEFILE_DEPENDENT_SLOTS,
slotConfigs,
slotFileLabel,
TERRAIN_SLOTS,
type FileSlot,
type FileSlotState,
@@ -209,7 +207,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const percent = state.file ? Math.min(100, (state.progressBytes / state.file.size) * 100) : 0;
// 로컬 파일이 없어도 서버에 업로드된 파일이 있으면 그 정보(정본)를 보여준다.
if (fileName) fileName.textContent = state.file?.name ?? state.serverUploaded?.name ?? "";
if (fileName) fileName.textContent = slotFileLabel(state);
if (fileSize) {
fileSize.textContent = state.file
? formatBytes(state.file.size)
@@ -240,7 +238,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
const preview = card.querySelector<HTMLDivElement>(".b03-file__preview");
if (preview) {
const terrain = terrainCoverage();
const terrain = terrainCoverage(slots);
renderSlotPreview(preview, {
metadata: state.serverUploaded?.metadata,
// 업로드·분석이 끝난 카드에만 보인다 (2026-09-04 사용자 지시).
@@ -258,26 +256,6 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
* .
* (2026-09-04).
*/
function terrainCoverage(): { extent: PreviewExtent | null; crs: string | null } {
let extent: PreviewExtent | null = null;
let crs: string | null = null;
for (const slot of TERRAIN_SLOTS) {
const metadata = slots.get(slot)?.serverUploaded?.metadata;
const next = readExtent(metadata);
if (!next) continue;
crs ??= readCrsLabel(metadata);
extent = extent
? {
xMin: Math.min(extent.xMin, next.xMin),
xMax: Math.max(extent.xMax, next.xMax),
yMin: Math.min(extent.yMin, next.yMin),
yMax: Math.max(extent.yMax, next.yMax),
}
: next;
}
return { extent, crs };
}
function showErrorMessage(slot: FileSlot, error: string): void {
const state = slots.get(slot);
if (!state) return;
@@ -297,8 +275,13 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
showErrorMessage(state.slot, `${validation} ${file.name}`);
return;
}
if (!targetSlot && state.file && state.file.name !== file.name) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
// 지형 자료는 도엽별로 여러 장이 온다 — 카드에 더 담고 전처리가 합쳐 쓴다.
if (state.file && state.file.name !== file.name) {
if (!pushExtraFile(state, file)) {
showErrorMessage(state.slot, `${L("B03_File_Error_DuplicateSlot")} ${file.name}`);
return;
}
renderSlot(state.slot);
return;
}
// 서버에 이미 완료된 슬롯이면 교체 확인을 받는다(2026-08-04 사용자 지시). 이어올리기로
@@ -382,6 +365,7 @@ export async function renderB03FileInput(root: HTMLElement): Promise<void> {
localStorage.removeItem(makeSessionKey(activeProjectId, state.file));
}
state.file = undefined;
state.extraFiles = undefined;
state.uploadSessionId = undefined;
state.uploadStatus = "pending";
state.progressBytes = 0;
+13 -3
View File
@@ -201,14 +201,24 @@ export function createUploadFlow(ctx: UploadFlowContext): UploadFlowHandle {
ctx.pageError.textContent = "";
ctx.setUploading(true);
try {
for (let index = 0; index < targetStates.length; index += 1) {
const state = targetStates[index];
// 지형 자료는 한 카드에 여러 장이 담길 수 있다 — 카드 순서대로 한 장씩 올린다.
const jobs = targetStates.flatMap((state) =>
[state.file!, ...(state.extraFiles ?? [])].map((file) => ({ state, file })),
);
for (let index = 0; index < jobs.length; index += 1) {
const { state, file } = jobs[index];
if (file !== state.file) {
// 앞 파일이 쓰던 전송 세션·진행률을 물려받지 않게 되돌린다.
state.uploadSessionId = undefined;
state.progressBytes = 0;
}
await uploadOneFile(
ctx.projectId(),
state,
index === targetStates.length - 1,
index === jobs.length - 1,
() => ctx.renderSlot(state.slot),
ctx.lasFreeDesign(),
file,
);
}
clearDerivedCaches(ctx.projectId());
@@ -10,6 +10,7 @@
import { UPLOAD_MAX_FILES, UPLOAD_MAX_MB } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import type { UploadOverviewFile } from "./B03_FileInput_Api_Fetch";
import { readCrsLabel, readExtent, type PreviewExtent } from "./B03_FileInput_UI_Preview";
import {
getExtension,
SHAPEFILE_DEPENDENT_SLOTS,
@@ -105,3 +106,31 @@ export function slotForOverviewFile(
(candidate) => candidate.slot !== "route_prj" && candidate.extensions.includes(extension),
)?.slot;
}
/**
* .
* (··) . 700
* (2026-09-06).
*/
export function terrainCoverage(slots: SlotMap): {
extent: PreviewExtent | null;
crs: string | null;
} {
let extent: PreviewExtent | null = null;
let crs: string | null = null;
for (const slot of TERRAIN_SLOTS) {
const metadata = slots.get(slot)?.serverUploaded?.metadata;
const next = readExtent(metadata);
if (!next) continue;
crs ??= readCrsLabel(metadata);
extent = extent
? {
xMin: Math.min(extent.xMin, next.xMin),
xMax: Math.max(extent.xMax, next.xMax),
yMin: Math.min(extent.yMin, next.yMin),
yMax: Math.max(extent.yMax, next.yMax),
}
: next;
}
return { extent, crs };
}
+25
View File
@@ -28,6 +28,12 @@ export interface SlotConfig {
export interface FileSlotState extends SlotConfig {
file?: File;
/**
* () .
* ,
* (2026-09-06 ). .
*/
extraFiles?: File[];
uploadSessionId?: string;
uploadStatus: UploadStatus;
progressBytes: number;
@@ -123,6 +129,25 @@ const SLOT_CONFIGS: readonly SlotConfig[] = [
},
];
/** 카드에 적을 파일 이름 — 여러 장이면 「첫 장 외 N장」. */
export function slotFileLabel(state: FileSlotState): string {
const name = state.file?.name ?? state.serverUploaded?.name ?? "";
const extras = state.extraFiles?.length ?? 0;
return extras > 0 ? `${name}${extras}` : name;
}
/**
* true, false.
* () . .
*/
export function pushExtraFile(state: FileSlotState, file: File): boolean {
if (state.slot !== "las_laz") return false;
const extras = state.extraFiles ?? [];
if (!extras.some((item) => item.name === file.name)) state.extraFiles = [...extras, file];
state.error = undefined;
return true;
}
export function getExtension(fileName: string): string {
const index = fileName.lastIndexOf(".");
return index >= 0 ? fileName.slice(index).toLowerCase() : "";
+3 -1
View File
@@ -83,8 +83,10 @@ export async function uploadOneFile(
completeUpload: boolean,
onProgress: () => void,
lasFree = false,
// 지형 자료는 한 카드에 여러 장이 담긴다 — 올릴 파일을 지정받는다(2026-09-06).
target?: File,
): Promise<UploadedFileResult[]> {
const file = state.file;
const file = target ?? state.file;
if (!file) return [];
state.error = undefined;
state.uploadStatus = "uploading";
+30 -14
View File
@@ -7,7 +7,7 @@
import json
import logging
import time
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
@@ -37,14 +37,22 @@ GROUND_POINT_SAMPLE_LIMIT = 500_000
GROUND_POINT_CACHE_VERSION = 2
def _source_identity(las_path: Path) -> dict[str, Any]:
"""입력 LAS의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2)."""
stat = las_path.stat()
return {
"filename": las_path.name,
"size_bytes": int(stat.st_size),
"mtime": float(stat.st_mtime),
}
def _source_identity(las_paths: list[Path]) -> dict[str, Any]:
"""입력 지형 파일들의 정체성(이름·크기·수정시각)으로 캐시 세대를 식별한다 (PLAN B-2).
여러 장을 병합하므로 ** 장이라도 바뀌거나 늘고 줄면** 다시 계산해야 한다
(2026-09-06 다중 입력).
"""
files = [
{
"filename": path.name,
"size_bytes": int(path.stat().st_size),
"mtime": float(path.stat().st_mtime),
}
for path in sorted(las_paths, key=lambda item: item.name)
]
# 한 장일 때는 옛 형식과 같은 모양을 유지한다 — 이미 만든 캐시를 헛되이 버리지 않는다.
return files[0] if len(files) == 1 else {"files": files}
def _relative_to_project(project_root: Path, path: Path) -> str:
@@ -108,7 +116,7 @@ def cache_ground_points(
def run_surface_analysis(
project_root: Path,
las_path: Path,
las_path: Path | Sequence[Path],
*,
source_filters: list[str] | None,
methods: list[str],
@@ -117,6 +125,9 @@ def run_surface_analysis(
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
`las_path` 지형 파일 또는 여러 장이다 여러 장이면 합친 범위로 벌을
만든다(2026-09-06 사용자 확정).
`source_filters` 비면 입력 LAS를 보고 기본 필터를 정한다(자동 전처리 경로).
반환 dict:
@@ -132,6 +143,9 @@ def run_surface_analysis(
on_progress(percent, stage, message)
total_started = time.monotonic()
las_paths = [las_path] if isinstance(las_path, Path) else [Path(item) for item in las_path]
if not las_paths:
raise ValueError("지형 파일이 없습니다.")
stage_root = project_root / "B04_PreProcess"
processed_dir = stage_root / "processed"
models_dir = stage_root / "models"
@@ -140,7 +154,7 @@ def run_surface_analysis(
# 0. 입력 세대 검증: LAS가 바뀌었으면 모든 캐시를 재계산한다 (PLAN B-2)
identity_path = processed_dir / "source_identity.json"
current_identity = _source_identity(las_path)
current_identity = _source_identity(las_paths)
stored_identity: dict[str, Any] | None = None
if identity_path.is_file():
try:
@@ -154,10 +168,12 @@ def run_surface_analysis(
if rebuild or not structured_path.is_file():
_report(10, "structurize", "LAS 구조화 중")
step_started = time.monotonic()
structured_path = structurize_las(las_path, processed_dir)
structured_path = structurize_las(las_paths, processed_dir)
atomic_write_json(identity_path, current_identity)
logger.info(
"B04 LAS 구조화 완료: %s (%.1fs)", las_path.name, time.monotonic() - step_started
"B04 LAS 구조화 완료: %s (%.1fs)",
", ".join(path.name for path in las_paths),
time.monotonic() - step_started,
)
else:
_report(10, "structurize", "구조화 캐시 재사용")
@@ -254,7 +270,7 @@ def run_surface_analysis(
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_path.parent, rebuild, report=_report
project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
@@ -1,82 +1,308 @@
"""B04 LAS/LAZ 고속 구조화 엔진."""
"""B04 LAS/LAZ 고속 구조화 엔진 — 여러 장을 한 벌로 병합한다 (2026-09-06 사용자 확정).
드론 라이다는 사업지가 넓으면 도엽별로 여러 장이 온다. 여기서 **합친 범위** 벌을
만들고, 단계(지면필터·모델·등고선·배수) 받는 형식이 그대로라 손대지 않는다.
점이 임계를 넘으면 **(기본 0.5m)마다 최저점 하나만** 남긴다(씨닝). 설계가 쓰는 격자가
1m(지면필터 2m·CSF 1.5m) 0.5m 설계보다 촘촘해 결과 표고가 사실상 같고, 30GB
장이 메모리 29GB 1.4GB 내려간다. 임계 아래면 원본 점을 그대로 쓴다 작은 자료의
결과는 바뀌지 않는다.
"""
import logging
import os
import tempfile
from collections.abc import Callable
from collections.abc import Callable, Sequence
from pathlib import Path
from typing import Any
import laspy
import numpy as np
from common_util.common_util_json import replace_with_retry
from config.config_system import SURFACE_DEFAULT_RGB_VALUE, SURFACE_LAS_CHUNK_SIZE
from config.config_system import (
SURFACE_DEFAULT_RGB_VALUE,
SURFACE_LAS_CHUNK_SIZE,
SURFACE_MERGE_MAX_GAP_M,
SURFACE_THIN_CELL_SIZE_M,
SURFACE_THIN_MAX_CELLS,
SURFACE_THIN_TRIGGER_POINTS,
)
logger = logging.getLogger(__name__)
ProgressCallback = Callable[[int], None]
PathLike = str | Path
# 청크에서 점과 함께 옮기는 속성들 — 파일에 없으면 기본값이 남는다.
_ATTRIBUTES = ("intensity", "rgb", "return_number", "number_of_returns", "classification")
def _as_list(las_path: PathLike | Sequence[PathLike]) -> list[Path]:
if isinstance(las_path, (str, Path)):
return [Path(las_path)]
return [Path(item) for item in las_path]
def point_cloud_extent(path: PathLike) -> tuple[int, tuple[float, float, float, float]]:
"""머리글만 읽어 점 수와 XY 범위를 돌려준다 — 파일 크기와 무관하게 즉시 끝난다."""
with laspy.open(Path(path)) as las_file:
header = las_file.header
return int(header.point_count), (
float(header.mins[0]),
float(header.mins[1]),
float(header.maxs[0]),
float(header.maxs[1]),
)
def merge_gap_error(
paths: Sequence[PathLike], gap_m: float = SURFACE_MERGE_MAX_GAP_M
) -> str | None:
"""서로 멀리 떨어진 지형 파일이 섞였는지 — 문제면 안내 문구, 없으면 None.
다른 사업지 파일이나 좌표계가 다른 파일이 섞이면 합친 범위가 통째로 어긋나 격자가
터진다. 도엽으로 나뉜 자료는 경계가 맞닿으므로 여유를 두고 **어느 파일과도 만나지
않는 파일** 걸러 낸다.
"""
sources = _as_list(paths)
if len(sources) < 2:
return None
boxes = [(path, point_cloud_extent(path)[1]) for path in sources]
for index, (path, box) in enumerate(boxes):
near = any(
box[0] - gap_m <= other[2]
and other[0] - gap_m <= box[2]
and box[1] - gap_m <= other[3]
and other[1] - gap_m <= box[3]
for other_index, (_, other) in enumerate(boxes)
if other_index != index
)
if not near:
return (
f"지형 파일 「{path.name}」의 좌표가 다른 파일과 "
f"{int(gap_m):,}m 넘게 떨어져 있습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
return None
class _Merged:
"""합친 점을 담는 그릇 — 원본 유지형과 씨닝형이 같은 모양으로 낸다."""
def __init__(self, capacity: int) -> None:
self.xyz = np.empty((capacity, 3), dtype=np.float64)
self.intensity = np.zeros(capacity, dtype=np.uint16)
self.rgb = np.full((capacity, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(capacity, dtype=np.uint8)
self.number_of_returns = np.ones(capacity, dtype=np.uint8)
self.classification = np.zeros(capacity, dtype=np.uint8)
self.size = 0
def arrays(self) -> dict[str, np.ndarray]:
end = self.size
return {
"xyz": self.xyz[:end],
"intensity": self.intensity[:end],
"rgb": self.rgb[:end],
"return_number": self.return_number[:end],
"number_of_returns": self.number_of_returns[:end],
"classification": self.classification[:end],
}
def append(self, columns: dict[str, np.ndarray]) -> None:
count = len(columns["x"])
section = slice(self.size, self.size + count)
self.xyz[section, 0] = columns["x"]
self.xyz[section, 1] = columns["y"]
self.xyz[section, 2] = columns["z"]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[section] = columns[key]
self.size += count
def _chunk_columns(chunk: Any, dimensions: set[str]) -> dict[str, np.ndarray]:
"""청크에서 쓸 값만 꺼낸다. 파일에 없는 항목은 키를 빼서 기본값이 남게 한다."""
columns: dict[str, np.ndarray] = {
"x": np.asarray(chunk.x, dtype=np.float64),
"y": np.asarray(chunk.y, dtype=np.float64),
"z": np.asarray(chunk.z, dtype=np.float64),
}
if "intensity" in dimensions:
columns["intensity"] = np.asarray(chunk.intensity, dtype=np.uint16)
if {"red", "green", "blue"}.issubset(dimensions):
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
columns["rgb"] = colors.clip(0, 255).astype(np.uint8)
if {"return_number", "number_of_returns"}.issubset(dimensions):
columns["return_number"] = np.asarray(chunk.return_number, dtype=np.uint8)
columns["number_of_returns"] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if "classification" in dimensions:
columns["classification"] = np.asarray(chunk.classification, dtype=np.uint8)
return columns
class _ThinGrid:
"""씨닝형 — **지면 분류점은 전부** 남기고, 나머지는 칸마다 최저점 하나만 남긴다.
설계 지표면을 만드는 것은 지면점이다(업체가 분류해 ASPRS class 2). 점을 하나도
버리지 않으므로 **지면 결과는 씨닝 전과 완전히 같다**(2026-09-06 용화 실측: 1m 지면
격자 141,969 전부 표고 차이 0). 지면점은 원본의 2~3%뿐이라 남겨도 가볍다.
나머지(수목·구조물·잡음) 칸마다 최저점만 남긴다 분류가 없는 자료에서 CSF·PMF가
지면을 찾을 밑그림으로 충분하다(CSF 간격 1.5m > 0.5m).
청크 안에서 같은 칸이 여러 나오면 뒤에 값이 이겨 최저점이 아니게 된다.
그래서 청크를 (, 표고) 정렬해 **칸마다 ** 골라 격자와 견준다.
"""
#: ASPRS 지면 분류 코드.
GROUND_CLASS = 2
def __init__(self, bounds: np.ndarray, cell_size: float) -> None:
self.cell_size = cell_size
self.x_min = float(bounds[0, 0])
self.y_min = float(bounds[1, 0])
self.width = int(np.ceil((float(bounds[0, 1]) - self.x_min) / cell_size)) + 1
self.height = int(np.ceil((float(bounds[1, 1]) - self.y_min) / cell_size)) + 1
cells = self.width * self.height
if cells > SURFACE_THIN_MAX_CELLS:
raise ValueError(
"지형 자료의 합친 범위가 너무 넓습니다."
" 같은 사업지의 파일인지, 좌표계가 같은지 확인해 주십시오."
)
self.best_z = np.full(cells, np.inf, dtype=np.float64)
self.x = np.zeros(cells, dtype=np.float64)
self.y = np.zeros(cells, dtype=np.float64)
self.intensity = np.zeros(cells, dtype=np.uint16)
self.rgb = np.full((cells, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
self.return_number = np.ones(cells, dtype=np.uint8)
self.number_of_returns = np.ones(cells, dtype=np.uint8)
self.classification = np.zeros(cells, dtype=np.uint8)
# 그대로 남길 지면점 — 청크마다 모아 두었다가 마지막에 잇는다.
self.ground: list[dict[str, np.ndarray]] = []
def add(self, columns: dict[str, np.ndarray]) -> None:
classification = columns.get("classification")
if classification is not None:
is_ground = classification == self.GROUND_CLASS
if is_ground.any():
self.ground.append({key: value[is_ground] for key, value in columns.items()})
keep = ~is_ground
columns = {key: value[keep] for key, value in columns.items()}
x, y, z = columns["x"], columns["y"], columns["z"]
if not len(x):
return
grid_x = np.clip(((x - self.x_min) / self.cell_size).astype(np.int64), 0, self.width - 1)
grid_y = np.clip(((y - self.y_min) / self.cell_size).astype(np.int64), 0, self.height - 1)
cell = grid_y * self.width + grid_x
order = np.lexsort((z, cell))
sorted_cell = cell[order]
first = np.ones(len(order), dtype=bool)
first[1:] = sorted_cell[1:] != sorted_cell[:-1]
candidate = order[first]
candidate_cell = cell[candidate]
better = z[candidate] < self.best_z[candidate_cell]
chosen = candidate[better]
target = candidate_cell[better]
self.best_z[target] = z[chosen]
self.x[target] = x[chosen]
self.y[target] = y[chosen]
for key in _ATTRIBUTES:
if key in columns:
getattr(self, key)[target] = columns[key][chosen]
def collect(self) -> _Merged:
occupied = np.flatnonzero(np.isfinite(self.best_z))
ground_count = sum(len(item["x"]) for item in self.ground)
merged = _Merged(len(occupied) + ground_count)
merged.xyz[: len(occupied), 0] = self.x[occupied]
merged.xyz[: len(occupied), 1] = self.y[occupied]
merged.xyz[: len(occupied), 2] = self.best_z[occupied]
for key in _ATTRIBUTES:
getattr(merged, key)[: len(occupied)] = getattr(self, key)[occupied]
merged.size = len(occupied)
for item in self.ground:
merged.append(item)
return merged
def _headers(sources: list[Path]) -> tuple[int, np.ndarray, bool]:
"""전체 점 수·합친 범위(3x2)·색 보유 여부를 머리글만 읽어 구한다."""
total = 0
has_rgb = False
mins = np.full(3, np.inf, dtype=np.float64)
maxs = np.full(3, -np.inf, dtype=np.float64)
for source in sources:
with laspy.open(source) as las_file:
header = las_file.header
total += int(header.point_count)
mins = np.minimum(mins, np.asarray(header.mins, dtype=np.float64))
maxs = np.maximum(maxs, np.asarray(header.maxs, dtype=np.float64))
dimensions = set(header.point_format.dimension_names)
has_rgb = has_rgb or {"red", "green", "blue"}.issubset(dimensions)
if not np.isfinite(mins).all():
mins = np.zeros(3, dtype=np.float64)
maxs = np.zeros(3, dtype=np.float64)
return total, np.column_stack((mins, maxs)), has_rgb
def _merge_sources(
sources: list[Path],
total_points: int,
bounds: np.ndarray,
thin: bool,
progress_callback: ProgressCallback | None,
) -> _Merged:
grid = _ThinGrid(bounds, SURFACE_THIN_CELL_SIZE_M) if thin else None
merged = _Merged(total_points) if grid is None else None
done = 0
for source in sources:
with laspy.open(source) as las_file:
dimensions = set(las_file.header.point_format.dimension_names)
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
columns = _chunk_columns(chunk, dimensions)
if grid is not None:
grid.add(columns)
else:
merged.append(columns)
done += len(columns["x"])
if progress_callback:
progress_callback(int(done / total_points * 100) if total_points else 100)
return grid.collect() if grid is not None else merged
def structurize_las(
las_path: str | Path,
las_path: PathLike | Sequence[PathLike],
output_dir: str | Path,
progress_callback: Callable[[int], None] | None = None,
progress_callback: ProgressCallback | None = None,
) -> Path:
"""LAS/LAZ 속성을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
source = Path(las_path)
"""지형 파일 한 장 또는 여러 장을 청크로 읽어 B04 structured.npz로 원자적 저장한다."""
sources = _as_list(las_path)
if not sources:
raise ValueError("구조화할 지형 파일이 없습니다.")
target_dir = Path(output_dir)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / "structured.npz"
with laspy.open(source) as las_file:
header = las_file.header
total_points = int(header.point_count)
point_format = header.point_format
dimensions = set(point_format.dimension_names)
has_rgb = {"red", "green", "blue"}.issubset(dimensions)
has_intensity = "intensity" in dimensions
has_returns = {"return_number", "number_of_returns"}.issubset(dimensions)
has_classification = "classification" in dimensions
bounds = np.array(
[
[float(header.mins[0]), float(header.maxs[0])],
[float(header.mins[1]), float(header.maxs[1])],
[float(header.mins[2]), float(header.maxs[2])],
],
dtype=np.float64,
)
xyz = np.empty((total_points, 3), dtype=np.float64)
intensity = np.zeros(total_points, dtype=np.uint16)
rgb = np.full((total_points, 3), SURFACE_DEFAULT_RGB_VALUE, dtype=np.uint8)
return_number = np.ones(total_points, dtype=np.uint8)
number_of_returns = np.ones(total_points, dtype=np.uint8)
classification = np.zeros(total_points, dtype=np.uint8)
offset = 0
for chunk in las_file.chunk_iterator(SURFACE_LAS_CHUNK_SIZE):
chunk_size = len(chunk)
section = slice(offset, offset + chunk_size)
xyz[section, 0] = np.asarray(chunk.x, dtype=np.float64)
xyz[section, 1] = np.asarray(chunk.y, dtype=np.float64)
xyz[section, 2] = np.asarray(chunk.z, dtype=np.float64)
if has_intensity:
intensity[section] = np.asarray(chunk.intensity, dtype=np.uint16)
if has_rgb:
colors = np.stack(
[
np.asarray(chunk.red, dtype=np.float64),
np.asarray(chunk.green, dtype=np.float64),
np.asarray(chunk.blue, dtype=np.float64),
],
axis=1,
)
if colors.size and float(colors.max()) > 255.0:
colors /= 256.0
rgb[section] = colors.clip(0, 255).astype(np.uint8)
if has_returns:
return_number[section] = np.asarray(chunk.return_number, dtype=np.uint8)
number_of_returns[section] = np.asarray(chunk.number_of_returns, dtype=np.uint8)
if has_classification:
classification[section] = np.asarray(chunk.classification, dtype=np.uint8)
offset += chunk_size
if progress_callback:
progress_callback(int(offset / total_points * 100) if total_points else 100)
total_points, bounds, has_rgb = _headers(sources)
thin = total_points > SURFACE_THIN_TRIGGER_POINTS
merged = _merge_sources(sources, total_points, bounds, thin, progress_callback)
logger.info(
"B04 구조화: 파일 %d장 원본 %d점 → 저장 %d점 (씨닝 %s)",
len(sources),
total_points,
merged.size,
f"{SURFACE_THIN_CELL_SIZE_M}m 칸" if thin else "없음",
)
temporary_path: Path | None = None
try:
@@ -90,14 +316,12 @@ def structurize_las(
temporary_path = Path(temporary.name)
np.savez_compressed(
temporary,
xyz=xyz,
intensity=intensity,
rgb=rgb,
return_number=return_number,
number_of_returns=number_of_returns,
classification=classification,
**merged.arrays(),
bounds=bounds,
total_points=np.array([total_points], dtype=np.int64),
total_points=np.array([merged.size], dtype=np.int64),
source_point_count=np.array([total_points], dtype=np.int64),
source_file_count=np.array([len(sources)], dtype=np.int64),
thinned=np.array([int(thin)], dtype=np.int8),
has_rgb=np.array([int(has_rgb)], dtype=np.int8),
)
temporary.flush()
@@ -109,6 +333,6 @@ def structurize_las(
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
if progress_callback and total_points == 0:
if progress_callback:
progress_callback(100)
return target
@@ -166,8 +166,12 @@ def preview_stages(
"""
if len(vertices) < 2:
return None
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
marks.append(("1차 영역(resolve_primary_region)", time.perf_counter()))
if region is None:
return None
@@ -185,6 +189,7 @@ def preview_stages(
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
return StagePreview(region=region)
marks.append(("격자 확장·흐름 판정(expand_by_red_boundary)", time.perf_counter()))
analysis = expansion.analysis
spec = analysis.spec
red = analysis.flow.reaches_road & analysis.flow.analyzed
@@ -194,6 +199,7 @@ def preview_stages(
# 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다.
routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None
strength_curve = _preview_strength(analysis, routing, red, route_line.length)
marks.append(("흐름 강도(trace_flow)", time.perf_counter()))
# ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽.
boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols))
@@ -209,6 +215,8 @@ def preview_stages(
# B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다.
flow_arrows = build_flow_arrows(analysis, analysis.flow)
marks.append(("외곽선·기본 관·화살표", time.perf_counter()))
_log_steps("배수유역 preview_stages", marks)
logger.info(
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
@@ -23,6 +23,7 @@
from __future__ import annotations
import logging
import time
import math
from dataclasses import dataclass
from typing import Any
@@ -71,12 +72,25 @@ class ContourDescent:
levels: list[float] # 사용된 등고 표고(내림차순)
def rasterize_contours(
spec: GridSpec,
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
) -> tuple[np.ndarray, list[float]]:
"""등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN)."""
_LINES_CACHE: list[tuple[Any, int, float | None, dict[float, list[Any]]]] = []
def _lines_by_level(
contour_features: list[dict[str, Any]], elevation_floor_m: float | None
) -> dict[float, list[Any]]:
"""등고선 피처를 표고별 선 묶음으로 푼다 — **격자와 무관**하므로 한 번만 푼다.
확장 회차마다 다시 부르는데 피처 4,200개를 매번 `shape()` 푸는 비용이 그대로
붙었다. 같은 목록·같은 하한이면 그대로 돌려준다(목록 객체를 함께 들고 있어 id
다른 목록에 재사용되지 않는다).
"""
for holder, count, floor, cached in _LINES_CACHE:
if (
holder is contour_features
and count == len(contour_features)
and floor == elevation_floor_m
):
return cached
by_level: dict[float, list[Any]] = {}
for feature in contour_features:
geometry = feature.get("geometry")
@@ -95,7 +109,18 @@ def rasterize_contours(
if line.length < DRAINAGE_CONTOUR_MIN_LENGTH_M:
continue
by_level.setdefault(float(elevation), []).append(line)
_LINES_CACHE.append((contour_features, len(contour_features), elevation_floor_m, by_level))
del _LINES_CACHE[:-2]
return by_level
def rasterize_contours(
spec: GridSpec,
contour_features: list[dict[str, Any]],
elevation_floor_m: float | None = None,
) -> tuple[np.ndarray, list[float]]:
"""등고선을 격자에 굽는다. 셀마다 그 위를 지나는 등고 라인의 표고(없으면 NaN)."""
by_level = _lines_by_level(contour_features, elevation_floor_m)
burned = np.full((spec.n_rows, spec.n_cols), np.nan, dtype=np.float32)
levels = sorted(by_level, reverse=True)
transform = grid_transform(spec)
@@ -120,6 +145,107 @@ def rasterize_contours(
return burned, levels
# 밴드별 하강거리 EDT 를 **쓸 자리 둘레**에서만 돌리기 위한 값들.
#
# 왜(2026-09-07 실측) — 확장 회차마다 방향장을 다시 만드는데, 그 안의 밴드별 EDT 가
# 격자 전체를 단계마다 훑어 확장 95.8s 중 50.4s 를 썼다. 정작 쓰는 값은 그 단계의
# 밴드 셀뿐이다. 다만 `analyze_domain` 이 `domain` 을 안 넘겨 밴드가 **격자 전역에
# 흩어지므로** 바운딩박스 하나로는 안 좁아진다. 그래서 격자를 타일로 나눠 **셀이 있는
# 타일만** 그 둘레 `margin` 까지 잘라 EDT 를 돌린다.
#
# 창 밖에 더 가까운 등고선이 있을 수 있으면(창 안 최대 거리가 여유에 닿거나 창에 낮은
# 라인이 없으면) **그 타일만** 창을 4배로 넓혀 다시 잰다 — 근사가 아니라 같은 값을 싸게
# 구하는 것. 단계 전체를 격자 전체로 되돌리면 이득이 사라진다(그 방식일 때 폴백 25/91).
#
# 고른 근거(745×1035 격자·166단 중 EDT 도는 91단, 전체격자 방식 5.2s):
# **타일 128 여유 32 → 2.1s(넓힘 15)** 타일 128 여유 24 → 1.9s(넓힘 52)
# 타일 128 여유 48 → 2.5s(넓힘 7) 타일 256 여유 32 → 2.7s(넓힘 15)
# 타일 512 여유 32 → 4.5s(넓힘 15)
# 여유 24 가 0.2s 빠르지만 넓힘이 52회로 지형에 예민해 32 로 뒀다. 한 단 아래 등고선은
# 주곡선 5m·셀 1m 에서 대개 수십 칸 안이다. 넓힘 횟수는 계측 줄에 찍힌다.
DESCENT_WINDOW_MARGIN_CELLS = 32
DESCENT_TILE_CELLS = 128
def _distance_to_lower(
members: np.ndarray,
lower: np.ndarray,
margin: int | None = None,
tile: int | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, int]:
"""`members` 셀에서 한 단 낮은 등고 라인까지의 거리와 목표 셀(**전체 격자 좌표**).
번째 값은 창을 넓혀 다시 타일 수다(0 = 전부 창에서 끝남). 반환 순서는
`members` 행우선 순서 호출부가 `distance[members] = ...` 그대로 넣는다.
"""
margin = DESCENT_WINDOW_MARGIN_CELLS if margin is None else margin
tile = DESCENT_TILE_CELLS if tile is None else tile
rows, cols = members.shape
out_distance = np.zeros((rows, cols), dtype=np.float64)
out_row = np.zeros((rows, cols), dtype=np.int32)
out_col = np.zeros((rows, cols), dtype=np.int32)
widened = 0
for row_start in range(0, rows, tile):
row_stop = min(rows, row_start + tile)
for col_start in range(0, cols, tile):
col_stop = min(cols, col_start + tile)
tile_members = members[row_start:row_stop, col_start:col_stop]
if not tile_members.any():
continue
# 첫 창에서 안 닿으면 **그 타일만** 창을 넓혀 다시 잰다 — 단계 전체를 격자
# 전체로 되돌리면 이득이 사라진다(실측 폴백 25/91).
reach = margin
attempt = 0
while True:
win_row0 = max(0, row_start - reach)
win_row1 = min(rows, row_stop + reach)
win_col0 = max(0, col_start - reach)
win_col1 = min(cols, col_stop + reach)
whole = win_row0 == 0 and win_col0 == 0 and win_row1 == rows and win_col1 == cols
window_lower = lower[win_row0:win_row1, win_col0:win_col1]
if not window_lower.any():
if whole:
return _full_distance_to_lower(members, lower)
reach *= 4
attempt += 1
widened += 1
continue
step, (step_row, step_col) = distance_transform_edt(
~window_lower, return_indices=True
)
in_window = np.zeros(window_lower.shape, dtype=bool)
in_window[
row_start - win_row0 : row_stop - win_row0,
col_start - win_col0 : col_stop - win_col0,
] = tile_members
hit_rows, hit_cols = np.nonzero(in_window)
values = step[hit_rows, hit_cols]
# 타일 둘레로 `reach` 를 뒀으므로, 거리가 그보다 짧으면 창 밖에 더
# 가까운 것은 있을 수 없다. 닿으면 창을 넓혀 다시 잰다.
if not whole and values.size and float(values.max()) >= reach:
reach *= 4
attempt += 1
widened += 1
continue
break
global_rows = hit_rows + win_row0
global_cols = hit_cols + win_col0
out_distance[global_rows, global_cols] = values
out_row[global_rows, global_cols] = step_row[hit_rows, hit_cols] + win_row0
out_col[global_rows, global_cols] = step_col[hit_rows, hit_cols] + win_col0
return out_distance[members], out_row[members], out_col[members], widened
def _full_distance_to_lower(
members: np.ndarray, lower: np.ndarray
) -> tuple[np.ndarray, np.ndarray, np.ndarray, bool]:
"""격자 전체 EDT — 창으로 못 믿을 때만 쓰는 폴백."""
step, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True)
return step[members], step_row[members], step_col[members], True
def build_contour_descent(
spec: GridSpec,
contour_features: list[dict[str, Any]],
@@ -127,8 +253,12 @@ def build_contour_descent(
elevation_floor_m: float | None = None,
) -> ContourDescent:
"""등고선만으로 셀별 흐름 방향을 세운다. 보간면을 만들지 않는다."""
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
rows, cols = spec.n_rows, spec.n_cols
burned, levels = rasterize_contours(spec, contour_features, elevation_floor_m)
marks.append(("등고선 굽기(rasterize_contours)", time.perf_counter()))
empty = ContourDescent(
spec=spec,
band_elevation=np.full((rows, cols), np.nan, dtype=np.float32),
@@ -146,6 +276,7 @@ def build_contour_descent(
# ② 셀마다 가장 가까운 등고 라인의 표고 = 그 셀의 밴드.
_, (near_row, near_col) = distance_transform_edt(~on_contour, return_indices=True)
band_elevation = burned[near_row, near_col].astype(np.float32)
marks.append(("밴드 표고(EDT 1회)", time.perf_counter()))
inside = domain if domain is not None else np.ones((rows, cols), dtype=bool)
band_elevation = np.where(inside, band_elevation, np.nan)
@@ -154,6 +285,8 @@ def build_contour_descent(
target_row = np.zeros((rows, cols), dtype=np.int32)
target_col = np.zeros((rows, cols), dtype=np.int32)
band_rank = np.full((rows, cols), -1, dtype=np.int32)
window_widened = 0
edt_steps = 0
for rank, elevation in enumerate(levels[:-1]):
members = inside & (band_elevation == elevation)
if not members.any():
@@ -161,14 +294,22 @@ def build_contour_descent(
lower = on_contour & (burned < elevation)
if not lower.any():
continue
step_distance, (step_row, step_col) = distance_transform_edt(~lower, return_indices=True)
distance[members] = step_distance[members].astype(np.float32)
target_row[members] = step_row[members]
target_col[members] = step_col[members]
step_distance, step_row, step_col, widened = _distance_to_lower(members, lower)
window_widened += widened
edt_steps += 1
distance[members] = step_distance.astype(np.float32)
target_row[members] = step_row
target_col[members] = step_col
band_rank[members] = len(levels) - 1 - rank # 높을수록 큰 값
# 최하단 밴드는 더 내려갈 등고 라인이 없다. 무효로 빼지 않고 **정지 셀**로 남긴다 —
# 그래야 그 자리가 도로·세류선이면 적색으로 잡히고, 아니면 물이 고이는 지점으로 보인다.
marks.append(
(
f"밴드별 하강거리(EDT {edt_steps}단 · 창 넓힘 {window_widened}회)",
time.perf_counter(),
)
)
lowest = inside & (band_elevation == levels[-1]) & (band_rank < 0)
if lowest.any():
distance[lowest] = 0.0
@@ -185,9 +326,12 @@ def build_contour_descent(
distance[valid & ~np.isfinite(distance)] = 0.0
potential = np.where(valid, band_rank.astype(np.float64) * span + distance, np.inf)
marks.append(("위치에너지 조립", time.perf_counter()))
receiver, step_length, azimuth = _route_by_potential(
spec, potential, valid, target_row, target_col
)
marks.append(("흐름 경로(_route_by_potential)", time.perf_counter()))
_log_steps(f"하강 방향장({rows}×{cols})", marks)
logger.info(
"배수유역: 등고선 하강 방향 %d셀 (밴드 %d단), 최하단 정지 %d",
int(valid.sum()),
@@ -15,6 +15,7 @@
from __future__ import annotations
import logging
import time
from dataclasses import dataclass
from typing import Any
@@ -80,8 +81,13 @@ def analyze_domain(
회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`).
해석 영역은 마지막에 마스크로만 씌운다.
"""
if descent is None or descent.spec != spec:
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
reused = descent is not None and descent.spec == spec
if not reused:
descent = build_contour_descent(spec, contour_features, None, elevation_floor_m)
marks.append(("하강 방향장" + ("(재사용)" if reused else "(새로 만듦)"), time.perf_counter()))
valid = descent.valid & domain
if not valid.any():
return None
@@ -92,9 +98,14 @@ def analyze_domain(
receiver=descent.receiver,
step_length=descent.step_length,
)
marks.append(("지형 격자 조립", time.perf_counter()))
road = rasterize_road(spec, route_line, DRAINAGE_ROAD_WIDTH_M)
marks.append(("도로 굽기(rasterize_road)", time.perf_counter()))
terrain, burned = burn_stream_flow(terrain, road, upstream_streams)
marks.append(("세류 새김(burn_stream_flow)", time.perf_counter()))
flow = classify_flow(terrain, road, burned, azimuth=descent.azimuth)
marks.append(("도로 도달 판정(classify_flow)", time.perf_counter()))
_log_steps(f"격자 해석 1회({spec.n_rows}×{spec.n_cols})", marks)
return GridAnalysis(
spec=spec, domain=domain, descent=descent, terrain=terrain, road=road, flow=flow
)
@@ -111,6 +122,7 @@ def expand_by_red_boundary(
max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS,
) -> RedExpansion | None:
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
_expand_started = time.perf_counter()
band_cells = max(1, int(round(band_m / spec.cell_m)))
# 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를
# 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다.
@@ -122,9 +134,17 @@ def expand_by_red_boundary(
if analysis is None:
return None
logger.info(
"[계측] 확장 0회차(첫 해석) %.1fs · 격자 %d×%d · 셀 %d",
time.perf_counter() - _expand_started,
analysis.spec.n_rows,
analysis.spec.n_cols,
started_cells,
)
rounds = 0
closed = False
for attempt in range(max_rounds):
_round_started = time.perf_counter()
current = analysis.spec
reaches = analysis.flow.reaches_road.reshape(current.n_rows, current.n_cols)
rim_red = outermost_cells(analysis.domain) & reaches
@@ -162,6 +182,16 @@ def expand_by_red_boundary(
break
analysis = widened_analysis
rounds += 1
logger.info(
"[계측] 확장 %d회차 %.1fs · 격자 %d×%d · 셀 %d (+%d) · 방향장 %s",
attempt + 1,
time.perf_counter() - _round_started,
grown_spec.n_rows,
grown_spec.n_cols,
int(widened.sum()),
int(added_mask.sum()),
"재사용" if grown_spec == current else "새로",
)
added_reaches = widened_analysis.flow.reaches_road.reshape(
grown_spec.n_rows, grown_spec.n_cols
@@ -90,14 +90,25 @@ def split_streams_at_road(
node_edges.setdefault(head, []).append(index)
node_edges.setdefault(tail, []).append(index)
sampler = ElevationSampler(cloud)
# 표고를 묻는 조각은 **도로 교차 노드에 닿은 것뿐**이다(나머지는 확산으로만 정해진다).
# 그 조각들만 먼저 골라 두고, 그 둘레로 삼각망을 좁힌다 — 위 클래스 주석 참조.
seed_pieces = [
(index, piece, touching)
for index, piece in enumerate(pieces)
if (touching := [node for node in ends[index] if node in crossing_nodes])
]
focus_xy = (
np.concatenate(
[np.asarray(piece.coords, dtype=np.float64)[:, :2] for _index, piece, _n in seed_pieces]
)
if seed_pieces
else None
)
sampler = ElevationSampler(cloud, focus_xy=focus_xy)
# 씨앗 조각 → 그 조각의 하류쪽 끝점(= 도로 교차 노드). 이 값이 물 흐름 방향의 기준이 된다.
upper_seeds: dict[int, tuple[float, float]] = {}
lower_seeds: dict[int, tuple[float, float]] = {}
for index, piece in enumerate(pieces):
touching = [node for node in ends[index] if node in crossing_nodes]
if not touching:
continue
for index, piece, touching in seed_pieces:
heights = sampler.at(np.array(touching, dtype=np.float64))
crossing_node = touching[int(np.argmin(heights))]
if _mean_elevation(piece, sampler) > float(np.min(heights)):
@@ -210,16 +221,42 @@ class ElevationSampler:
최근접 등고선 정점만 쓰면 오차가 등고선 간격(주곡선 5m)만큼 나서, 계곡 교차점 표고가
실제보다 등고선 위로 잡히고 상류 조각이 통째로 하류로 오판된다. TIN 선형보간을
1차로 쓰고, TIN (볼록껍질 외부) 최근접 정점으로 메운다.
**`focus_xy` 노선 [확인] 3 반이던 원인이 여기였다**(2026-09-07 실측).
`LinearNDInterpolator` 들로네 삼각망을 ** 호출 ** 만드는데, 등고선 구름을 통째로
(용화 317,348) 넘기면 번이 **256~1,481** 뛴다(같은 입력에도 편차가 ).
쓰는 곳은 세류 조각 점뿐이므로, 물어볼 자리 둘레만 남기면 삼각망이 작아져
**1 아래** 된다. 남기는 여유(`margin_m`) 등고선 재샘플 간격(5m) 스무 배라
물어볼 점은 언제나 볼록껍질 한참 안쪽에 있고 보간값도 그대로다.
"""
def __init__(self, cloud: ContourCloud) -> None:
def __init__(
self,
cloud: ContourCloud,
focus_xy: np.ndarray | None = None,
margin_m: float = 100.0,
) -> None:
self._z = cloud.z
if cloud.is_empty:
self._interpolator = None
self._tree = None
return
self._interpolator = LinearNDInterpolator(cloud.xy, cloud.z)
self._tree = cKDTree(cloud.xy)
xy = np.asarray(cloud.xy, dtype=np.float64)
z = np.asarray(cloud.z, dtype=np.float64)
if focus_xy is not None and len(focus_xy):
focus = np.asarray(focus_xy, dtype=np.float64)
near = cKDTree(focus).query(xy, workers=-1)[0] <= margin_m
if near.any():
xy, z = xy[near], z[near]
logger.info(
"배수유역: 표고 보간 구름을 물어볼 자리 %.0fm 안으로 줄임 — %d%d",
margin_m,
len(cloud.z),
len(z),
)
self._z = z
self._interpolator = LinearNDInterpolator(xy, z)
self._tree = cKDTree(xy)
def at(self, xy: np.ndarray) -> np.ndarray:
"""(N, 2) 좌표의 표고 (N,)."""
@@ -230,6 +267,15 @@ class ElevationSampler:
if missing.any():
_, indices = self._tree.query(xy[missing])
values[missing] = self._z[indices]
# 볼록껍질 밖으로 빠진 점은 최근접 정점 값이라 **등고선 간격(5m)만큼 틀릴 수**
# 있고, 그러면 그 조각의 상·하류 판정이 갈린다. 구름을 좁힌 뒤로는 「둘레
# 100m 안에 등고선이 없는 자리」가 그 원인이 되므로 조용히 넘기지 않고 남긴다
# (2026-09-07 보조 창 제안 — 다른 현장에서 갈리는 것을 잡는 값싼 그물).
logger.warning(
"배수유역: 표고를 TIN 밖에서 읽음 — %d/%d점은 최근접 등고선 정점 값",
int(missing.sum()),
int(missing.size),
)
return values
+20 -1
View File
@@ -6,7 +6,7 @@ terrain_layers(지형 레이어) 테이블에 메타데이터와 상대 경로
"""
import json
from pathlib import PurePosixPath
from pathlib import Path, PurePosixPath
from typing import Any
from uuid import UUID
@@ -230,6 +230,25 @@ async def list_project_point_cloud_inputs(
]
async def list_project_point_cloud_paths(
connection: aiomysql.Connection, project_id: UUID, project_root: Path
) -> list[Path]:
"""전처리가 병합할 지형 파일 경로 목록 — 실제로 있는 파일만 (2026-09-06 다중 입력).
교체된 (`SUPERSEDED`) 조회에서 이미 빠지므로, 지금 살아 있는 파일만 남는다.
"""
rows = await list_project_point_cloud_inputs(connection, project_id)
paths: list[Path] = []
for row in rows:
raw = str(row.get("raw_file_path") or "")
if not raw:
continue
path = project_root / Path(raw)
if path.is_file() and path not in paths:
paths.append(path)
return paths
async def list_surface_models(
connection: aiomysql.Connection, project_id: UUID
) -> list[dict[str, Any]]:
+6 -1
View File
@@ -26,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
list_project_point_cloud_inputs,
list_project_point_cloud_paths,
list_surface_models,
save_surface_analysis_to_db,
)
@@ -117,6 +118,10 @@ async def analyze_surface(
status_code=404,
content={"status": "error", "message": "원본 LAS 파일을 찾을 수 없습니다."},
)
# 지형 파일이 여러 장이면 합쳐서 다시 만든다 — 자동 전처리와 같은 대상을 쓴다.
terrain_paths = await list_project_point_cloud_paths(
connection, project_id, project_root
)
# 분석 시작 진행률 기록 (별도 스레드의 콜백은 파일에만 원자적 기록).
write_surface_progress(project_root, 5, "analyzing", "WF1 분석을 시작합니다.")
@@ -128,7 +133,7 @@ async def analyze_surface(
result = await asyncio.to_thread(
run_surface_analysis,
project_root,
las_path,
terrain_paths or [las_path],
source_filters=source_filters,
methods=methods,
force=request.force,
+18 -1
View File
@@ -37,6 +37,7 @@ from common_util.common_util_drainage_pipes import (
save_detail_basins,
save_pipe_points,
)
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_route_geometry import StructureCandidate
from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M
@@ -122,7 +123,23 @@ def _payload(
points: list[PipePoint],
saved: bool,
) -> dict[str, Any]:
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다."""
"""관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.
좌표는 위경도 7자리( 1.1cm) 맞춰 내보낸다 저장된 값에는 없는 자리가 붙어 있어
응답이 151KB 100KB(압축까지 얹으면 22KB) 된다(2026-09-06 실측). 화면이 그리는 값이고
다시 계산에 넣지 않으므로 줄여도 결과가 갈린다.
"""
return round_floats(_raw_payload(project_id, context, detail, points, saved), LONLAT_DIGITS)
def _raw_payload(
project_id: UUID,
context: DrainageContext,
detail: DrainageDetail,
points: list[PipePoint],
saved: bool,
) -> dict[str, Any]:
"""자릿수를 줄이기 전의 응답 본문."""
to_lonlat = context.to_lonlat
return {
"status": "success",
+41 -1
View File
@@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_json import LONLAT_DIGITS, round_floats
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -150,7 +151,13 @@ async def get_project_geojson(
if layer in _SHEET_GEOJSON_FILES:
# 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시).
# 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다.
return cached_file_response(request, filepath, "application/geo+json")
# 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은
# 그대로고 뜻 없는 자리만 사라진다.
return cached_file_response(
request,
await asyncio.to_thread(_display_copy, filepath),
"application/geo+json",
)
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
@@ -349,3 +356,36 @@ async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, A
await connection.rollback()
raise
return {"status": "success", "method": method, "surface_model_ids": model_ids}
def _display_copy(source: Path) -> Path:
"""도엽 GeoJSON 의 **표시용 사본**(좌표 자릿수만 줄인 것) 경로를 돌려준다.
저장된 파일에는 `1.4000000000000001` 꼴로 없는 자리가 붙어 있다 위경도 7자리
(1.1cm) 맞추면 형상은 그대로면서 등고선 도엽이 65.9MB 36.8MB 된다(2026-09-06 실측).
압축까지 얹으면 11.3MB.
사본은 ** 번만** 만든다(66MB 기준 3). 원본이 새로 깔리면 수정시각이 앞서므로
저절로 다시 만든다. 만들다 실패하면 원본을 그대로 보낸다 화면을 막지 않는다.
"""
target = source.with_suffix(".display.geojson")
try:
if target.exists() and target.stat().st_mtime >= source.stat().st_mtime:
return target
payload = json.loads(source.read_text(encoding="utf-8"))
target.write_text(
json.dumps(
round_floats(payload, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":")
),
encoding="utf-8",
)
logger.info(
"도엽 표시용 사본 생성: %s (%.1fMB → %.1fMB)",
source.name,
source.stat().st_size / 1e6,
target.stat().st_size / 1e6,
)
return target
except Exception as exc: # 사본 실패가 화면을 막으면 안 된다.
logger.warning("도엽 표시용 사본을 만들지 못해 원본을 보냅니다 (%s): %s", source.name, exc)
return source
@@ -11,6 +11,7 @@ import asyncio
import json
import logging
import math
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -58,7 +59,7 @@ from common_util.common_util_wamis_station import (
build_station_rainfall_table,
is_jeju,
)
from config.config_db import get_db_pool
from config.config_db import get_db_pool, run_with_connection
from config.config_system import (
DRAINAGE_ARROW_SPACING_M,
DRAINAGE_DESIGN_RETURN_PERIOD_YR,
@@ -172,11 +173,13 @@ async def _prepare(project_id: UUID) -> dict[str, Any] | JSONResponse:
잘라 프로젝트 좌표계로 돌려준다. 원본을 그대로 쓰면 유역·관이 확정 노선 밖에도
찍히고 좌표계마저 갈린다(2026-09-01 실측: 관은 5179, 노선은 5176이었다).
"""
pool = get_db_pool()
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
epsg = await get_surface_crs_epsg(connection, project_id, 0)
surface_params = await get_surface_confirmation_params(connection, str(project_id))
# 셋은 서로 기다릴 이유가 없다 — DB 가 원격이라 순차로 내면 왕복 12ms 가 세 번 붙는다
# (2026-09-06 실측). 커넥션을 갈라 같이 보낸다.
stored_path, epsg, surface_params = await asyncio.gather(
run_with_connection(get_project_storage_relative_path, project_id),
run_with_connection(get_surface_crs_epsg, project_id, 0),
run_with_connection(get_surface_confirmation_params, str(project_id)),
)
project_root = Path(resolve_stored_project_path(stored_path))
route_file = find_planned_route_file(_route_input_dir(stored_path))
@@ -387,9 +390,13 @@ async def get_primary_region(
logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path)
return {**saved, "from_cache": True}
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
marks.append(("_prepare(도엽 읽기·좌표변환)", time.perf_counter()))
preview = await asyncio.to_thread(
preview_stages,
prepared["vertices"],
@@ -401,6 +408,7 @@ async def get_primary_region(
status_code=400,
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
)
marks.append(("preview_stages(유역 산정)", time.perf_counter()))
region = preview.region
to_lonlat = prepared["to_lonlat"]
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
@@ -514,5 +522,7 @@ async def get_primary_region(
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
_write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat)
# 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다.
marks.append(("응답 만들기·저장", time.perf_counter()))
_log_steps("배수유역 분석 내부", marks)
_save_response(prepared["stored_path"], payload)
return payload
+24 -26
View File
@@ -11,6 +11,8 @@
* - {status:"error", message:"..."} Error로 .
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */
@@ -264,6 +266,19 @@ export async function confirmRoute(
});
}
/** ( ) B06 []·[].
* 3D B05 B06 (B05·B06 ).
* B06
* (2026-09-06 ). . */
export async function flushUphillOverrides(projectId: string): Promise<void> {
const stored = readState<Record<string, "left" | "right">>("uphill", projectId);
const overrides = Object.entries(stored ?? {})
.filter(([, side]) => side === "left" || side === "right")
.map(([chainage, side]) => ({ chainage_m: Number(chainage), side }));
if (!overrides.length) return;
await confirmRoute(projectId, { uphill_overrides: overrides }, false);
}
/** [초기화] 응답 — 초기 자동 계산 상태로 재구성된 경로. */
export interface RouteResetResponse {
status: string;
@@ -292,39 +307,22 @@ export async function fetchLatestRoute(projectId: string): Promise<RouteLatestRe
});
}
/** B05가 최신 경로·확정 설정값을 탭 세션에 담아 둘 때 쓰는 키(유일한 정의처). */
export const routeLatestCacheKey = (projectId: string): string => `b05:latest:${projectId}`;
/* · (`b_page_state`) .
B04에서 B05가 ,
`clearRouteLatestCache` . */
/** . B04
* B05가 , . */
/** 세션 캐시에서 최신 경로 응답을 읽는다. 없거나 깨졌으면 null(다음 진입은 DB 조회). */
export function readRouteLatestCache(projectId: string): RouteLatestResponse | null {
try {
const raw = window.sessionStorage.getItem(routeLatestCacheKey(projectId));
return raw ? (JSON.parse(raw) as RouteLatestResponse) : null;
} catch {
return null;
}
return readState<RouteLatestResponse>("latest", projectId);
}
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패하면 캐시를 비운다. */
/** 최신 경로 응답을 세션 캐시에 넣는다. 용량 초과 등으로 실패해도 화면은 그대로 돈다. */
export function writeRouteLatestCache(projectId: string, value: RouteLatestResponse): void {
const key = routeLatestCacheKey(projectId);
try {
window.sessionStorage.setItem(key, JSON.stringify(value));
} catch {
try {
window.sessionStorage.removeItem(key);
} catch {
/* noop */
}
}
writeState("latest", value, projectId);
}
export function clearRouteLatestCache(projectId: string): void {
try {
window.sessionStorage.removeItem(routeLatestCacheKey(projectId));
} catch {
/* 세션 접근 실패 시에는 다음 진입에서 DB를 읽게 되므로 그대로 둔다. */
}
clearState("latest", projectId);
// 설정값에 노선 id 가 실려 있어 함께 버려야 옛 노선을 가리키지 않는다(2026-09-06).
clearState("section-context", projectId);
}
+120
View File
@@ -0,0 +1,120 @@
/* =============================================================================
* B05_Profile_Api_HaulPlan.ts
* ****(··) .
*
* (2026-09-06 )
* . (`common_util_mass_haul`),
* . TS Node .
*
* ** ** ,
* . ( ).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
/** ** ** .
* `import type` ( ). */
export type HaulPlanPayload = HaulPlan | null;
/** 편집이 멈춘 것으로 볼 시간(ms). 계획고를 연속으로 누르는 동안은 안 보낸다. */
const SETTLE_MS = 400;
/** 배분 계산 대기 상한 — Node 실행 200ms 대라 넉넉히 잡는다. */
const TIMEOUT_MS = 20000;
export interface HaulPlanPrefetch {
/** 새 누가토량 결과가 나왔음을 알린다 — 잠잠해지면 서버에 물어본다. */
schedule: (result: unknown) => void;
/** 지금 쥐고 있는 배분. 아직 못 받았으면 null. */
current: () => HaulPlanPayload;
/** 화면을 떠날 때 예약을 지운다. */
dispose: () => void;
}
/**
* . `onReady`
* .
*/
export function createHaulPlanPrefetch(
projectId: string,
routeId: () => number | undefined,
onReady: () => void,
): HaulPlanPrefetch {
let timer = 0;
let sequence = 0;
let plan: HaulPlanPayload = null;
let pending: unknown = null;
/** 마지막으로 보낸 입력의 표식 — 같은 값이면 다시 묻지 않는다. */
let sentKey: string | null = null;
/** 받은 값을 화면에 반영하려고 다시 그리는 중 — 그 그리기가 거는 요청은 무시한다. */
let applying = false;
/**
* . ,
* `schedule` ** **(2026-09-06 ).
* .
*/
function keyOf(result: unknown): string {
const points = (result as { points?: Array<{ cumulative_volume_m3?: number }> })?.points;
if (!Array.isArray(points) || !points.length) return "empty";
const last = points[points.length - 1]?.cumulative_volume_m3 ?? 0;
const mid = points[Math.floor(points.length / 2)]?.cumulative_volume_m3 ?? 0;
return `${points.length}:${last.toFixed(3)}:${mid.toFixed(3)}`;
}
async function send(result: unknown, seq: number): Promise<void> {
const route = routeId();
if (!route) return;
const controller = new AbortController();
const abort = window.setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const response = await fetch(
`${API_BASE_URL}/projects/${projectId}/sections/${route}/haul-plan`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ result }),
signal: controller.signal,
},
);
if (!response.ok) return;
const payload = (await response.json()) as { haul_plan?: HaulPlanPayload };
// 늦게 온 응답은 버린다 — 그 사이 사용자가 계획고를 더 만졌을 수 있다.
if (seq !== sequence) return;
plan = payload.haul_plan ?? null;
// 다시 그리기가 `schedule` 을 되부르므로 그 한 바퀴를 막는다. 표식(`sentKey`)만으로는
// 부족했다 — 반영 뒤 누계가 아주 조금 달라지는 경로가 있어 2~3바퀴가 더 돌았다
// (2026-09-06 보조 창 실측: 편집이 멈춘 뒤 2.8~6.3초에 걸쳐 3건).
applying = true;
try {
onReady();
} finally {
applying = false;
}
} catch {
// 배분을 못 받아도 곡선 자체는 그대로 보인다 — 화면을 막지 않는다.
} finally {
window.clearTimeout(abort);
}
}
return {
schedule(result) {
if (applying) return; // 응답을 반영하려고 다시 그리는 중이다.
const key = keyOf(result);
if (key === sentKey) return; // 같은 값 — 응답이 부른 재그리기가 되돌아온 것이다.
sentKey = key;
pending = result;
sequence += 1;
const seq = sequence;
window.clearTimeout(timer);
timer = window.setTimeout(() => void send(pending, seq), SETTLE_MS);
},
current: () => plan,
dispose() {
window.clearTimeout(timer);
sequence += 1; // 남아 있는 응답을 모두 무효로 만든다.
},
};
}
@@ -0,0 +1,33 @@
/* =============================================================================
* B05_Profile_Api_Pipes_Draft.ts
* B05 ** **() []·[] .
*
* (2026-09-06 ) ·· , B06
* [] (`savePipes()` B05 []
* ). .
*
* (CLAUDE.md 5).
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { saveDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { DetailPipeInput } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
/** 세션에 담는 관 한 벌 — 정본 PUT 이 그대로 받는 꼴이다. */
export type PendingPipes = DetailPipeInput[];
export function readPendingPipes(projectId: string): PendingPipes | null {
return readState<PendingPipes>("pipes", projectId);
}
export function writePendingPipes(projectId: string, next: PendingPipes | null): void {
writeState("pipes", next, projectId);
}
/** 초안이 있으면 정본에 쓰고 비운다. 없으면 아무 일도 하지 않는다. */
export async function flushPendingPipes(projectId: string): Promise<void> {
const pending = readPendingPipes(projectId);
if (!pending) return;
await saveDetailPipePoints(projectId, pending);
writePendingPipes(projectId, null);
}
+138
View File
@@ -0,0 +1,138 @@
/* =============================================================================
* B05_Profile_Api_Replan.ts
* (·) .
*
* GET /projects/{id}/route/plan · ( m)
* POST /projects/{id}/route/replan
* POST /projects/{id}/route/replan/reset
*
* ( 67 3)
* .
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 노선 재계산 대기 상한 — 배수유역 분석(90초대)까지 포함해 넉넉히 잡는다. */
const REPLAN_TIMEOUT_MS = 15 * 60 * 1000;
/** 사용자가 잡아 옮기는 제어점 하나 — 서버가 이 노드로 폴리라인을 만든다. */
export interface RoutePlanNode {
x: number;
y: number;
/** 직전·직후 구간이 이루는 내각(도). 끝점은 null. */
inner_angle_deg: number | null;
/** 이 자리에 끼운 원호 반지름(m). 곡선을 지운 자리는 null. */
radius_m: number | null;
tangent_m: number | null;
/** 법정 기준 위반 표시 — 값은 내되 막지 않는다. */
violations: string[];
}
/** 직선 사이에 놓인 **곡선 성분 하나** — 화면이 손잡이와 R 칸을 그리는 재료. */
export interface RoutePlanCurve {
/** 앞뒤 직선을 늘려 만나는 자리(교각점). **반지름을 바꿔도 여기는 안 움직인다.** */
apex: [number, number];
radius_m: number;
tangent_m: number;
inner_angle_deg: number;
/** 곡선 시작점 — 직선이 곡선에 닿는 자리. 사용자가 잡는 손잡이다. */
start: [number, number];
/** 곡선 끝점. */
end: [number, number];
/** 이 곡선이 대신하는 꺾임점 구간(첫·끝) — 편집이 어느 노드를 건드리는지 알려 준다. */
node_first: number;
node_last: number;
violations: string[];
}
export interface RoutePlanResponse {
status: string;
project_id: string;
/** 예상노선(원본) **점 묶음** [[x, y], …] — 사업지 좌표계(m). 폴리라인이 아니다. */
expected: Array<[number, number]>;
/** 계획노선 폴리라인(원호 포함) — 그려 보이는 선. 잡는 대상이 아니다. */
planned: Array<[number, number]>;
/** 잡아 옮기는 노드(꺾임점). 편집은 이것으로 한다(2026-09-06 사용자 지시). */
nodes: RoutePlanNode[];
/** 직선·곡선 성분 — 곡선 시작·끝점과 반지름. 화면이 이것으로 손잡이를 그린다. */
curves: RoutePlanCurve[];
/** 이 프로젝트에 적용한 법정 최소곡선반지름(m). */
min_radius_m: number;
curve_count: number;
violation_count: number;
/** 사용자가 고친 계획노선이 저장돼 있으면 true. */
edited: boolean;
}
export interface RouteReplanResponse {
status: string;
project_id: string;
route_id: number | null;
total_length_m: number | null;
vertex_count: number;
}
async function requestJson<T>(path: string, init: RequestInit, timeoutMs: number): Promise<T> {
const controller = new AbortController();
const timer = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
credentials: "include",
headers: { "Content-Type": "application/json", ...(init.headers ?? {}) },
signal: controller.signal,
});
const payload = (await response.json()) as T & { message?: string };
if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`);
return payload;
} finally {
window.clearTimeout(timer);
}
}
/** 예상노선·계획노선을 함께 읽는다(편집 모달이 점선·실선으로 그린다). */
export async function fetchRoutePlan(projectId: string): Promise<RoutePlanResponse> {
return requestJson<RoutePlanResponse>(
`/projects/${projectId}/route/plan`,
{ method: "GET" },
60000,
);
}
/** 꺾임점 하나에 실어 보내는 편집값 — 곡선을 둘지, 반지름을 못박을지. */
export interface RouteReplanVertex {
x: number;
y: number;
/** 이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다(곡선 삭제). */
curve?: boolean;
/** 못박을 반지름(m). 없으면 서버가 고른다. */
radius_m?: number | null;
}
/** .
*
* (2026-09-07 )
* ** ·** , ** ·** `curve` ,
* ** ** `radius_m` . */
export async function replanRoute(
projectId: string,
vertices: Array<[number, number]> | RouteReplanVertex[],
): Promise<RouteReplanResponse> {
const payload = (vertices as Array<[number, number] | RouteReplanVertex>).map((vertex) =>
Array.isArray(vertex) ? { x: vertex[0], y: vertex[1] } : vertex,
);
return requestJson<RouteReplanResponse>(
`/projects/${projectId}/route/replan`,
{ method: "POST", body: JSON.stringify({ vertices: payload }) },
REPLAN_TIMEOUT_MS,
);
}
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
return requestJson<RouteReplanResponse>(
`/projects/${projectId}/route/replan/reset`,
{ method: "POST" },
REPLAN_TIMEOUT_MS,
);
}
+48 -19
View File
@@ -11,6 +11,7 @@
* , .
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 배치형태 — 점형(측점 1개) / 구간형(시~종점) / 부지형(위치+면적). */
@@ -25,6 +26,8 @@ export interface StructureOptionField {
default: string | number | null;
/** 미확정 항목(기본값 없음) — 사용자가 값을 넣어야 저장된다. */
required?: boolean;
/** 거짓이면 폼에 **회색으로** 그려지고 못 고른다 — 칸은 남기되 잠그는 자리. */
enabled?: boolean;
/** B05 ·· (detail) B06/B07
* (2026-08-17 ). detail이면 required여도 B05 . */
phase?: "b05" | "detail";
@@ -45,6 +48,9 @@ export interface StructureType {
drawing_views: string[];
/** 다른 정본이 관리하는 타입(배관 = pipe_points.json) — 구조물 목록에 넣지 않는다. */
managed_by: string | null;
/** **· ** .
* () = `"횡단 설계"`. ~ . */
design_owner: string | null;
reference_only: boolean;
enabled: boolean;
}
@@ -128,18 +134,28 @@ async function requestJson<T>(path: string, init: RequestInit = {}): Promise<T>
/** 타입 레지스트리는 서버 배포 중에 바뀌지 않으므로 탭 수명 동안 한 번만 받는다. */
let typesCache: Promise<StructureType[]> | null = null;
export function fetchStructureTypes(): Promise<StructureType[]> {
/**
* .
*
* `includeDisabled` `enabled:false` (B군 ·F군 /·G군 )
* . (2026-08-17) B05 **B06
* ** 2026-09-07 A군뿐
* B06 . B05 .
*/
export function fetchStructureTypes(includeDisabled = false): Promise<StructureType[]> {
if (!typesCache) {
typesCache = requestJson<StructureTypesResponse>("/projects/structure-types", {
method: "GET",
})
.then((payload) => payload.types.filter((type) => type.enabled))
.then((payload) => payload.types)
.catch((error) => {
typesCache = null; // 실패한 약속을 남겨 두면 다시 시도할 수 없다.
throw error;
});
}
return typesCache;
return typesCache.then((types) =>
includeDisabled ? types : types.filter((type) => type.enabled),
);
}
export async function fetchStructures(projectId: string): Promise<StructureListResponse> {
@@ -173,6 +189,32 @@ export async function migrateLegacyStations(
/** = . chainage_m (2026-08-17
* 확정: 기준점에 + · ). . */
/** ** ** `pipe_points.json`
* . B05 B06
* (2026-09-07 4 ). */
export function pipesToStructureMarks(
pipes: ReadonlyArray<{
chainage_m: number;
facility: string;
options?: Record<string, unknown> | null;
}>,
): StructureInstance[] {
return pipes.map((pipe) => ({
structure_id: `pipe-${pipe.chainage_m.toFixed(2)}`,
type_id: pipe.facility,
placement: "point",
chainage_m: pipe.chainage_m,
start_m: null,
end_m: null,
options: (pipe.options ?? {}) as Record<string, string | number>,
memo: "",
placement_source: "automatic",
status: "draft",
revision: 0,
geometry: null,
})) as StructureInstance[];
}
export function structureAnchorM(structure: StructureInstance): number {
return structure.chainage_m ?? structure.start_m ?? 0;
}
@@ -193,26 +235,13 @@ export function defaultOptions(type: StructureType): Record<string, string | num
* 2026-08-29 ). B05에서 B06으로
* ·· . */
const pendingStructuresKey = (projectId: string): string => `b05:structures:${projectId}`;
/** 미저장 조작분. 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
/** 미저장 조작분(② 설계 초안). 없으면 null(= 만진 적 없음, 빈 목록과 구분된다). */
export function readPendingStructures(projectId: string): StructureInstance[] | null {
try {
const raw = window.sessionStorage.getItem(pendingStructuresKey(projectId));
return raw ? (JSON.parse(raw) as StructureInstance[]) : null;
} catch {
return null;
}
return readState<StructureInstance[]>("structures", projectId);
}
export function writePendingStructures(projectId: string, next: StructureInstance[] | null): void {
try {
const key = pendingStructuresKey(projectId);
if (next) window.sessionStorage.setItem(key, JSON.stringify(next));
else window.sessionStorage.removeItem(key);
} catch {
/* 세션 저장 실패는 무시 — 값은 화면에 남아 있다. */
}
writeState("structures", next, projectId);
}
/**
+3 -68
View File
@@ -16,8 +16,6 @@ from __future__ import annotations
import asyncio
import json
import logging
import os
import subprocess
import tempfile
from pathlib import Path
from typing import Any
@@ -25,76 +23,13 @@ from uuid import UUID
from B05_Profile.B05_Profile_Repository import get_route_points
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
from common_util.common_util_node_bundle import build_bundle, bundle_stale, run_node
from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
ROOT = Path(__file__).resolve().parents[1]
BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js"
# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더.
_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util")
# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다.
_BUILD_TIMEOUT_S = 300
_RUN_TIMEOUT_S = 600
def _node_env() -> dict[str, str]:
"""config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약)."""
env = os.environ.copy()
node_modules = ROOT / "config" / "node_modules"
env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}"
env["NODE_PATH"] = str(node_modules)
return env
def _bundle_stale() -> bool:
"""번들이 없거나 TS 원본보다 오래됐으면 참.
번들이 낡으면 서버와 화면이 **다른 기하** 만든다 판정이 그것을 막는 유일한
장치다. 개발 중에는 `npm run build` 따로 돌리지 않으므로 여기서 스스로 갱신한다.
"""
if not BUNDLE.is_file():
return True
built_at = BUNDLE.stat().st_mtime
for directory in _SOURCE_DIRS:
for path in (ROOT / directory).rglob("*.ts"):
if path.stat().st_mtime > built_at:
return True
return False
def _build_bundle() -> bool:
result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음
"npm run build:corridor",
shell=True,
cwd=str(ROOT),
env=_node_env(),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_BUILD_TIMEOUT_S,
)
if result.returncode != 0:
logger.error("코리도 번들 빌드 실패:\n%s", result.stderr)
return False
return True
def _run_node(input_path: Path, output_path: Path) -> int:
result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐
["node", str(BUNDLE), str(input_path), str(output_path)],
cwd=str(ROOT),
env=_node_env(),
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=_RUN_TIMEOUT_S,
)
if result.returncode != 0:
logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr)
return result.returncode
async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None:
@@ -121,7 +56,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root:
logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id)
return False
if _bundle_stale() and not await asyncio.to_thread(_build_bundle):
if bundle_stale(BUNDLE) and not await asyncio.to_thread(build_bundle, "build:corridor"):
return False
target = corridor_path(Path(project_root), route_id)
@@ -133,7 +68,7 @@ async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root:
json.dumps({"detail": detail, "route_points": points}, default=float),
"utf-8",
)
if await asyncio.to_thread(_run_node, source, built) != 0:
if await asyncio.to_thread(run_node, BUNDLE, source, built) != 0:
return False
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
await asyncio.to_thread(_replace, built, target)
+14 -1
View File
@@ -106,7 +106,20 @@ def run_route_design(
},
options=options,
)
if algorithm == "ridge_valley":
if algorithm == "as_planned":
# 사용자가 고친 계획노선을 **그대로** 쓴다 — 다시 풀지 않는다(PLAN 0-3 자동탐색 접기).
# 제어점 목록(bp·cp·ep)이 곧 노선이며, 표고만 지표면에서 뜬다.
from B05_Profile.B05_Profile_Engine_AsPlanned import solve_as_planned
sequence = [points_data.get("bp")] + list(points_data.get("cp") or [])
sequence.append(points_data.get("ep"))
vertices = [
(float(point["x"]), float(point["y"])) for point in sequence if isinstance(point, dict)
]
result = solve_as_planned(
project_root, filter_key, smooth, vertices, options, method=method
)
elif algorithm == "ridge_valley":
result = solve_ridge_valley_route(
project_root, filter_key, smooth, points_data, options, method=method
)
+169
View File
@@ -0,0 +1,169 @@
"""사용자가 고친 계획노선을 **그대로 쓰는** 길 — 다시 풀지 않는다.
필요한가(2026-09-06 실측) 노선 편집 [확인] 재확정 체인을 타는데, 체인이
`solve_route` **BP·CP·EP 제어점 사이를 격자에서 다시 풀었다**. 사용자가 노드를 조금만
비틀어도 탐색 제약에 걸려 체인이 통째로 멈췄다:
세그먼트 1 (BP CP1) 경로 탐색 실패: 종단경사 한계(26%)·최소곡선반지름(12m)·
회피지역 제약으로 통과 경로가 없습니다.
결과 배수유역·관은 노선으로 가고 종횡단만 노선에 남아 배수관 측점이 9 0 됐다.
PLAN 0-3 에서 **노선 자동탐색은 접기로** 했고(사용자 확정), 계획노선은 사용자가 직접 고친다.
여기서는 노선을 **그대로 받아** 표고만 지표면에서 떠서 결과 꼴을 맞춘다.
**위반은 세되 막지 않는다** 종단기울기·곡선반지름을 재어 개수로 알리되 경로를 바꾸지
않는다(사용자 확정: 자동 보정·차단 없이 경고만). 막는 순간 내가 그린 선이 들어간다 된다.
솔버(`B05_Profile_Engine_Solver`) 지표 계산이 겹치지만 **합치지 않았다** 솔버는 0-3 으로
접히는 코드라 그쪽을 리팩터링해 곳을 얽을 값어치가 없다.
"""
from __future__ import annotations
import logging
import math
from pathlib import Path
from typing import Any
import numpy as np
logger = logging.getLogger(__name__)
_MODELS_SUBDIR = "B04_PreProcess/models"
def _elevation_lookup(
project_root: Path, filter_key: str, method: str, smooth: bool
) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""지표면 격자(x, y, z) — 노선 정점의 표고를 뜨는 데 쓴다."""
from B05_Profile.B05_Profile_Engine_Solver import _load_dtm_grid, _sample_surface_on_grid
models_dir = Path(project_root) / _MODELS_SUBDIR
x_coords, y_coords, dtm_z, _valid = _load_dtm_grid(models_dir, filter_key, smooth)
surface_z = _sample_surface_on_grid(
models_dir, filter_key, method, smooth, x_coords, y_coords, dtm_z
)
return x_coords, y_coords, surface_z
def _z_at(
x_coords: np.ndarray, y_coords: np.ndarray, z_grid: np.ndarray, x: float, y: float
) -> float:
"""격자에서 가장 가까운 칸의 표고. 격자 밖이면 가장자리 값."""
col = int(np.clip(np.searchsorted(x_coords, x), 0, len(x_coords) - 1))
row = int(np.clip(np.searchsorted(y_coords, y), 0, len(y_coords) - 1))
value = float(z_grid[row, col])
return value if math.isfinite(value) else 0.0
def solve_as_planned(
project_root: Path,
filter_key: str,
smooth: bool,
vertices: list[tuple[float, float]],
options: dict[str, Any],
method: str = "dtm",
) -> dict[str, Any]:
"""계획노선 정점을 그대로 노선으로 삼는다. 반환 꼴은 솔버와 같다."""
if len(vertices) < 2:
raise ValueError("계획노선 정점이 2개 미만입니다.")
x_coords, y_coords, z_grid = _elevation_lookup(project_root, filter_key, method, smooth)
polyline = [[float(x), float(y), _z_at(x_coords, y_coords, z_grid, x, y)] for x, y in vertices]
max_uphill = float(options.get("max_uphill_grade") or 0.26)
max_downhill = float(options.get("max_downhill_grade") or 0.26)
min_radius = float(options.get("min_curve_radius_m") or 12.0)
count = len(polyline)
chainage_m = [0.0] * count
length_m = 0.0
grade_sums = 0.0
max_grade = 0.0
max_up = 0.0
max_down = 0.0
slope_violations = 0
for index in range(count - 1):
x1, y1, z1 = polyline[index]
x2, y2, z2 = polyline[index + 1]
horizontal = math.hypot(x2 - x1, y2 - y1)
chainage_m[index + 1] = chainage_m[index] + horizontal
if horizontal <= 0.01:
continue
dz = z2 - z1
slope = abs(dz) / horizontal
length_m += horizontal
grade_sums += slope * horizontal
max_grade = max(max_grade, slope)
if dz > 0:
max_up = max(max_up, slope)
else:
max_down = max(max_down, slope)
if slope > (max_uphill if dz > 0 else max_downhill):
slope_violations += 1
avg_grade = (grade_sums / length_m) if length_m > 0 else 0.0
# 곡선반지름 — 세 점을 지나는 원으로 재고, 하한을 밑도는 자리를 **세기만** 한다.
from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d
curve_violations = 0
min_radius_actual = float("inf")
for index in range(1, count - 1):
radius = circumradius_2d(polyline[index - 1], polyline[index], polyline[index + 1])
min_radius_actual = min(min_radius_actual, radius)
if radius < min_radius:
curve_violations += 1
total_length = chainage_m[-1] if chainage_m else 0.0
segments = [
{
"index": 0,
"from": "BP",
"to": "EP",
"point_start": 0,
"point_end": count - 1,
"chainage_start_m": 0.0,
"chainage_end_m": round(total_length, 2),
"length_m": round(total_length, 2),
"max_grade_pct": round(max_grade * 100, 2),
}
]
logger.info(
"계획노선 그대로 사용: 정점 %d · 연장 %.1fm · 경사위반 %d · 곡선위반 %d",
count,
total_length,
slope_violations,
curve_violations,
)
return {
"polyline": polyline,
"chainage_m": [round(value, 3) for value in chainage_m],
"segments": segments,
# 사용자가 그린 선이므로 제어점 도달 검사는 뜻이 없다 — 통과로 둔다.
"required_point_checks": [],
"required_points_ok": True,
"avoid_intrusions": [],
"forbidden_intrusions": [],
"curve_warning_segments": [],
"avoid_retry_performed": False,
"conditions_snapshot": {
"filter": filter_key,
"method": method,
"smooth": smooth,
"source": "as_planned",
"min_curve_radius_m": round(min_radius, 2),
},
"metrics": {
"length_m": round(length_m, 2),
"avg_grade_pct": round(avg_grade * 100, 2),
"max_grade_pct": round(max_grade * 100, 2),
"max_uphill_pct": round(max_up * 100, 2),
"max_downhill_pct": round(max_down * 100, 2),
"slope_violations": slope_violations,
"search_max_grade_pct": round(max(max_uphill, max_downhill) * 100, 2),
"curve_violations": curve_violations,
"min_curve_radius_m": round(min_radius_actual, 2)
if math.isfinite(min_radius_actual)
else None,
"min_curve_radius_limit_m": round(min_radius, 2),
},
}
+12
View File
@@ -139,6 +139,18 @@ def legal_grade_limit_pct(
return legal_max
def legal_plan_radius_min_m(design_speed_kph: int, terrain_type: str = "normal") -> float:
"""설계속도 × 지형 구분에 따른 법정 **평면** 최소곡선반지름(m, 별표2 .2.다.(1)).
경로탐색 제약이 아니라 **위반 표시** 기준이다(2026-09-06 사용자 확정). 설계속도는
이미 확정된 값을 받는다(계획선 옵션이 `resolve_design_speed` 눌러 ).
"""
table = FOREST_ROAD_PROFILE_CRITERIA["min_plan_radius_m"]
terrain = terrain_type if terrain_type in GRADE_TERRAIN_TYPES else "normal"
speeds = table.get(int(design_speed_kph)) or table[20]
return float(speeds[terrain])
def _pick(*candidates: Any) -> Any:
"""요청 → DB 저장값 → config 순으로 처음 나오는 유효값을 고른다."""
for value in candidates:
@@ -20,7 +20,7 @@ from typing import Any
import numpy as np
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT
from config.config_system import FOREST_ROAD_PROFILE_ALIGNMENT, FOREST_ROAD_PROFILE_CRITERIA
ALIGNMENT_SCHEMA_VERSION = 1
# chainage를 dict 키로 쓸 때의 표기. 프론트엔드(`toFixed(3)`)와 반드시 같아야 한다.
@@ -51,6 +51,10 @@ class AlignmentPolicy:
max_grade_pct: float
curve_skip_delta_pct: float
paved: bool
#: 법정 평면 최소곡선반지름(m) — 위반 표시 기준(2026-09-06). 0이면 판정하지 않는다.
min_plan_radius_m: float = 0.0
#: 배향곡선 하한(m) — 이보다 급하면 경고만 낸다.
hairpin_min_radius_m: float = 0.0
@classmethod
def from_config(
@@ -60,6 +64,7 @@ class AlignmentPolicy:
max_grade_pct: float,
curve_skip_delta_pct: float,
paved: bool,
min_plan_radius_m: float = 0.0,
) -> "AlignmentPolicy":
config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls(
@@ -76,6 +81,8 @@ class AlignmentPolicy:
max_grade_pct=float(max_grade_pct),
curve_skip_delta_pct=float(curve_skip_delta_pct),
paved=bool(paved),
min_plan_radius_m=float(min_plan_radius_m),
hairpin_min_radius_m=float(FOREST_ROAD_PROFILE_CRITERIA["hairpin_min_radius_m"]),
)
@classmethod
@@ -140,6 +147,9 @@ class AlignmentPolicy:
"max_grade_pct": self.max_grade_pct,
"curve_skip_delta_pct": self.curve_skip_delta_pct,
"paved": self.paved,
# 평면 곡선 판정값 — 화면 위반 표시가 쓴다(2026-09-06).
"min_plan_radius_m": self.min_plan_radius_m,
"hairpin_min_radius_m": self.hairpin_min_radius_m,
}
@@ -30,6 +30,7 @@ from B05_Profile.B05_Profile_Engine_Grade import (
GradeDesignOptions,
detect_main_direction,
ground_profile,
legal_plan_radius_min_m,
)
from B05_Profile.B05_Profile_Engine_Grade_Alignment import (
ALIGNMENT_SCHEMA_VERSION,
@@ -179,6 +180,8 @@ def design_ground_following_profile(
max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
)
warnings = list(options.warnings)
@@ -270,6 +273,8 @@ def design_pipe_anchored_profile(
max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
)
warnings = list(options.warnings)
@@ -385,6 +390,8 @@ def design_alignment_profile(
max_grade_pct=options.max_grade_pct,
curve_skip_delta_pct=options.vertical_curve_skip_delta_pct,
paved=options.paved,
# 평면 최소곡선반지름은 화면 위반 표시에만 쓴다(2026-09-06) — 탐색 제약과 별개.
min_plan_radius_m=legal_plan_radius_min_m(options.design_speed_kph, options.terrain_type),
)
warnings = list(options.warnings)
@@ -100,6 +100,8 @@ def _cross_summary(cross_section: dict[str, Any]) -> dict[str, Any]:
"chainage_m": cross_section.get("chainage_m"),
"center_z": cross_section.get("center_z"),
"azimuth_deg": cross_section.get("azimuth_deg"),
# 평면 곡선반경 — 곡선부 확폭·최소곡선반지름 판정이 쓴다(2026-09-06).
"plan_radius_m": cross_section.get("plan_radius_m"),
"sample_count": len(samples),
"min_elevation_m": min(valid_z) if valid_z else None,
"max_elevation_m": max(valid_z) if valid_z else None,
@@ -11,13 +11,16 @@ from typing import Any
import numpy as np
from B05_Profile.B05_Profile_Engine_Geometry import circumradius_2d
from common_util.common_util_surface_sampler import SurfaceElevationSampler
from config.config_system import (
CURVE_WIDENING_TAPER_M,
SECTION_CROSS_HALF_WIDTH_M,
SECTION_CROSS_SAMPLE_INTERVAL_M,
SECTION_INCLUDE_ENDPOINT,
SECTION_LONG_SAMPLE_INTERVAL_M,
SECTION_STATION_INTERVAL_M,
curve_widening_m,
)
SECTION_SCHEMA_VERSION = 1
@@ -116,6 +119,100 @@ def _float_or_none(value: float) -> float | None:
return round(float(value), 6) if math.isfinite(float(value)) else None
#: 평면 곡선반경을 재는 앞뒤 거리(m). 측점 간격(20m)의 절반이라 한 측점의 곡률을
#: 이웃 측점에 번지지 않게 재고, 짧은 곡선도 놓치지 않는다.
PLAN_RADIUS_ARM_M = 10.0
#: 이보다 크면 직선으로 본다(m). 별표2 확폭표가 45m 이상을 "확폭 없음"으로 두므로
#: 그보다 넉넉한 값이면 판정에 영향이 없다.
PLAN_RADIUS_STRAIGHT_M = 10000.0
def _plan_radii(
points: np.ndarray,
route_chainage: np.ndarray,
station_chainage: np.ndarray,
total: float,
) -> tuple[list[float | None], list[str | None]]:
"""측점마다 (평면 곡선반경 m, 곡선 **바깥쪽**). 직선·측정 불가는 (None, None).
바깥쪽은 확폭이 붙는 쪽이다(2026-09-06 사용자 확정). 좌회전(반시계)이면 곡선 안쪽이
좌측이므로 바깥은 우측이다. offset 부호는 + 좌측이라는 횡단 규약을 따른다.
"""
arm = min(PLAN_RADIUS_ARM_M, max(total / 2.0, 0.0))
if arm < 0.5:
return [None] * len(station_chainage), [None] * len(station_chainage)
radii: list[float | None] = []
outer_sides: list[str | None] = []
for value in station_chainage:
center = float(value)
back = max(0.0, center - arm)
ahead = min(total, center + arm)
# 시·종점에서는 한쪽 팔이 짧아진다 — 양쪽이 다 확보될 때만 잰다.
if center - back < arm * 0.5 or ahead - center < arm * 0.5:
radii.append(None)
outer_sides.append(None)
continue
trio = _interpolate_xy(points, route_chainage, np.array([back, center, ahead]))
radius = circumradius_2d(trio[0], trio[1], trio[2])
if not math.isfinite(radius) or radius >= PLAN_RADIUS_STRAIGHT_M:
radii.append(None)
outer_sides.append(None)
continue
radii.append(round(radius, 3))
# 외적 z 부호로 회전 방향을 본다: 양수 = 좌회전 → 안쪽이 좌측 → 바깥은 우측.
first = trio[1] - trio[0]
second = trio[2] - trio[1]
cross = float(first[0] * second[1] - first[1] * second[0])
outer_sides.append("right" if cross > 0 else "left" if cross < 0 else None)
return radii, outer_sides
def _curve_widenings(
station_chainage: np.ndarray,
radii: list[float | None],
outer_sides: list[str | None],
) -> tuple[list[float], list[str | None]]:
"""측점별 확폭량(m)과 그것이 붙는 쪽 — 곡선 앞뒤 테이퍼까지 반영한다.
(별표2 .2..(4)) 곡선 안에서의 확폭량만 준다. 곡선 ·종점에서 폭이
끊기면 되므로, 앞뒤 `CURVE_WIDENING_TAPER_M` 구간에서 0 W 잇는다
(2026-09-06 사용자 지시). 측점 간격이 테이퍼보다 넓으면 함수가 중간값이
없고, 화면·3D 측점 사이를 이어 그리는 것으로 대신한다.
"""
base = [curve_widening_m(radius) for radius in radii]
widenings = list(base)
sides: list[str | None] = list(outer_sides)
# 확폭이 붙는 측점의 연속 덩어리(=곡선 구간)를 찾아 그 바깥으로 테이퍼를 편다.
runs: list[tuple[int, int]] = []
start: int | None = None
for index, value in enumerate(base):
if value > 0.0 and outer_sides[index] in ("left", "right"):
if start is None:
start = index
elif start is not None:
runs.append((start, index - 1))
start = None
if start is not None:
runs.append((start, len(base) - 1))
for first, last in runs:
for edge, step in ((first, -1), (last, 1)):
edge_chainage = float(station_chainage[edge])
index = edge + step
while 0 <= index < len(base):
distance = abs(float(station_chainage[index]) - edge_chainage)
if distance > CURVE_WIDENING_TAPER_M or base[index] > 0.0:
break
ratio = max(0.0, 1.0 - distance / CURVE_WIDENING_TAPER_M)
tapered = round(base[edge] * ratio, 4)
# 양쪽 곡선 사이에 낀 측점은 넓은 쪽을 따른다.
if tapered > widenings[index]:
widenings[index] = tapered
sides[index] = outer_sides[edge]
index += step
return widenings, sides
def generate_sections(
polyline: np.ndarray | list[list[float]],
sampler: SurfaceElevationSampler,
@@ -167,6 +264,14 @@ def generate_sections(
]
)
left_axes = np.column_stack([-tangents[:, 1], tangents[:, 0]])
# 측점별 **평면 곡선반경**(m). 곡선부 확폭(별표2 Ⅰ.2.나.(4))과 최소곡선반지름 위반
# 표시가 이 값을 쓴다(2026-09-06). 노선 폴리라인 위에서 앞뒤로 같은 거리를 떨어진 세
# 점의 외접원 반경이며, 직선이면 무한대라 None 으로 낸다.
plan_radii, plan_outer_sides = _plan_radii(points, route_chainage, station_chainage, total)
# 확폭량은 여기서 한 번에 낸다 — 테이퍼가 이웃 측점을 봐야 하므로 측점 단위로는 못 낸다.
plan_widenings, plan_outer_sides = _curve_widenings(
station_chainage, plan_radii, plan_outer_sides
)
offsets = np.arange(
-options.cross_half_width_m,
@@ -240,6 +345,12 @@ def generate_sections(
"center_y": round(float(station_xy[index, 1]), 6),
"center_z": _float_or_none(center_z),
"azimuth_deg": round(azimuth, 6),
"plan_radius_m": plan_radii[index],
# 곡선 바깥쪽 — 곡선부 확폭이 붙는 쪽(2026-09-06 사용자 확정).
"curve_outer_side": plan_outer_sides[index],
# 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 반경이 아니라
# 이 값을 쓴다(테이퍼 측점은 반경이 없거나 커도 확폭이 남아 있다).
"curve_widening_m": plan_widenings[index],
# 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). 사용자 변경 시 확정에서 덮어쓴다.
"uphill_side": uphill_side,
"frame": frame,
+59 -38
View File
@@ -90,13 +90,16 @@ async def sync_uphill_overrides_into_designs(
양성(both_fill) 측구가 없으므로 건드리지 않는다.
"""
# 지연 import — B06 라우터 모듈 로드는 이 함수가 실제 불릴 때만 필요하다.
from B06_Section.B06_Section_Engine_Design import compute_cross_design
from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
update_cross_section_design,
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
from B06_Section.B06_Section_Repository import get_cross_section_designs
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from B06_Section.B06_Section_Router_Design import (
ford_drop_at,
ford_surface_drops,
pavement_suggestions,
read_cross_design_inputs,
resolve_longitudinal_path,
)
from B06_Section.B06_Section_Router import _read_cross_design_inputs
from B06_Section.B06_Section_Router_Design import ford_drop_at, ford_surface_drops
if not overrides:
return
@@ -109,7 +112,8 @@ async def sync_uphill_overrides_into_designs(
options = longitudinal["data"].get("options")
if isinstance(options, dict):
stored_standard = options.get("standard_cross_section")
ford_drops = ford_surface_drops(Path(project_root))
# 다시 계산할 측점만 먼저 고른다 — 파일·계산은 아래에서 **스레드 한 번**에 몰아 한다.
jobs: list[tuple[float, dict[str, Any], str, str]] = []
for record in designs:
chainage = round(float(record["chainage_m"]), 3)
side = by_chainage.get(chainage)
@@ -120,38 +124,55 @@ async def sync_uphill_overrides_into_designs(
if mode == "both_fill":
continue
next_mode = f"{side}_cut" if mode in ("left_cut", "right_cut") else mode
next_ditch = side
if next_mode == mode and design.get("ditch_side") == next_ditch:
if next_mode == mode and design.get("ditch_side") == side:
continue
samples, design_elevation, pavement_suggested = await asyncio.to_thread(
_read_cross_design_inputs, project_root, longitudinal_file_path, float(chainage)
)
next_design = compute_cross_design(
samples,
design_elevation,
ground_type=str(design.get("ground_type", "soil")),
section_mode=str(next_mode),
ditch_side=next_ditch,
ditch_type=str(design.get("ditch_type", "standard")),
paved=bool(design.get("paved", False)),
standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
)
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
# 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다.
if design.get("display_half_width_m") is not None:
next_design["display_half_width_m"] = design["display_half_width_m"]
await update_cross_section_design(
connection,
route_id=route_id,
chainage_m=float(chainage),
design=next_design,
project_id=project_id,
)
jobs.append((chainage, design, str(next_mode), side))
if not jobs:
return
def _recompute() -> list[tuple[float, dict[str, Any]]]:
"""측점마다 종단 정본을 다시 열던 것을 한 번으로 줄인다(측점당 13.8ms 였다)."""
root = Path(project_root)
longitudinal_path = resolve_longitudinal_path(root, longitudinal_file_path)
longitudinal_json = json.loads(longitudinal_path.read_text(encoding="utf-8"))
preloaded = (longitudinal_path, longitudinal_json, pavement_suggestions(longitudinal_json))
ford_drops = ford_surface_drops(root)
out: list[tuple[float, dict[str, Any]]] = []
for chainage, design, next_mode, side in jobs:
samples, design_elevation, pavement_suggested, cross_record = read_cross_design_inputs(
root, longitudinal_file_path, float(chainage), preloaded
)
next_design = compute_cross_design(
samples,
design_elevation,
ground_type=str(design.get("ground_type", "soil")),
section_mode=next_mode,
ditch_side=side,
ditch_type=str(design.get("ditch_type", "standard")),
paved=bool(design.get("paved", False)),
standard=stored_standard,
rock_boundary_offset_m=design.get("rock_boundary_offset_m"),
two_stage_slope=bool(design.get("two_stage_slope", True)),
ditch_enabled=design.get("ditch_enabled"),
surface_drop_m=ford_drop_at(float(chainage), ford_drops),
**curve_widening_args(cross_record),
)
next_design["status"] = design.get("status", "provisional")
next_design["pavement_suggested"] = design.get("pavement_suggested", pavement_suggested)
# 개별 표시 반폭 등 계산과 무관한 표시 설정은 그대로 이월한다.
if design.get("display_half_width_m") is not None:
next_design["display_half_width_m"] = design["display_half_width_m"]
out.append((float(chainage), next_design))
return out
# 쓰기도 한 문장으로 — 행마다 내면 원격 DB 왕복이 측점 수만큼 난다(건당 24.5ms).
await merge_cross_section_designs(
connection,
route_id=route_id,
entries=await asyncio.to_thread(_recompute),
replace=True,
project_id=project_id,
)
def _merge_irregular_into_longitudinal(
+41 -3
View File
@@ -9,10 +9,11 @@
import asyncio
import json
import logging
import re
from pathlib import Path
from uuid import UUID
from fastapi import APIRouter, Body
from fastapi import APIRouter, Body, Query
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -64,9 +65,39 @@ async def _resolve_project_root(project_id: UUID) -> Path | None:
return Path(resolve_stored_project_path(stored_path))
# 저장본 머리에서 열쇠(해시)만 떼어 볼 만큼. 봉투 첫 두 칸이 `version`·`hash` 라
# (`B05_Profile_UI_Corridor_Envelope.serialize`) 이 안에 반드시 들어온다.
_HASH_HEAD_BYTES = 4096
_HASH_PATTERN = re.compile(rb'"hash"\s*:\s*"([0-9a-fA-F]+)"')
async def _stored_hash(path: Path) -> str | None:
"""저장본을 통째로 읽지 않고 머리 4KB 에서 열쇠만 뽑는다. 못 찾으면 None."""
def read_head() -> bytes:
with path.open("rb") as handle:
return handle.read(_HASH_HEAD_BYTES)
found = _HASH_PATTERN.search(await asyncio.to_thread(read_head))
return found.group(1).decode() if found else None
@router.get("/{project_id}/routes/{route_id}/corridor", response_model=None)
async def get_corridor(project_id: UUID, route_id: int) -> Response:
"""저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백)."""
async def get_corridor(
project_id: UUID,
route_id: int,
hash: str | None = Query(
None,
description="지금 정본의 열쇠. 저장본이 다르면 파일 대신 stale 만 돌려준다.",
max_length=64,
),
) -> Response:
"""저장된 코리도 파일 반환 — 없으면 404(프론트가 빌드로 폴백).
`hash` 주면 **먼저 열쇠부터 맞춰 본다**(2026-09-06 실측). 저장본이 낡았으면
18.6MB 내려보낸 브라우저가 버리는 일이 벌어졌다 어긋날 때는 수십 바이트만
돌려주고 끝낸다. 맞으면 종전처럼 파일을 그대로 흘려보내므로 왕복은 여전히 번이다.
"""
try:
project_root = await _resolve_project_root(project_id)
if project_root is None:
@@ -79,6 +110,13 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response:
status_code=404,
content={"status": "error", "message": "저장된 코리도가 없습니다."},
)
# `isinstance` 로 좁히는 이유 — 이 함수를 파이썬에서 곧바로 부르면 `hash` 에 `None`
# 대신 **`Query(...)` 기본값 객체**가 들어오고 그것이 truthy 라, 열쇠를 안 준 호출이
# 통째로 `stale` 로 떨어졌다(2026-09-06 보조 창 지적). HTTP 경로만 보면 안 드러난다.
if isinstance(hash, str) and hash:
stored = await _stored_hash(path)
if stored is not None and stored != hash:
return JSONResponse(content={"status": "stale", "hash": stored})
payload = await asyncio.to_thread(path.read_bytes)
return Response(content=payload, media_type="application/json")
except OSError as exc:
+541
View File
@@ -0,0 +1,541 @@
"""계획노선 편집 — 노선 두 벌(예상노선·계획노선)과 그 뒤 재계산의 서버 몫.
노선은 ** **이다(2026-09-06 사용자 확정 같은 정정, PLAN 0-7).
- **예상노선**(원본) 파일 업로드 자동 체인이 . `B05_Profile/route/expected_route.csv`.
이것은 폴리라인이 아니라 ** 묶음**이고 규칙 없는 폴리라인과도 맞지 않는다
(2026-09-06 사용자 확인). 어떤 경로로도 고치지 않는다.
- **계획노선 초기본** 위를 복사해 **폴리라인으로 바꾼 **. `planned_route_initial.csv`.
**불변의 초기 데이터**이며, 유토곡선·3D 투영되는 선도 이것이다. 곡선은 지식DB
기준(별표2 .2. 설계속도·지형별 최소곡선반지름, 내각 155° 이상은 생략)으로 끼운다.
- **계획노선**(수정본) 초기본에서 시작해 사용자가 **노드를 잡아** 고친 .
`planned_route.csv`. 설계 계통은 이것이 있으면 이것을 읽는다(`load_design_route`).
노선 초기화는 수정본 파일을 지우는 것이다 그러면 초기 폴리라인을 읽으므로 초기본을
수정본으로 복사 결과가 같다.
[확인] 눌렀을 때만 계산이 돈다. 재계산은 **초기 업로드 체인의 로직을 그대로 재사용**한다
(2026-09-06 사용자 제안) `run_redesign_chain` 배수유역 다시 분석 기본 저장
정착 계획선 재산출 종횡단 재생성 측점 설계를 누가거리로 이월 B05·B06 확정까지
줄로 돈다. 여기서 하는 일은 앞에 **노선을 갈아 끼우는 **뿐이다.
시설 처리(2026-09-06 사용자 확정) 횡단배수 지점은 노선 자리에 따라 생기고 없어지므로
저장분을 버리고 **새로 계산한 ** 쓴다. 밖의 구조물은 ** 측점값을 그대로** 이어받는다
(`structures.json` 프로젝트 정본이라 손대지 않으면 그대로 남는다).
GET /api/projects/{id}/route/plan 예상노선·계획노선 정점(사업지 좌표계)
POST /api/projects/{id}/route/replan 고친 계획노선으로 갈아 끼우고 재계산
POST /api/projects/{id}/route/replan/reset 계획노선을 예상노선으로 되돌리고 재계산
"""
import asyncio
import csv
import functools
import json
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
densify_route,
expected_route_csv_path,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
write_route_csv,
)
from common_util.common_util_route_polyline import build_planned_polyline
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
from config.config_system import (
ROUTE_DIRECT_LINK_CELL_FACTOR,
ROUTE_GRID_RES_M,
ROUTE_PLANNED_DENSIFY_SAFETY,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B05 Route Design"])
_PROJECT_PATH_MISSING = {"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."}
class RouteVertexInput(BaseModel):
"""계획노선 꺾임점 하나 — 사업지 좌표계(m).
사용자 편집을 그대로 나른다(2026-09-07 사용자 지시)
· **직선 삭제·추가** = 목록에서 점을 빼거나 더하는 .
· **곡선 삭제·추가** = `curve` 끄고 켜는 .
· **반지름 변경** = `radius_m` 주는 . 주면 서버가 고른다.
"""
x: float
y: float
curve: bool = True
"""이 자리에 곡선을 둘지. 끄면 직선이 그대로 꺾인다."""
radius_m: float | None = None
"""못박을 곡선 반지름(m). 없으면 예정노선에 맞추거나 법정 하한을 쓴다."""
class RouteReplanRequest(BaseModel):
"""고친 계획노선. 정점은 시점 → 종점 순서(사업지 좌표계 m)."""
vertices: list[RouteVertexInput] = Field(default_factory=list)
async def _project_paths(project_id: UUID) -> tuple[Path, str] | None:
"""(프로젝트 실경로, 저장소 상대경로). 없으면 None."""
pool = get_db_pool()
async with pool.acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
return None
return Path(resolve_stored_project_path(stored)), str(stored)
def _vertices_of(path: Path) -> list[list[float]]:
"""CSV 정점을 [[x, y], …]로. 없거나 못 읽으면 빈 목록."""
if not path.is_file():
return []
route = read_planned_route_csv(path)
if route is None:
return []
return [[float(vertex.x), float(vertex.y)] for vertex in route.vertices]
def _ensure_expected_route(project_root: Path) -> str:
"""예상노선(원본) 정본이 없으면 만들어 둔다. 어디서 씨앗을 얻었는지 돌려준다.
프로젝트는 자동 체인이 만들어 두지만(2026-09-06), 전에 만들어진 프로젝트는
초기값 스냅샷 안에만 있거나 그마저 재확정 체인이 지운 뒤일 있다. 노선 초기화가
성립하려면 파일이 반드시 있어야 하므로 여기서 세워 둔다.
"""
target = expected_route_csv_path(project_root)
if target.is_file():
return "already"
for source, label in (
(design_route_csv_path(project_root), "snapshot"),
(planned_route_working_path(project_root), "working"),
):
vertices = _vertices_of(source)
if len(vertices) >= 2:
write_route_csv(target, [{"x": x, "y": y} for x, y in vertices])
logger.info("예상노선 정본을 %s 에서 세웠습니다: %s", label, target)
return label
return "none"
async def _min_plan_radius_m(project_id: UUID) -> float:
"""이 프로젝트에 적용할 법정 최소곡선반지름(m) — 임도 종류·설계속도·지형으로 고른다.
값의 출처는 지식DB(`01_임도/02_상세설계/평면선형.md`, 별표2 .2.)이고 산식은 이미
`B05_Profile_Engine_Grade.legal_plan_radius_min_m` 있다 여기서 다시 짜지 않는다.
읽는 자리 임도 종류는 `projects.road_type`, 설계속도·지형은 **워크플로 stage 2 params**
(노선 풀기 요청이 남긴 ). `projects` 에는 설계속도·지형 칸이 없다(2026-09-06 확인:
있는 것은 `road_type` ). 읽으면 가장 완화된 조건으로 떨어진다 막지 않고 위반
표시만 하기 때문이다.
"""
import aiomysql
from B05_Profile.B05_Profile_Engine_Grade import legal_plan_radius_min_m, resolve_design_speed
from common_util.common_util_workflow_state import get_workflow_state
grade_class, design_speed, terrain = "work", None, "special"
try:
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT road_type FROM projects WHERE id = %s", (str(project_id),)
)
row = await cursor.fetchone()
if row and row[0]:
grade_class = str(row[0])
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
stage2 = next(
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
)
params = (stage2 or {}).get("params") or {}
speed = params.get("design_speed_kph")
if isinstance(speed, (int, float)):
design_speed = int(speed)
if params.get("terrain_type") in ("normal", "special"):
terrain = str(params["terrain_type"])
except Exception: # noqa: BLE001 — 설정을 못 읽어도 폴리라인화는 이어 간다
logger.exception("최소곡선반지름 설정을 못 읽어 기본값을 씁니다: %s", project_id)
return legal_plan_radius_min_m(resolve_design_speed(grade_class, design_speed), terrain)
def _nodes_path(path: Path) -> Path:
"""그 폴리라인을 낳은 **노드** 파일 자리 — `planned_route.csv` → `planned_route_nodes.csv`."""
return path.with_name(f"{path.stem}_nodes.csv")
def _curves_path(path: Path) -> Path:
"""그 폴리라인의 **곡선 성분** 자리 — `planned_route.csv` → `planned_route_curves.json`.
따로 남기나(2026-09-07 사용자 확정) 계획노선은 직선 > 곡선 > 직선이고 사용자가
잡는 것은 **곡선 시작·끝점**이며 반지름도 직접 바꾼다. 정점 목록만으로는 어디부터
어디까지가 곡선인지, 반지름이 얼마인지 없어 편집·도면이 같은 값을 본다.
"""
return path.with_name(f"{path.stem}_curves.json")
def _read_curves(path: Path) -> list[dict]:
"""저장해 둔 곡선 성분. 없거나 못 읽으면 빈 목록(옛 프로젝트)."""
target = _curves_path(path)
if not target.is_file():
return []
try:
data = json.loads(target.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
logger.warning("계획노선 곡선 성분을 읽지 못했습니다: %s", target)
return []
curves = data.get("curves") if isinstance(data, dict) else None
return curves if isinstance(curves, list) else []
def _write_planned_polyline(
path: Path,
points: list[tuple[float, float]],
radius_m: float,
*,
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
) -> dict:
"""점 묶음을 폴리라인으로 바꿔 CSV 로 쓴다. 노드 요약을 돌려준다.
**노드도 함께 남긴다** 노드는 폴리라인에서 되뽑을 없다. 폴리라인에는 원호 점이
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인] 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 134 119). 낳은 값을 그대로 보관한다.
"""
result = build_planned_polyline(
points,
min_radius_m=radius_m,
simplify=simplify,
curve_flags=curve_flags,
radii=radii,
)
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text(
json.dumps(
{"min_radius_m": round(radius_m, 3), "curves": [c.as_dict() for c in result.curves]},
ensure_ascii=False,
),
encoding="utf-8",
)
return {
"nodes": len(result.nodes),
"curves": result.curve_count,
"violations": result.violation_count,
"vertices": len(result.vertices),
}
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
낡았다 = 예상노선 파일이 나중에 쓰였다. 파일을 다시 올리면 예상노선이 새로
깔리는데 초기본이 노선인 채로 남으면 노선 초기화가 자리로 돌아간다.
"""
target = planned_route_initial_path(project_root)
source = expected_route_csv_path(project_root)
if target.is_file():
if not source.is_file() or source.stat().st_mtime <= target.stat().st_mtime:
return None
logger.info("예상노선이 새로 깔려 초기 폴리라인을 다시 만듭니다: %s", target)
points = [(x, y) for x, y in _vertices_of(source)]
if len(points) < 2:
return None
summary = _write_planned_polyline(target, points, radius_m)
logger.info(
"계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)",
target,
summary["nodes"],
summary["curves"],
summary["violations"],
summary["vertices"],
)
return summary
def _write_working_route(path: Path, vertices: list[tuple[float, float]]) -> int:
"""계획노선(수정본) CSV를 쓴다. 열 이름은 `read_planned_route_csv()`가 아는 것.
쓰기 전에 **조밀화**한다 정점이 성기면 B05 격자 탐색이 원좌표를 그대로 잇지 못하고
나름의 길을 찾아 사용자가 그린 선과 달라진다(`densify_route` 주석). 평면 형상은
바뀌지 않고 같은 위에 점만 찍힌다.
"""
dense = densify_route(
vertices,
ROUTE_DIRECT_LINK_CELL_FACTOR * ROUTE_GRID_RES_M * ROUTE_PLANNED_DENSIFY_SAFETY,
)
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(("sequence", "x", "y"))
writer.writerows((index, round(x, 4), round(y, 4)) for index, (x, y) in enumerate(dense))
return len(dense)
async def _recompute(project_id: UUID, project_root: Path, stored_path: str) -> dict[str, Any]:
"""노선을 갈아 끼운 뒤의 재계산 — 초기 업로드 체인과 같은 로직을 그대로 탄다."""
import aiomysql
from B03_FileInput.B03_FileInput_Service_Chain import (
_log_steps,
_planned_route_points_in_project_crs,
run_redesign_chain,
)
from B04_PreProcess.B04_PreProcess_Service import find_surface_model_for_selection
from B05_Profile.B05_Profile_Repository import get_latest_route
from common_util.common_util_drainage_pipes import clear_pipe_points
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from common_util.common_util_workflow_state import get_workflow_state, start_stage
marks = [("시작", time.perf_counter())]
# 횡단배수 지점은 새 노선에서 새로 계산한다 — 저장분을 남기면 옛 자리가 살아난다.
await asyncio.to_thread(clear_pipe_points, stored_path)
pool = get_db_pool()
async with pool.acquire() as connection:
selection = await get_surface_confirmation_params(connection, str(project_id))
latest = await get_latest_route(connection, project_id)
# 지금 노선이 쓰던 지표면 모델이 1순위다 — 확정 선택값으로 다시 찾으면 프로젝트마다
# 모델 조합이 달라 못 찾는 경우가 있다(용화: filter=classification/method=dtm 무매칭).
surface_model_id = (
int(latest["surface_model_id"]) if latest and latest.get("surface_model_id") else None
)
if surface_model_id is None:
try:
surface_model_id = await find_surface_model_for_selection(
connection, project_id, selection
)
except Exception:
surface_model_id = None
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()
if surface_model_id is None:
return {"error": "확정된 지표면 모델을 찾지 못해 노선을 다시 계산할 수 없습니다."}
route_range = (range_row[0], range_row[1]) if range_row else None
# 갈아 끼운 노선을 stage 2 제어점으로 세운다 — 재확정 체인이 여기서 BP·EP·경유점을
# 읽어 경로를 다시 푼다. 읽기는 설계 계통과 같은 한 곳(`load_design_route`)을 지난다.
points = await asyncio.to_thread(
_planned_route_points_in_project_crs, project_root, selection, route_range
)
if not points or len(points) < 2:
return {"error": "계획노선을 읽지 못했습니다(정점이 2개 미만)."}
# 수정본도 예상노선 정본도 없던 프로젝트 — 지금 읽은 것이 곧 예상노선이므로 여기서 세운다.
expected_path = expected_route_csv_path(project_root)
if not expected_path.is_file() and not planned_route_working_path(project_root).is_file():
await asyncio.to_thread(write_route_csv, expected_path, points)
logger.info("예상노선 정본을 원본 재판독으로 세웠습니다: %s", expected_path)
pool = get_db_pool()
async with pool.acquire() as connection:
async with connection.cursor(aiomysql.DictCursor) as cursor:
state = await get_workflow_state(cursor, str(project_id))
stage2 = next(
(s for s in (state or {}).get("stages", []) if int(s.get("stage_no", -1)) == 2), None
)
params = dict((stage2 or {}).get("params") or {})
params["points"] = {
"bp": points[0],
"ep": points[-1],
"cp": [{**point, "order": index} for index, point in enumerate(points[1:-1], start=1)],
}
async with connection.cursor(aiomysql.DictCursor) as cursor:
await start_stage(cursor, str(project_id), 2, params)
await connection.commit()
marks.append(("재계산 준비(관 삭제·노선 읽기·stage 기록)", time.perf_counter()))
failure = await run_redesign_chain(project_id, int(surface_model_id), selection)
marks.append(("재확정 체인", time.perf_counter()))
_log_steps("노선 [확인] 재계산", marks)
if failure:
# 체인이 끊기면 **노선만 새것으로 갈아 끼워진 채** 종횡단은 옛 노선에 남아 어긋난다
# (2026-09-06 실측: 배수유역·관은 새 노선, 종횡단은 옛 노선 → 배수관 측점 9 → 0).
# 부르는 쪽이 계획노선을 되돌리도록 사유를 올려 보낸다.
return {"error": failure}
pool = get_db_pool()
async with pool.acquire() as connection:
made = await get_latest_route(connection, project_id)
return {
"route_id": int(made["id"]) if made else None,
"total_length_m": float(made.get("total_length_m") or 0.0) if made else None,
"vertex_count": len(points),
}
@router.get("/{project_id}/route/plan", response_model=None)
async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""예상노선(점 묶음)·계획노선(폴리라인)·편집할 노드를 함께 돌려준다.
화면이 그리는 것은 셋이다 예상노선은 **점선**, 계획노선은 **실선**, 그리고 사용자가
잡아 옮기는 **노드**(꺾임점). 노드에는 자리에 끼운 반지름·내각·법정 위반 표시가
붙어 있어 화면이 그대로 보여 있다(2026-09-06 사용자 지시).
"""
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, _ = paths
# 예상노선 정본이 먼저, 없으면 옛 자리(초기값 스냅샷)를 본다.
expected = await asyncio.to_thread(_vertices_of, expected_route_csv_path(project_root))
if not expected:
expected = await asyncio.to_thread(_vertices_of, design_route_csv_path(project_root))
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working = await asyncio.to_thread(_vertices_of, planned_route_working_path(project_root))
initial = await asyncio.to_thread(_vertices_of, planned_route_initial_path(project_root))
planned = working or initial or expected
# 노드는 **저장해 둔 것을 그대로** 쓴다 — 폴리라인에서 되뽑으면 안 된다. 정점에 원호
# 위 점이 섞여 있어 다시 단순화하면 꺾임점이 지워지고, 그 결과로 만든 폴리라인을 또
# 단순화하게 되어 [확인]마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119).
saved_nodes = await asyncio.to_thread(
_vertices_of,
_nodes_path(
planned_route_working_path(project_root)
if working
else planned_route_initial_path(project_root)
),
)
node_source = saved_nodes or (working or expected)
saved_curves = await asyncio.to_thread(
_read_curves,
planned_route_working_path(project_root)
if working
else planned_route_initial_path(project_root),
)
outline = await asyncio.to_thread(
build_planned_polyline,
[(x, y) for x, y in node_source],
min_radius_m=radius_m,
# 저장해 둔 노드면 이미 꺾임점이라 다시 뽑지 않는다. 없을 때(옛 프로젝트)만 뽑는다.
simplify=not saved_nodes,
)
return {
"status": "success",
"project_id": str(project_id),
"expected": expected,
"planned": planned,
"nodes": [node.as_dict() for node in outline.nodes],
# 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다
# (2026-09-07 사용자 확정). 저장분이 있으면 그것을, 없으면 방금 뽑은 것을 준다.
"curves": saved_curves or [curve.as_dict() for curve in outline.curves],
"min_radius_m": round(radius_m, 2),
"curve_count": len(saved_curves) if saved_curves else outline.curve_count,
"violation_count": outline.violation_count,
"edited": bool(working),
}
@router.post("/{project_id}/route/replan", response_model=None)
async def replan_route(
project_id: UUID, request: RouteReplanRequest
) -> dict[str, Any] | JSONResponse:
"""고친 계획노선으로 갈아 끼우고 배수유역부터 다시 계산한다(모달 [확인])."""
if len(request.vertices) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "계획노선은 정점이 2개 이상이어야 합니다."},
)
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
# 고치기 전에 예상노선(원본)·초기 폴리라인이 서 있는지 본다 — 초기화가 돌아갈 자리다.
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
# 화면이 보낸 것은 **노드(꺾임점)** 다 — 같은 R 규칙으로 다시 폴리라인을 만든다.
# 노드만 옮기면 선이 저절로 규칙을 지키는 것이 이 구조의 목적이다(2026-09-06 사용자).
# 받은 것은 이미 꺾임점이므로 **다시 뽑지 않는다** — 뽑으면 사용자가 둔 노드가 지워진다.
nodes = [(vertex.x, vertex.y) for vertex in request.vertices]
curve_flags = [bool(vertex.curve) for vertex in request.vertices]
radii = [vertex.radius_m for vertex in request.vertices]
# 체인이 끊기면 되돌릴 수 있게 이전 수정본을 손에 쥔다 — 실패했는데 노선만 바뀌면
# 종횡단과 어긋난 채 굳고, 다시 누를수록 어긋남이 쌓인다(2026-09-06 실측).
working_path = planned_route_working_path(project_root)
previous = working_path.read_bytes() if working_path.is_file() else None
summary = await asyncio.to_thread(
functools.partial(
_write_planned_polyline, simplify=False, curve_flags=curve_flags, radii=radii
),
working_path,
nodes,
radius_m,
)
written = summary["vertices"]
logger.info(
"계획노선 갈아 끼움: project_id=%s 노드 %d → 정점 %d (곡선 %d · 위반 %d · R %.1fm)",
project_id,
len(nodes),
written,
summary["curves"],
summary["violations"],
radius_m,
)
result = await _recompute(project_id, project_root, stored_path)
if "error" in result:
# 노선을 되돌린다 — 실패했는데 새 노선만 남으면 화면·정본이 어긋난 채 굳는다.
if previous is None:
working_path.unlink(missing_ok=True)
else:
working_path.write_bytes(previous)
logger.warning(
"계획노선 갈아 끼우기 실패 — 노선을 되돌렸습니다: project_id=%s 사유=%s",
project_id,
result["error"],
)
return JSONResponse(
status_code=409, content={"status": "error", "message": result["error"]}
)
return {"status": "success", "project_id": str(project_id), **result}
@router.post("/{project_id}/route/replan/reset", response_model=None)
async def reset_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"""계획노선을 예상노선으로 되돌리고 다시 계산한다(노선 초기화).
수정본 파일을 지우면 설계 계통이 원본을 읽으므로 원본을 수정본으로 복사 같다.
"""
paths = await _project_paths(project_id)
if paths is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
project_root, stored_path = paths
await asyncio.to_thread(_ensure_expected_route, project_root)
radius_m = await _min_plan_radius_m(project_id)
await asyncio.to_thread(_ensure_planned_initial, project_root, radius_m)
working_path = planned_route_working_path(project_root)
if working_path.is_file():
working_path.unlink()
logger.info("계획노선 초기화(초기 폴리라인으로): project_id=%s", project_id)
result = await _recompute(project_id, project_root, stored_path)
if "error" in result:
return JSONResponse(
status_code=409, content={"status": "error", "message": result["error"]}
)
return {"status": "success", "project_id": str(project_id), **result}
+6 -2
View File
@@ -67,7 +67,10 @@ class RouteSolveRequest(BaseModel):
method: str = Field(default="dtm", description="지표면 표현 (dtm/tin/nurbs/implicit/meshfree)")
smooth: bool = Field(default=False)
surface_model_id: int | None = Field(default=None, description="기반 지표면 모델 id")
algorithm: str = Field(default="dijkstra", description="경로 알고리즘 (dijkstra/ridge_valley)")
algorithm: str = Field(
default="dijkstra",
description="경로 알고리즘 (dijkstra/ridge_valley/as_planned)",
)
bp: RoutePoint
ep: RoutePoint
@@ -116,7 +119,8 @@ class RouteSolveRequest(BaseModel):
def validate_choices(self) -> "RouteSolveRequest":
if self.grade_class not in ROUTE_GRADE_CLASSES:
raise ValueError(f"임도 등급은 {ROUTE_GRADE_CLASSES} 중 하나여야 합니다.")
if self.algorithm not in ("dijkstra", "ridge_valley"):
# `as_planned` = 사용자가 고친 계획노선을 그대로 씀(PLAN 0-3 자동탐색 접기).
if self.algorithm not in ("dijkstra", "ridge_valley", "as_planned"):
raise ValueError("경로 알고리즘은 dijkstra 또는 ridge_valley여야 합니다.")
if self.design_speed_kph is not None and self.design_speed_kph not in (20, 30, 40):
raise ValueError("설계속도는 20/30/40 중 하나여야 합니다.")
@@ -448,6 +448,7 @@
"enabled": false,
"group": "B",
"name": "측구(옆도랑)",
"design_owner": "횡단 설계",
"placement": "interval",
"style": {
"color": "#2eaadc",
@@ -639,6 +640,13 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
}
]
},
@@ -697,6 +705,13 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
}
]
},
@@ -755,6 +770,13 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
}
]
},
@@ -813,6 +835,13 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
}
]
},
@@ -871,6 +900,13 @@
"default": null,
"required": true,
"phase": "detail"
},
{
"key": "side",
"label": "설치 측",
"input": "select",
"choices": ["자동(성토 쪽)", "좌", "우"],
"default": "자동(성토 쪽)"
}
]
},
@@ -44,6 +44,10 @@ class StructureOptionField(BaseModel):
# (2026-08-17 사용자 확정). `detail`이면 required여도 B05 저장에서 강제하지 않는다
# — 필수 원칙은 유지되고 강제 시점만 B06/B07로 미뤄진다.
phase: Literal["b05", "detail"] = "b05"
# 폼에 칸은 두되 **지금은 못 고르게** 할 때 거짓으로 둔다 — 회색으로 그려지고 값은
# 기본값이 그대로 저장된다(2026-09-07 사용자: 「폼 선택은 가능하게 반영하고 나중에
# 선택 비활성화로 하자」). 칸 자체를 없애면 나중에 켤 자리를 다시 찾아야 한다.
enabled: bool = True
class StructureType(BaseModel):
@@ -61,6 +65,14 @@ class StructureType(BaseModel):
drawing_views: list[str] = Field(default_factory=list)
# 다른 정본이 관리하는 타입(배관 = pipe_points.json). structures.json에 저장하지 않는다.
managed_by: str | None = None
# 목록에는 두되 **제원·수량을 내는 주인이 다른 화면**인 타입 — 그 화면 이름을 적는다
# (2026-09-07 사용자: 「두되 표시만 해줘」). `managed_by`와 달리 저장은 그대로 되고,
# ① 화면이 「{이름}에서 관리」 표시를 붙이고 ② 수량 집계가 건너뛴다.
#
# 측구(옆도랑)가 그 경우다 — 횡단 설계가 측구 켬/끔·형식·터파기 단면적을 이미 셈하므로
# (`B06_Section_Engine_Design.py` · `common_util_cross_design.ts` 짝), 구조물로 또 세면
# **같은 것을 두 번 계상**한다(2026-09-07 조사).
design_owner: str | None = None
# 전문 상세설계가 따로 필요한 시설(교량 등) — 배치·제원 입력까지만 담당한다.
reference_only: bool = False
enabled: bool = True
+99 -17
View File
@@ -14,6 +14,11 @@
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import {
purgeAssetsWithPrefix,
readCachedBytes,
writeCachedBytes,
} from "../A00_Common/b_asset_cache";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
@@ -58,18 +63,55 @@ async function requestCorridor(path: string, init: RequestInit): Promise<Respons
}
}
async function fetchStored(projectId: string, routeId: number): Promise<CorridorEnvelope | null> {
/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */
type StoredLookup =
{ kind: "envelope"; envelope: CorridorEnvelope } | { kind: "stale" } | { kind: "missing" };
/**
* . **(hash) **
* `stale` (2026-09-06). 18.6MB
* .
*/
/** 이 노선 코리도의 보관 주소 앞머리 — 열쇠만 다른 옛 보관본을 치울 때 쓴다. */
function corridorUrlPrefix(projectId: string, routeId: number): string {
return `${API_BASE_URL}/projects/${projectId}/routes/${routeId}/corridor`;
}
async function fetchStored(
projectId: string,
routeId: number,
hash: string,
): Promise<StoredLookup> {
const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`;
try {
const response = await requestCorridor(`/projects/${projectId}/routes/${routeId}/corridor`, {
method: "GET",
});
if (!response.ok) return null;
const payload = (await response.json()) as CorridorEnvelope;
return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)
? payload
: null;
// **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번
// 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가
// 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다.
const cached = await readCachedBytes(projectId, url);
if (cached) {
const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope;
if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) {
return { kind: "envelope", envelope: payload };
}
}
const response = await requestCorridor(
`/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`,
{ method: "GET" },
);
if (!response.ok) return { kind: "missing" };
const text = await response.text();
const payload = JSON.parse(text) as CorridorEnvelope & { status?: string };
if (payload?.status === "stale") return { kind: "stale" };
if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) {
// 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게.
void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() =>
writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer),
);
return { kind: "envelope", envelope: payload };
}
return { kind: "missing" };
} catch {
return null; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
}
}
@@ -120,16 +162,19 @@ export async function ensureCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
/** ** ** . false
* 3D [3D ] (2026-09-06). */
rebuild = true,
): Promise<CorridorBuildResult | null> {
const key = keyOf(projectId, routeId);
const hash = corridorHash(detail, routePoints);
const cached = cache.get(key);
if (cached && cached.hash === hash) return cached.build;
const stored = await fetchStored(projectId, routeId);
if (stored && stored.hash === hash) {
const lookup = await fetchStored(projectId, routeId, hash);
if (lookup.kind === "envelope" && lookup.envelope.hash === hash) {
try {
const build = deserialize(stored);
const build = deserialize(lookup.envelope);
cache.set(key, { hash, build, dirty: false });
markSource("stored", hash);
return build;
@@ -137,6 +182,12 @@ export async function ensureCorridor(
// 손상 저장본 — 빌드로 폴백.
}
}
// 저장본이 낡았고 다시 만들지 않기로 했으면 여기서 끝낸다 — 캐시도 건드리지 않아
// 화면에 이미 서 있는 예상형상이 그대로 남는다([3D 업데이트] 대기 표시는 부르는 쪽 몫).
if (!rebuild) return null;
// 저장본이 아예 없는 경우(missing)와 있는데 낡은 경우(stale)를 가른다 — 아래 저장 규칙이
// 갈린다. `stale` 도 파일은 있으므로 즉시 PUT 하지 않고 [저장]·페이지 이동 때 올린다.
const hasStored = lookup.kind !== "missing";
// 종단 계획선 샘플을 함께 넘겨 측점 사이가 종단곡선을 따라 부드럽게 이어지게 한다.
// 라이브 편집분(alignment.samples)이 있으면 그걸 쓴다 — 정본 design_profiles는
@@ -151,7 +202,7 @@ export async function ensureCorridor(
return null;
}
markSource("built", hash);
if (stored === null) {
if (!hasStored) {
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
cache.set(key, { hash, build, dirty: false });
void putStored(projectId, routeId, serialize(build, hash)).then((ok) => {
@@ -173,8 +224,16 @@ function markDirty(projectId: string, routeId: number): void {
export async function saveCorridorIfDirty(projectId: string, routeId: number): Promise<void> {
const entry = cache.get(keyOf(projectId, routeId));
if (!entry || !entry.dirty) return;
const ok = await putStored(projectId, routeId, serialize(entry.build, entry.hash));
if (ok) entry.dirty = false;
const envelope = serialize(entry.build, entry.hash);
const ok = await putStored(projectId, routeId, envelope);
if (!ok) return;
entry.dirty = false;
// 방금 올린 것을 **보관함에도** 담는다(2026-09-06) — 그러지 않으면 다음 진입이 열쇠가
// 바뀐 주소로 한 번 더 받아 온다. 담아 두면 네트워크 없이 선다.
const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(entry.hash)}`;
const bytes = new TextEncoder().encode(JSON.stringify(envelope)).buffer as ArrayBuffer;
await purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId));
await writeCachedBytes(projectId, url, bytes);
}
/** Page ( ).
@@ -186,9 +245,32 @@ export function refreshCorridor(
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
rebuild = true,
): Promise<void> {
if (!routeId) return Promise.resolve();
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples)
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, rebuild)
.then((build) => viewer.setCorridor(build))
.catch(() => viewer.setCorridor(null));
}
/**
* ** ** 3D .
* `false` , [3D ] (2026-09-06).
* 3D .
*/
export function loadCorridorIfFresh(
viewer: { setCorridor: (build: CorridorBuildResult | null) => void },
projectId: string,
routeId: number | undefined,
detail: SectionDetailResponse,
routePoints: RoutePoint[],
designSamples?: ProfileSamples,
): Promise<boolean> {
if (!routeId) return Promise.resolve(false);
return ensureCorridor(projectId, routeId, detail, routePoints, designSamples, false)
.then((build) => {
if (build) viewer.setCorridor(build);
return build !== null;
})
.catch(() => false);
}
+24 -4
View File
@@ -1,4 +1,5 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import {
computeDetailBasins,
fetchDetailPipePoints,
@@ -27,6 +28,7 @@ import { buildStrengthArray } from "../B04_PreProcess/B04_PreProcess_UI_FlowRamp
import { resampleRoute } from "../B04_PreProcess/B04_PreProcess_UI_RouteSamples";
import { createPipeEditor } from "./B05_Profile_UI_Drainage_Pipes";
import { createFacilityStore } from "./B05_Profile_UI_Drainage_Facility";
import { writePendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import { createDrainageChrome } from "./B05_Profile_UI_Drainage_Chrome";
import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact";
import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render";
@@ -39,7 +41,6 @@ import {
fitViewToRoute,
observeViewportSize,
bindPipeContextMenu,
COLLAPSED_KEY,
MAX_PANEL_WIDTH_RATIO,
MIN_PANEL_WIDTH,
renderBasinRows,
@@ -129,7 +130,12 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
scheduleDraw();
},
// 배치가 실제로 바뀐 순간(추가·삭제·이동 완료)에만 세부유역을 다시 나눈다.
() => void analyze(),
// 같은 순간에 **세션 초안**에도 담는다 — 예전에는 패널 메모리에만 있어 B06 으로
// 넘어가 저장하면 편집이 사라졌다(2026-09-06 대응표 조사).
() => {
rememberPipes();
void analyze();
},
);
// 2차 전체 배수유역 외곽선 = 분수령. 별도 "능선" 레이어를 두지 않고 이 선 하나로 표시한다
// (전체 유역 외곽선과 능선이 같은 선이므로 — 2026-07-31 사용자 지시).
@@ -389,6 +395,20 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
}
}
/** 지금 화면의 관 목록을 세션 초안에 담는다 — 정본 쓰기는 [저장]·[확정] 몫이다. */
function rememberPipes(): void {
if (!projectId) return;
writePendingPipes(
projectId,
facilityStore.attach(
pipeEditor.pipes().map((pipe) => ({
chainage_m: pipe.chainage_m,
source: (pipe.reason || "user") as PipeSource,
})),
),
);
}
/** 저장된 관 지점(없으면 자동 배치)을 불러온다. 화면에 들어올 때 1회. */
const loadSaved = (): Promise<void> => run(() => fetchDetailPipePoints(projectId as string));
@@ -526,7 +546,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
panelHandle.setOpen(!collapsed);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
writeStateRaw("drainage-collapsed", String(collapsed));
if (!collapsed) scheduleDraw();
}
panelHandle.root.addEventListener("click", () =>
@@ -534,7 +554,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
);
// 상세 배수유역 정보는 페이지에 들어오면 바로 보여야 한다 — 저장값이 없으면 펼침이 기본이다
// (같은 페이지의 하단 종단 패널과 같은 규칙).
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
setCollapsed(readStateRaw("drainage-collapsed") === "true");
return {
root,
+4 -2
View File
@@ -6,6 +6,7 @@
* , .
* ========================================================================== */
import { stateKey } from "../A00_Common/b_page_state";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { themeColor } from "@ui/ui_template_palette";
import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
@@ -52,9 +53,10 @@ export const hotspotToggleColor = (): string => themeColor("--map-flow-ramp-4",
/** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */
export const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b");
export const COLLAPSED_KEY = "b05-route-drainage-collapsed";
/** 접힘·폭은 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
export const COLLAPSED_KEY = stateKey("drainage-collapsed") ?? "";
/** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
export const WIDTH_KEY = "b05-route-drainage-width";
export const WIDTH_KEY = stateKey("drainage-width") ?? "";
/** 지도가 담기는 최소 폭(px). CSS의 min-width와 같은 값. */
export const MIN_PANEL_WIDTH = 320;
/** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */
+42 -11
View File
@@ -95,6 +95,14 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) {
return { x: point.x + cx, y: -point.z + cy, z: point.y + cz };
}
/** (m) 2m 20m 10
* (2026-09-06). */
const DRAPE_STEP_M = 2;
/** 한 구간을 나누는 최대 토막 수 — 정점이 아주 멀리 떨어진 자료에서 점이 폭주하지 않게 한다. */
const MAX_DRAPE_STEPS = 64;
/** 노선 선을 지형 위로 띄우는 높이(m) — 지형과 겹쳐 깜빡이지 않을 만큼만. */
const ROUTE_LINE_LIFT_M = 0.35;
export function createRouteMarkers(
scene: THREE.Scene,
getBounds: () => ModelBounds | null,
@@ -254,17 +262,40 @@ export function createRouteMarkers(
disposeGroup(routeGroup);
const bounds = getBounds();
if (!bounds || polyline.length < 2) return;
// 정점 사이를 잘게 나눠 **각 점의 지형고를 찍어** 선을 지면에 붙인다(2026-09-06).
// 원래는 정점(간격 중앙값 10m)을 곧바로 이었는데, 볼록한 능선에서는 그 10m 직선이
// 지면을 뚫고 들어가 선이 땅속에 잠겼다 — 화면에는 폴리라인이 끊긴 것처럼 보였다
// (실측: 161+0.0~163+0.0 구간이 통째로 사라짐). 원래 정점은 모두 남기므로 평면
// 형상은 바뀌지 않는다.
const dense: Array<{ x: number; y: number; z?: number }> = [];
/** 원래 정점 i 가 조밀화 뒤 몇 번째인지 — 경고 구간이 인덱스로 자르므로 필요하다. */
const denseIndex: number[] = [];
polyline.forEach((point, index) => {
if (index > 0) {
const previous = polyline[index - 1];
const span = Math.hypot(point.x - previous.x, point.y - previous.y);
const steps = Math.min(MAX_DRAPE_STEPS, Math.ceil(span / DRAPE_STEP_M));
for (let step = 1; step < steps; step += 1) {
const ratio = step / steps;
dense.push({
x: previous.x + (point.x - previous.x) * ratio,
y: previous.y + (point.y - previous.y) * ratio,
});
}
}
denseIndex[index] = dense.length;
dense.push(point);
});
// 표고 없는 점을 0으로 삼키면 마커와 같은 함정에 빠진다(지형 한참 아래 평면에 눕는다).
// 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 경고 구간이
// 인덱스로 이 배열을 다시 자르므로 점 개수는 그대로 두어야 한다.
// 지형 표면에서 찾아 채우고, 그래도 모르면 직전 점 높이를 이어 쓴다. 끼워 넣은 점과
// 원래 정점이 **같은 지형면**에서 높이를 받아야 선이 매끄러우므로 지형고를 먼저 본다.
let lastZ: number | null = null;
const linePoints = polyline.map((point) => {
const resolved = Number.isFinite(point.z)
? (point.z as number)
: (getTerrainZ?.(point.x, point.y) ?? lastZ);
const linePoints = dense.map((point) => {
const sampled = getTerrainZ?.(point.x, point.y) ?? null;
const resolved = sampled ?? (Number.isFinite(point.z) ? (point.z as number) : lastZ);
if (resolved !== null) lastZ = resolved;
return modelToScene({ x: point.x, y: point.y, z: resolved ?? bounds.z[0] }, bounds).add(
new THREE.Vector3(0, 0.35, 0),
new THREE.Vector3(0, ROUTE_LINE_LIFT_M, 0),
);
});
routeGroup.add(
@@ -274,10 +305,10 @@ export function createRouteMarkers(
),
);
warnings.forEach((warning) => {
const segment = linePoints.slice(
Math.max(0, warning.polyline_start_index),
warning.polyline_end_index + 1,
);
// 경고 구간의 인덱스는 **원래 정점 기준**이라 조밀화 뒤 자리로 옮겨 자른다.
const from = denseIndex[Math.max(0, warning.polyline_start_index)] ?? 0;
const to = denseIndex[Math.min(warning.polyline_end_index, polyline.length - 1)];
const segment = linePoints.slice(from, (to ?? linePoints.length - 1) + 1);
if (segment.length > 1) {
routeGroup.add(
new THREE.Line(
+84 -20
View File
@@ -1,3 +1,4 @@
import { dropStationsNear } from "./B05_Profile_Util_Station";
import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { showToast } from "@ui/ui_template_elements";
@@ -12,8 +13,7 @@ import {
} from "../A00_Common/b_workflow_nav";
import {
fetchConfirmedSurface,
listSurfaceModels,
type SurfaceModelSummary,
type SurfaceConfirmedResponse,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
fetchLatestRoute,
@@ -26,6 +26,7 @@ import { createRoutePanel, type RoutePanelValues } from "./B05_Profile_UI_Panel"
import { createRouteProfilePanel } from "./B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../A00_Common/b_missing_data_guard";
import { navigateTo } from "../A00_Common/router";
import { openRouteEditModal } from "./B05_Profile_UI_RouteEdit";
import { createSelectionSync } from "./B05_Profile_UI_Selection";
import {
restoreStructurePick,
@@ -45,7 +46,11 @@ import {
} from "../B06_Section/B06_Section_Api_Fetch";
import { loadSectionDetail } from "../B06_Section/B06_Section_Section_Store";
import { migrateLegacyStations } from "./B05_Profile_Api_Structures";
import { refreshCorridor, saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import {
loadCorridorIfFresh,
refreshCorridor,
saveCorridorIfDirty,
} from "./B05_Profile_UI_Corridor";
import {
resetDesignAction,
solveRouteAction,
@@ -72,6 +77,9 @@ function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
const migratedRoutes = new Set<string>();
export async function renderB05Route(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) {
@@ -143,6 +151,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 구조물 알약 — 그래프에서 고르면 사이드 폼도 같은 항목을 연다. 계곡 통과 시설
// (가상 id `pipe-*`)은 관 정본 소관이라 누가거리로 되돌려 보낸다(2026-08-17).
onStructureSelect: (structureId) => {
// 고른 자리를 B06 으로 넘긴다 — 사이드 목록·3D 픽에는 있던 기록이 **알약에만
// 빠져 있어** 종단에서 고른 구조물이 B06 횡단도에서 안 잡혔다(2026-09-06).
writeStructurePick(activeProjectId, bridge.markChainage(structureId));
const chainage = bridge.pipeMarkChainage(structureId);
if (chainage !== null) {
panel.structures.selectPipeByChainage(chainage);
@@ -168,7 +179,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
* 3D·· , `selectionSyncing`
* ( ·) .
*/
let confirmedSurface: SurfaceModelSummary | null = null;
/** id 3D (2026-09-06 ).
* (`listSurfaceModels`) id 3D `surface/confirmed`
* . . */
let confirmedSurface: SurfaceConfirmedResponse | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | null = null;
@@ -233,8 +247,43 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
return fresh;
}
/** 이 응답이 "같은 자료"인지 가리는 서명 — 노선 번호·지표면 모델·지표면 설정. */
const latestSignature = (value: RouteLatestResponse): string =>
[
value.route?.id ?? "",
value.route?.surface_model_id ?? "",
value.surface_params.source_filter,
value.surface_params.method,
String(value.surface_params.smooth),
].join("|");
/**
* **** (2026-09-06 · ).
*
* `loadLatest(true)` DB를
* , B05B06을
* . (·) .
*/
async function verifyLatestFreshness(shown: RouteLatestResponse): Promise<void> {
try {
const fresh = await fetchLatestRoute(activeProjectId);
writeLatestCache(fresh);
if (latestSignature(fresh) === latestSignature(shown)) return;
renderLatest(fresh);
if (fresh.route?.id) await restoreSections(fresh.route.id);
} catch {
/* 확인 실패는 조용히 넘긴다 — 화면은 캐시로 이미 서 있다. */
}
}
const panel = createRoutePanel({
onSolve: () => void solveRouteAction(actionContext),
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면
// 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => {
navigateTo(ROUTES.B05_PROFILE);
}),
onTempSave: () => void tempSaveAction(actionContext),
onGoCross: () => {
// 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다.
@@ -273,6 +322,10 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
);
// 측점 바·라벨·램프도 계획고를 따라 움직여야 한다(2026-08-23 지적 ③).
renderStationLines(currentSectionDetail);
// 만든 것을 **바로 올려 둔다**(2026-09-06) — 예전에는 B05→B06 이동 때만 올려서,
// 누른 뒤 새로고침하면 저장본이 낡은 채라 다음 사람이 또 만들어야 했다.
// 브라우저 보관함이 생긴 지금은 올려 두면 다음 진입이 네트워크 없이 선다.
void saveCorridorIfDirty(activeProjectId, latest.route.id);
},
onMovePoint: viewer.beginMoveSelected,
onDeletePoint: viewer.markers.deleteSelected,
@@ -420,7 +473,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
return best.elevation_m;
};
// 측점 바 양 끝 램프용 상단측: 사용자 변경분 → solve 자동 판정 순으로 적용.
const withUphill = [...regular, ...injected].map((station) => {
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 3D 측점 띠도 종단 그래프와
// 같은 규칙을 써야 두 화면의 측점이 어긋나지 않는다(2026-09-06).
const withUphill = [...dropStationsNear(regular, injected), ...injected].map((station) => {
const design = designAt(station.chainage_m);
return {
...station,
@@ -465,16 +520,17 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// renderLatest 후 재렌더가 오므로 그때 그린다(반복 빌드 방지).
const routePoints = latest?.route_points ?? [];
if (routePoints.length > 1) {
void refreshCorridor(
// 저장본이 지금 정본과 맞을 때만 올린다 — 낡았으면 18.6MB 를 받지도, 다시 만들지도
// 않고 [3D 업데이트] 대기 표시만 켠다(2026-09-06 실측: 받아놓고 버리는 데다 다시
// 만드느라 화면 전환이 16.7초까지 갔다). 3D 는 원래 수동인데 진입만 자동이었다.
void loadCorridorIfFresh(
viewer,
activeProjectId,
routeId ?? latest?.route?.id,
detail,
routePoints,
profilePanel.alignmentSamples() ?? undefined,
);
// 방금 정본 그대로 그렸으므로 [3D 업데이트] 대기 표시를 지운다(2026-09-01).
panel.setCorridorPending(false);
).then((fresh) => panel.setCorridorPending(!fresh));
}
}
@@ -504,7 +560,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
if (legacy.length) {
// 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안
// 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다.
const migrateKey = `${activeProjectId}:${routeId}`;
if (legacy.length && !migratedRoutes.has(migrateKey)) {
migratedRoutes.add(migrateKey);
void migrateLegacyStations(activeProjectId, legacy)
.then((result) => {
if (result.migrated > 0) {
@@ -618,9 +678,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
try {
// ② 좌측 폼·노선 설정값 — 도착하는 대로 폼과 3D 마커 복원에 쓴다.
// 캐시가 있으면 그것으로 **먼저** 그리고 신선도는 뒤에서 확인한다 — B05↔B06 이동에서
// 서버를 다시 부르지 않으려는 것이다(2026-09-06). 캐시가 없을 때만 DB를 기다린다.
const cachedLatest = readLatestCache();
const [latestResponse, sectionContext, configuredRoadWidths] = await Promise.all([
// 다른 탭의 재업로드로 옛 route_id가 남을 수 있어 진입 때는 DB 최신값을 읽는다.
loadLatest(true),
cachedLatest ? Promise.resolve(cachedLatest) : loadLatest(true),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
@@ -651,9 +713,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
latest = latestResponse;
advanceLoading("확정 지표면 모델을 확인하는 중…");
// ③ 확정 지표면 모델 목록.
const models = await listSurfaceModels(activeProjectId);
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
// ③ 확정 지표면 모델 id·범위를 한 번에 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
confirmedSurface = confirmed.model_id === null ? null : confirmed;
if (!confirmedSurface) {
// 새 자료가 올라와 옛 결과가 지워진 상태 — 여기서 보여 줄 게 없다.
leaveForDashboard();
@@ -666,6 +728,9 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
// 구조물 타입 레지스트리·정본 — 노선이 없어도 목록은 보여 준다(추가는 노선 이후).
await bridge.load();
advanceLoading("");
// 캐시로 그렸다면 이제 뒤에서 신선도만 확인한다 — 화면은 이미 서 있으므로 기다리지
// 않는다. 다른 탭이 자료를 갈아 끼웠을 때만 다시 그린다.
if (cachedLatest) void verifyLatestFreshness(latestResponse);
} catch (error) {
showToast(error instanceof Error ? error.message : "화면을 불러오지 못했습니다.", "error");
} finally {
@@ -679,16 +744,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const [surface, current] = [confirmedSurface, latest];
if (!surface || !current) return;
try {
// 가장자리만 받는다 — 포인트클라우드 전체(수십 MB)는 안 받는다.
const confirmed = await fetchConfirmedSurface(activeProjectId);
if (!confirmed.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
// 범위는 ③에서 받아 둔 확정 응답에 이미 들어 있다 — 다시 부르지 않는다.
if (!surface.bounds) throw new Error("지표면 범위 정보를 찾을 수 없습니다.");
await viewer.loadSurface(
activeProjectId,
surface.id,
surface.model_id as number,
current.surface_params.method,
current.surface_params.smooth,
current.surface_params.contour_interval_m,
toBounds(confirmed.bounds),
toBounds(surface.bounds),
);
renderLatest(current); // 지형이 올라온 뒤에야 마커·측점선이 지형 위에 놓인다.
if (currentSectionDetail) renderStationLines(currentSectionDetail);
+12 -9
View File
@@ -16,7 +16,6 @@ import {
showToast,
} from "@ui/ui_template_elements";
import { navigateTo } from "../A00_Common/router";
import type { SurfaceModelSummary } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
clearRouteLatestCache,
confirmRoute,
@@ -25,11 +24,12 @@ import {
type RouteLatestResponse,
} from "./B05_Profile_Api_Fetch";
import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options";
import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft";
import {
invalidateSectionDetail,
saveCachedCrossPatches,
} from "../B06_Section/B06_Section_Section_Store";
import { clearStandardCrossSession } from "../B06_Section/B06_Section_UI_Standard_Panel";
import { clearDrafts } from "../A00_Common/b_page_state";
import { saveCorridorIfDirty } from "./B05_Profile_UI_Corridor";
import { circlePoint, routePoint } from "./B05_Profile_UI_Page_Helpers";
import { clearAlignmentDrafts } from "./B05_Profile_UI_Profile_Edit";
@@ -46,7 +46,8 @@ function L(key: keyof typeof ui_locales): string {
export interface PageActionContext {
projectId: string;
latest: () => RouteLatestResponse | null;
confirmedSurface: () => SurfaceModelSummary | null;
/** 확정 지표면 응답 — 모델 id 는 `model_id` 다(2026-09-06 호출 정리). */
confirmedSurface: () => { model_id: number | null } | null;
routeReady: () => boolean;
viewer: () => ReturnType<typeof createRouteViewer>;
panel: () => ReturnType<typeof createRoutePanel>;
@@ -77,7 +78,7 @@ export async function solveRouteAction(ctx: PageActionContext): Promise<void> {
filter_key: latest.surface_params.source_filter,
method: latest.surface_params.method,
smooth: latest.surface_params.smooth,
surface_model_id: confirmedSurface.id,
surface_model_id: confirmedSurface.model_id ?? undefined,
algorithm: values.algorithm,
bp: routePoint(points.bp),
ep: routePoint(points.ep),
@@ -131,6 +132,8 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
const latest = ctx.latest();
const projectId = ctx.projectId;
// 관 매설 지점을 B04와 같은 저장소에 남긴다 — 저장 실패가 임시저장을 막지는 않는다.
// 세션 초안(B06 저장도 같은 창구를 쓴다)을 먼저 내보내고, 화면 목록으로 한 번 더 맞춘다.
await flushPendingPipes(projectId).catch(() => undefined);
await ctx
.profilePanel()
.drainage.savePipes()
@@ -149,7 +152,7 @@ export async function tempSaveAction(ctx: PageActionContext): Promise<void> {
filter_key: latest?.surface_params.source_filter,
method: latest?.surface_params.method,
smooth: latest?.surface_params.smooth,
surface_model_id: ctx.confirmedSurface()?.id,
surface_model_id: ctx.confirmedSurface()?.model_id ?? undefined,
irregular_stations: ctx
.bridge()
.irregularStations()
@@ -202,11 +205,11 @@ export async function resetDesignAction(ctx: PageActionContext): Promise<void> {
// 옛 경로 기준 캐시를 전부 비우고 페이지를 새로 그린다 — 초기 계산본이 정본이 된다.
clearRouteLatestCache(ctx.projectId);
invalidateSectionDetail(ctx.projectId);
// 프로젝트 단위 세션 값도 함께 버린다 — 키에 route_id가 없어 새 노선에 그대로
// 되붙는다(2026-08-28). 초기화는 "사용자 편집을 전부 버린다"가 규약이다.
// 사용자 조작(② 초안)은 **등록표 한 곳**에서 통째로 버린다(2026-09-06 일원화).
// 예전에는 파일마다 따로 지워 새 값이 늘 때 빠뜨리기 쉬웠다. 노선 범위 초안은
// 노선이 바뀌면 키가 달라져 자연히 딸려 오지 않는다.
clearDrafts(ctx.projectId, ctx.latest()?.route?.id ?? null);
ctx.uphillOverrides.clear();
ctx.persistUphillOverrides();
clearStandardCrossSession(ctx.projectId);
// 계획선 편집 초안도 함께 버린다 — 남기면 초기값 위에 옛 편집이 다시 얹혀 계획선이
// 측점에서 원지반선과 만나지 않는다(2026-09-04 실측: 새로고침 후 최대 6.0m 어긋남).
clearAlignmentDrafts();
+7 -13
View File
@@ -7,6 +7,7 @@
* .
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { RoutePanelValues } from "./B05_Profile_UI_Panel";
import type {
ModelBounds,
@@ -246,19 +247,15 @@ export const FACILITY_NAMES: Record<PipeFacility, string> = {
/* (= )
* 3D . uphill_overrides로 .
* 700 · (2026-09-04, ). */
const uphillSessionKey = (projectId: string): string => `b05:uphill:${projectId}`;
/** 세션에 남은 상단측 변경분을 읽는다. 손상된 값은 무시하고 빈 것으로 시작한다. */
export function loadUphillOverrides(projectId: string): Map<string, "left" | "right"> {
const overrides = new Map<string, "left" | "right">();
try {
const raw = window.sessionStorage.getItem(uphillSessionKey(projectId));
if (!raw) return overrides;
Object.entries(JSON.parse(raw) as Record<string, "left" | "right">).forEach(
([chainage, side]) => {
if (side === "left" || side === "right") overrides.set(chainage, side);
},
);
const stored = readState<Record<string, "left" | "right">>("uphill", projectId);
if (!stored) return overrides;
Object.entries(stored).forEach(([chainage, side]) => {
if (side === "left" || side === "right") overrides.set(chainage, side);
});
} catch {
/* 손상된 세션 값은 무시 — 자동 판정값으로 재시작. */
}
@@ -271,10 +268,7 @@ export function saveUphillOverrides(
overrides: ReadonlyMap<string, "left" | "right">,
): void {
try {
window.sessionStorage.setItem(
uphillSessionKey(projectId),
JSON.stringify(Object.fromEntries(overrides)),
);
writeState("uphill", Object.fromEntries(overrides), projectId);
} catch {
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
}
+46 -14
View File
@@ -119,9 +119,11 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
}
/* (structures.json)
*
* , . ( )
* . */
* ** ** (`writePending`), []·[]
* (`saveStructuresIfDirty`). ( )
* .
* ( 2026-08-29
* . 2026-09-06 .) */
let structureRevision = 0;
let structureSaving: Promise<void> = Promise.resolve();
@@ -155,6 +157,19 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
applyIrregularStations([...pipeStations, ...crossDrainStationsOf(ownStructures)]);
}
/** id , id ( null).
* 3D· ** B06 **
* (2026-09-06: 알약만 `structure-pick` B06 ). */
function markChainage(structureId: string | null): number | null {
if (!structureId) return null;
const pipe = pipeMarkChainage(structureId);
if (pipe !== null) return pipe;
const found = ownStructures.find((item) => item.structure_id === structureId);
if (!found) return null;
const chainage = found.chainage_m ?? found.start_m ?? null;
return typeof chainage === "number" && Number.isFinite(chainage) ? chainage : null;
}
/** 알약 id가 계곡 통과 시설(관 정본)이면 그 누가거리, 아니면 null. */
function pipeMarkChainage(structureId: string | null): number | null {
if (!structureId?.startsWith("pipe-")) return null;
@@ -246,7 +261,16 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
async function persistStructures(next: StructureInstance[]): Promise<void> {
if (deps.isRestoring()) return;
try {
const saved = await saveStructures(deps.projectId, structureRevision, next);
// 판번호는 **쓰기 직전에** 서버에서 다시 받는다. 진입 때 받은 값으로 보내면,
// 화면이 떠 있는 동안 정본이 한 번이라도 바뀌었을 때(다른 창 저장·노선 재계산)
// 409 로 거절되고 아래 실패 처리가 초안을 지워 **사용자가 넣은 구조물이 통째로
// 사라졌다**(2026-09-06 실측: B06에서 구조물을 넣고 [저장]해도 정본에 안 남음).
const current = await fetchStructures(deps.projectId).catch(() => null);
const saved = await saveStructures(
deps.projectId,
current ? current.revision : structureRevision,
next,
);
structureRevision = saved.revision;
writePending(null);
// 서버가 새 항목에 식별자를 붙이므로 그 결과로 화면 목록을 맞춘다.
@@ -261,14 +285,15 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
);
}
} catch (error) {
// 실패해도 **초안은 지우지 않는다** — 지우면 사용자가 넣은 구조물이 사라진다
// (2026-09-06 정정). 화면 목록도 그대로 두고 다시 [저장]하면 된다.
if (error instanceof StructureConflictError) {
await refreshStructuresFromServer();
showToast("다른 창에서 구조물이 먼저 저장되어 최신 내용으로 되돌렸습니다.", "error");
showToast(
"다른 창에서 구조물이 먼저 저장돼 이번 저장을 건너뛰었습니다. 다시 [저장]해 주세요.",
"error",
);
return;
}
// 저장이 거절되면 화면에만 남은 항목은 식별자가 없어 고치지도 지우지도 못한다.
// 서버 정본으로 되돌려 화면과 정본을 다시 일치시킨다(2026-08-16 크로스체크 지적 1).
await refreshStructuresFromServer();
showToast(error instanceof Error ? error.message : "구조물 저장에 실패했습니다.", "error");
}
}
@@ -276,15 +301,20 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
/** 진입·새로고침 때 타입 레지스트리와 구조물 정본을 받아 화면에 채운다. */
async function loadStructures(): Promise<void> {
try {
const [types, stored] = await Promise.all([
fetchStructureTypes(),
// 레지스트리는 **한 번만** 받고 쓰는 자리에 따라 나눈다(2026-09-07).
// · 추가 메뉴 — 켜진 종류만(B05 는 종전대로 여섯 군).
// · 알약 레인·군 판정 — **전부**. B06 에서 넣은 B군(측구·맹암거 등)은 레지스트리에서
// 꺼져 있는데, 켜진 것만 주면 이름·색·군을 못 찾아 종단에 「?」 회색 알약으로 뜬다.
const [allTypes, stored] = await Promise.all([
fetchStructureTypes(true),
fetchStructures(deps.projectId),
]);
deps.panel().structures.setTypes(types);
deps.profilePanel().setStructureTypes(types);
const menuTypes = allTypes.filter((type) => type.enabled);
deps.panel().structures.setTypes(menuTypes);
deps.profilePanel().setStructureTypes(allTypes);
// A군 판정에 쓸 타입 정보 — 횡단배수만 그래프 세로선·틸팅 대상이다.
structureTypeMap = new Map(
types.map((type) => [type.type_id, { group: type.group, name: type.name }]),
allTypes.map((type) => [type.type_id, { group: type.group, name: type.name }]),
);
structureRevision = stored.revision;
// 저장하지 않고 나갔던 조작분이 있으면 그것으로 화면을 세운다(2026-08-29).
@@ -333,6 +363,8 @@ export function createStructuresBridge(deps: StructuresBridgeDeps) {
clearProjectedStations,
/** 알약 식별자에서 관 누가거리를 되읽는다(관이 아니면 null). */
pipeMarkChainage,
/** 알약 식별자의 누가거리(구조물·관 공통). B06 으로 넘길 선택값을 만들 때 쓴다. */
markChainage,
/** 사이드 목록이 바뀜 — 화면을 맞추고 서버 정본에 저장한다. */
applyStructures,
saveStructuresIfDirty,
+25 -1
View File
@@ -86,6 +86,8 @@ const SPEED_CHOICES: Record<string, Array<RoutePanelValues["designSpeed"]>> = {
interface PanelCallbacks {
onSolve: () => void;
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
/** [임시저장] — 현재 편집(계획선 델타·관로·비정규 측점·상단측)을 확정 전이 없이 저장. */
onTempSave: () => void;
/** [횡단 이동] — 저장 없이 B06 횡단 페이지로 이동만. */
@@ -302,6 +304,17 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
// 포인트 팔레트 + 임도 기준·옵션을 한 컨테이너로 병합하고 [최적 경로 계산]도 이 안에
// 둔다 — 경로 재탐색은 B04~B06 재계산을 부르는 무거운 작업이라 가끔만 쓴다
// (2026-08-08 사용자 지시).
// 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색
// (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다.
const plannedRoute = section("계획노선");
const plannedRouteBtn = document.createElement("button");
plannedRouteBtn.type = "button";
plannedRouteBtn.className = "b05-route__btn";
plannedRouteBtn.textContent = "계획노선 편집";
plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다.";
plannedRouteBtn.addEventListener("click", () => callbacks.onEditPlannedRoute());
plannedRoute.body.append(plannedRouteBtn);
const routeCalc = section("경로 계산 설정");
routeCalc.root.classList.add("is-collapsed");
const paletteGrid = document.createElement("div");
@@ -387,7 +400,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
// [최적 경로 계산] 입구는 2026-09-06 사용자 확정으로 화면에서 내렸다 — 평면만 보고
// 노선을 정하는 방식이라 실제 판단(평면·종단·횡단·유토곡선을 함께 봄)과 맞지 않는다.
// 코드·API 는 그대로 두고 버튼만 뺀다. 노선 변경은 계획노선 편집이 대신한다.
const solveButton = button("최적 경로 계산", callbacks.onSolve, "filled");
solveButton.hidden = true;
routeCalc.body.append(
algorithmField.root,
paved.wrapper,
@@ -599,7 +616,14 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
// 배치 순서: 구조물 배치 > 페이지 설정 > 경로 계산 설정(비활성) > 선택 포인트
// (평소 숨김) > 하단 고정 dock (2026-08-18 사용자 확정).
root.append(structures.root, sectionOptions.root, routeCalc.root, selected.root, actionDock);
root.append(
structures.root,
sectionOptions.root,
plannedRoute.root,
routeCalc.root,
selected.root,
actionDock,
);
// 컨테이너 제목 행 전체 클릭 시 본문을 접거나 편다(공용 collapsible). 내부 details 등 별도
// 접힘 항목은 손대지 않는다.
@@ -28,6 +28,10 @@ export interface AlignmentPolicy {
max_grade_pct: number;
curve_skip_delta_pct: number;
paved: boolean;
/** 법정 평면 최소곡선반지름(m) — 위반 표시 기준. 0이면 판정하지 않는다(옛 저장분). */
min_plan_radius_m?: number;
/** 배향곡선 하한(m) — 이보다 급하면 경고만 낸다(2026-09-06 사용자 확정). */
hairpin_min_radius_m?: number;
}
export interface AlignmentNode {
@@ -36,6 +36,11 @@ export interface BalanceBarParams {
minCoverViolations?: MinCoverViolation[];
/** 요약줄 **오른쪽 끝**에 붙이는 묶음(줌·Y레인지 조작구) — 2026-09-04. */
trailing?: HTMLElement;
/** 법정 평면 최소곡선반지름 위반(2026-09-06) — 자동 보정 없이 **경고만** 낸다. */
planCurve?: { count: number; worstRadiusM: number; limitM: number; hairpin: number } | null;
// 유토곡선 총괄값은 이 줄에서 **뺐다**(2026-09-06 사용자 지시) — 그래프 좌측 상단에
// 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로
// 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다.
/** [초기선 복원] — 편집·비정규 측점을 모두 지운다. */
onResetAll: () => void;
}
@@ -77,6 +82,19 @@ export function renderBalanceBar(params: BalanceBarParams): void {
// 절·성토 불균형은 2026-09-03 사용자 지시로 뺐다. 이 값은 **종단 기준**이었다 —
// 계획선과 지반선 사이 세로 면적(㎡)의 절·성토 비이지 실제 토량이 아니다. 의미 있는
// 균형은 횡단 단면적을 쌓아 부피로 내는 하단 유토곡선 요약(㎥)이고, 그쪽이 이미 있다.
// 유토곡선 총괄값은 2026-09-06 사용자 지시로 이 줄에서 **뺐다** — 그래프 좌측 상단에
// 겹쳐 뜨는 배지(`common_util_mass_haul_badge`)가 맡는다. B06 은 곡선을 아예 빼므로
// 같은 배지를 써야 두 화면이 같은 자리에서 같은 값을 보인다.
// 평면 곡선반경 — 법정 하한을 밑도는 측점이 있을 때만 적는다(별표2 Ⅰ.2.다.(1)).
// 프로그램은 값만 드러내고 고치지 않는다: 노선을 바꿀지는 사용자 판단이다.
const plan = params.planCurve;
if (plan && plan.count > 0) {
entries.push([
"곡선반경 부족",
`${plan.count} 곳 / 최소 ${plan.worstRadiusM.toFixed(1)} m`,
"over",
]);
}
// 필요한 곳이 없으면 적지 않는다 — "0곳"은 화면 폭만 먹는다.
if (curvesNeeded) entries.push(["종단곡선 필요", `${curvesNeeded}`, "over"]);
// 횡단배수 최소고 표시는 2026-09-02 사용자 지시로 삭제했다. 편집 차단(강제)은
@@ -91,6 +109,15 @@ export function renderBalanceBar(params: BalanceBarParams): void {
item.append(caption, document.createTextNode(value));
// 상한 초과 구간의 내역은 별도 경고 칩 대신 이 항목의 툴팁으로 붙인다 —
// 같은 사실을 두 번 적지 않는다(2026-08-19 재편).
if (label === "곡선반경 부족" && plan) {
item.title = [
`법정 하한 ${plan.limitM.toFixed(1)}m 미만인 측점 ${plan.count}`,
`가장 급한 곳 R=${plan.worstRadiusM.toFixed(1)}m`,
plan.hairpin > 0
? `그중 배향곡선 하한 미만 ${plan.hairpin}곳 — 경고만 낸다(자동 보정 없음)`
: "배향곡선 하한 미만은 없음",
].join("\n");
}
if (label === "최대 기울기" && violations.length) {
item.title = violations
.map(
+3 -2
View File
@@ -17,8 +17,9 @@ import {
type IrregularStation,
} from "./B05_Profile_UI_IrregularStations";
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
export const TABLE_ROW_COUNT = 12;
/** 6( S · · · · L · R).
* . 2026-09-06 12 . */
export const TABLE_ROW_COUNT = 6;
/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */
export const CELL_GAP_PX = 2;
+101 -24
View File
@@ -22,6 +22,7 @@
* scrollLeft를 .
* ========================================================================== */
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import type {
EarthworkConversion,
HaulEquipmentLimit,
@@ -31,6 +32,7 @@ import type {
} from "@util/common_util_mass_haul_types";
import type { MassHaulSeries } from "@util/common_util_mass_haul";
import type { HaulPlan } from "@util/common_util_mass_haul_balance";
import type { HaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
import type { MassHaulAxis } from "@util/common_util_mass_haul_view";
import {
applyLegendToggle,
@@ -39,12 +41,11 @@ import {
MASS_HAUL_DEFAULT_VISIBLE,
normalizeVisibleBasis,
} from "@util/common_util_mass_haul";
import { computeHaulPlan } from "@util/common_util_mass_haul_balance";
import { badgeValuesFrom, type MassHaulBadgeValues } from "@util/common_util_mass_haul_badge";
import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulSummary,
createMassHaulWindowState,
MASS_HAUL_MIN_HEIGHT,
scheduleMassHaulSettle,
@@ -57,14 +58,16 @@ import "./B05_Profile_UI_Style_MassHaul.css";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
const OPEN_KEY = "b05-route-profile-masshaul-open";
/* 펼침 상태·높이·범례는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
/** 오버레이 높이(px) 세션 키 — 메인 하단 패널 높이와 같은 수명. */
const OVERLAY_HEIGHT_KEY = "b05-route-profile-masshaul-height";
const OVERLAY_HEIGHT_KEY = stateKey("masshaul-height") ?? "";
/** 오버레이 높이 하한/기본값(px). 곡선 + 요약 막대가 읽히는 최소 크기다. */
const OVERLAY_MIN_HEIGHT = 160;
/** 유토곡선 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
export const MASSHAUL_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_DEFAULT_HEIGHT = 280;
/** 전체 맞춤(토량 분배) 보기의 우측 여백(px) — 마지막 점의 값 글자가 잘리지 않을 만큼만. */
const MASS_FIT_PAD_RIGHT = 12;
/** 오버레이가 패널 본문을 다 덮지 않게 남기는 상한 비율. */
const OVERLAY_MAX_RATIO = 0.8;
/** 리사이저가 높이를 담는 CSS 변수 — CSS 기본값(280px)은 OVERLAY_DEFAULT_HEIGHT와 같아야 한다. */
@@ -74,8 +77,10 @@ const HEIGHT_VAR = "--b05-masshaul-height";
* "같은 곡선인데 왜 다르게 보이나" (2026-08-03 ).
* B06 `_UI_Section_View` .
*/
const VISIBLE_KEY = "b06:masshaul-visible-v4";
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** **** (· )
* (2026-09-06 ). v4 v5
* . */
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE];
/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */
export interface RouteMassHaulContext {
@@ -162,7 +167,7 @@ export interface RouteMassHaulPanel {
function readVisible(): Set<string> {
try {
const raw = sessionStorage.getItem(VISIBLE_KEY);
const raw = readStateRaw("masshaul-visible");
if (!raw) return new Set(DEFAULT_VISIBLE);
const parsed = JSON.parse(raw) as unknown;
// 기준 키는 라디오라 항상 하나로 눌러 맞춘다(B06 읽기와 같은 규칙).
@@ -177,7 +182,19 @@ function readVisible(): Set<string> {
/**
* @param onChanged · · ( redraw).
*/
export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPanel {
/**
* ** ** (2026-09-06 ).
* .
*/
export type MassHaulSummaryValues = MassHaulBadgeValues;
export function createRouteMassHaulPanel(
onChanged: () => void,
/** 총괄값이 새로 나올 때마다 부른다 — 종단 상단줄이 받아 적는다. */
onSummary?: (summary: MassHaulSummaryValues | null) => void,
/** 유토 배분 선반입기 — **배분은 서버가 만든다**(2026-09-06). 없으면 분배는 안 그린다. */
haulPrefetch?: HaulPlanPrefetch,
): RouteMassHaulPanel {
// 손잡이는 다른 패널과 **같은 양식**의 표준 삼각형 손잡이 하나만 쓴다(2026-08-04 사용자
// 지시 — 예전 풀폭 바 + "유토곡선" 캡션은 다른 패널들과 모양이 달랐다). 무엇의 손잡이인지는
// 툴팁으로 밝힌다.
@@ -187,7 +204,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
handle.append(handleControl.root);
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
// 오버레이 뼈대 — 위 경계 리사이저 + 요약 막대 + 가로 스크롤러 + 범례.
// 오버레이 뼈대 — 위 경계 리사이저 + 안내 문구 자리 + 가로 스크롤러 + 범례.
// (요약 막대는 2026-09-06 종단 상단줄로 올라갔다.)
const overlay = document.createElement("div");
overlay.className = "b05-profile__masshaul-overlay";
const bar = document.createElement("div");
@@ -200,7 +218,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
// 높이를 복원하며 생성 중에 onResize → syncHandlePosition을 부르는데, `open`이 그 아래
// 있으면 TDZ ReferenceError로 B05 페이지 전체가 죽는다(2026-08-04 사용자 보고 —
// 저장된 높이가 있는 브라우저에서만 재현되는 이유).
let open = sessionStorage.getItem(OPEN_KEY) === "true";
let open = readStateRaw("masshaul-open") === "true";
let context: RouteMassHaulContext | null = null;
// 높이 조절 — 메인 하단 패널과 같은 공용 리사이저(위 경계, 위로 끌면 커짐, 세션 보존).
let resizeRedrawPending = false;
@@ -253,7 +271,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
function applyOpen(next: boolean): void {
open = next;
sessionStorage.setItem(OPEN_KEY, String(next));
writeStateRaw("masshaul-open", String(next));
handleControl.setOpen(next);
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
@@ -271,7 +289,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
function toggleSeries(key: string): void {
// 곡선 기준(횡단/종단)은 라디오 — 하나를 고르면 그 기준 그래프만 전체 영역에 보인다.
const visible = applyLegendToggle(readVisible(), key);
sessionStorage.setItem(VISIBLE_KEY, JSON.stringify([...visible]));
writeStateRaw("masshaul-visible", JSON.stringify([...visible]));
onChanged();
}
@@ -313,12 +331,48 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
let lastParams: RouteMassHaulDrawParams | null = null;
/** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */
function emitSummary(series: MassHaulSeries[]): void {
if (!onSummary) return;
if (!series.length) return onSummary(null);
// 총괄값 기준은 **횡단 고정**이다(2026-09-06 사용자: 종단 기준 곡선은 뒤에 없앨 예정).
// 범례에서 무엇을 켜 두었든 배지·상단줄이 흔들리지 않는다.
const picked = series.find((entry) => entry.basis === "cross") ?? series[0];
// 배분 선반입도 **여기서** 건다 — 접힘 여부와 무관하게 지나는 자리다(2026-09-06).
// 아래 `draw` 안에 두었더니 접힘 가드 뒤라 한 번도 안 불렸고, 그래서 「편집이 멈추면
// 조용히 받아 둔다」가 성립하지 않았다(펼친 뒤에야 첫 요청이 나갔다).
haulPrefetch?.schedule(picked.result);
onSummary(badgeValuesFrom(picked.result));
}
/** (2026-09-06).
* ** **
* (2026-09-06 실측: 계획고 130ms ). */
function reportSummary(params: RouteMassHaulDrawParams): void {
if (!onSummary) return;
if (!context || params.pendingRecalc) return;
emitSummary(
computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
["cross"],
),
);
}
function draw(params: RouteMassHaulDrawParams): void {
lastParams = params;
clearSelection = params.onClearSelection;
overlay.hidden = !open;
syncHandlePosition();
if (!open) return;
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
// 펼친 경우에는 아래에서 두 기준을 내므로 그 결과로 총괄값을 낸다(계산 한 번).
if (!open) {
reportSummary(params);
return;
}
if (!context) {
note("토량환산계수를 불러오지 못해 유토곡선을 계산할 수 없습니다.");
return;
@@ -345,6 +399,8 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
context.conversion,
context.naturalSpoilMinSlope,
);
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
emitSummary(series);
if (!series.length) {
note("횡단 설계가 아직 없어 유토곡선을 그릴 수 없습니다.");
return;
@@ -354,12 +410,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
const visible = readVisible();
// 토량 분배는 켜 둔 첫 곡선(정식 우선)에만 얹는다 — B06과 같은 규칙.
const banded = series.find((entry) => visible.has(entry.key));
// 배분은 **서버가 만든다** — 그 산식을 번들에 남기지 않으려고 옮겼다(2026-09-06).
// 요청은 `emitSummary` 가 걸어 두므로(접힘 여부와 무관) 펼칠 때는 이미 도착해 있다.
// 아직 못 받았으면 곡선만 그린다(분배 도형은 값이 오면 다시 그려진다).
const haulPlan: HaulPlan | null =
banded && visible.has(MASS_HAUL_BALANCE_KEY)
? computeHaulPlan(banded.result, context.haulLimits)
: null;
const summarySeries = banded ?? series[0];
bar.append(createMassHaulSummary(summarySeries, haulPlan));
banded && visible.has(MASS_HAUL_BALANCE_KEY) ? (haulPrefetch?.current() ?? null) : null;
// 요약 막대는 2026-09-06 사용자 지시로 뺐다 — 총괄값은 종단 상단줄이 늘 보여 주고,
// 이 자리는 곡선이 넓게 쓴다. `bar` 는 안내 문구 자리로만 남는다.
// 곡선 몫 = 스크롤러의 **안쪽 높이**(clientHeight = 가로 스크롤바 제외). 오버레이 전체
// 높이로 잡으면 스크롤바가 곡선 바닥(Y축 -200 라벨)을 덮는다(2026-08-04 사용자 보고).
@@ -370,17 +427,35 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
(overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12;
const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT);
// 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시).
// 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로
// 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다.
// 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼
// 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」).
const fitAll = haulPlan !== null;
const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx;
const chartAxis = fitAll
? {
...params.axis,
padLeft: params.axis.axisX ?? params.axis.padLeft,
padRight: MASS_FIT_PAD_RIGHT,
viewRange: undefined,
window: undefined,
hideStations: true,
}
: { ...params.axis, window: windowState };
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const chart = createMassHaulChart(
series,
visible,
params.stationSource,
{ ...params.axis, window: windowState },
chartAxis,
params.selectedStationId,
params.stationInterval,
params.widthPx,
widthPx,
chartHeight,
params.widthPx,
widthPx,
params.onSelectStation,
haulPlan,
(axis) => {
@@ -390,14 +465,16 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
// 종단 캔버스와 같은 폭의 래퍼 — sticky Y축 앵커의 기준(position: relative)이 된다.
const wrap = document.createElement("div");
wrap.className = "b05-profile__chart b05-profile__masshaul-chart";
wrap.style.width = `${params.widthPx}px`;
wrap.style.width = `${widthPx}px`;
wrap.style.height = `${chartHeight}px`;
wrap.append(chart);
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
scroll.replaceChildren(wrap);
// 종단 스크롤러가 이미 옆으로 가 있으면 새로 그린 곡선도 같은 자리에서 시작한다.
if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
if (fitAll) scroll.scrollLeft = 0;
else if (syncedMain) scroll.scrollLeft = syncedMain.scrollLeft;
legendLayer.append(
createMassHaulLegend(series, visible, toggleSeries, () => {
@@ -423,7 +500,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
commitHeight(px) {
const next = Math.round(px);
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
sessionStorage.setItem(OVERLAY_HEIGHT_KEY, String(next));
writeStateRaw("masshaul-height", String(next));
syncHandlePosition();
},
syncHandle: () => syncHandlePosition(),
+64 -8
View File
@@ -10,9 +10,12 @@
* `saveProfileAlignment()` .
* ========================================================================== */
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { createMassHaulBadge } from "@util/common_util_mass_haul_badge";
import { createHaulPlanPrefetch } from "./B05_Profile_Api_HaulPlan";
import { createDrainagePanel } from "./B05_Profile_UI_Drainage_Panel";
import type { StructureInstance, StructureType } from "./B05_Profile_Api_Structures";
import {
@@ -46,6 +49,7 @@ import { structureGroupMenuItems } from "./B05_Profile_UI_Profile_Structures";
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createRouteMassHaulPanel,
type MassHaulSummaryValues,
MASSHAUL_MIN_HEIGHT,
type RouteMassHaulContext,
} from "./B05_Profile_UI_Profile_MassHaul";
@@ -66,9 +70,8 @@ import "../B06_Section/B06_Section_UI_Style.css";
import "../B06_Section/B06_Section_UI_Style_Cross.css";
import "../B06_Section/B06_Section_UI_Style_Cross_Areas.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
/** 드래그로 조절한 하단 패널 높이(px) 보관 키 — 브라우저 세션 동안만 유지한다. */
const HEIGHT_KEY = "b05-route-profile-height";
/** 접힘·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
const HEIGHT_KEY = stateKey("profile-height") ?? "";
/* (--b05-table-height )
4:6 (TABLE_HEIGHT_KEY·CHART_HEIGHT_RATIO) (2026-08-05). */
const MIN_CHART_HEIGHT = 100;
@@ -83,8 +86,10 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9;
/** (ms).
* / ( 10)· .
* (2026-09-03) 25060ms
* (128) 7.5ms . */
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
* (128) 7.5ms .
* 6016ms(2026-09-06) 130ms 60ms
* . (16ms) . */
const CROSS_PREVIEW_DEBOUNCE_MS = 16;
/** (m).
* mm~cm . */
const STATION_SNAP_TOLERANCE_M = 0.1;
@@ -144,7 +149,29 @@ export function createRouteProfilePanel(
if (subPanelDragging) scheduleLightSync();
else draw();
};
const massHaul = createRouteMassHaulPanel(subPanelChanged);
/** ** ** (2026-09-06 ).
* , B06 . */
const massBadge = createMassHaulBadge();
// B05 는 그래프 위에 도구줄(상단줄)이 한 줄 있다 — 그 아래로 내려 겹치지 않게 한다.
massBadge.root.style.setProperty("--mass-haul-badge-top", "34px");
bodyWrap.append(massBadge.root);
let massHaulSummary: MassHaulSummaryValues | null = null;
// 유토 배분은 **서버가 만든다**(2026-09-06) — 편집이 멈추면 뒤에서 받아 두고, 도착하면
// 곡선을 다시 그린다. 그 산식이 브라우저 번들에 안 실리는 것이 이 구조의 목적이다.
const haulPrefetch = createHaulPlanPrefetch(
projectId,
() => routeId ?? undefined,
subPanelChanged,
);
const massHaul = createRouteMassHaulPanel(
subPanelChanged,
(summary) => {
const before = massHaulSummary;
massHaulSummary = summary;
if (before?.finalM3 !== summary?.finalM3) massBadge.set(summary);
},
haulPrefetch,
);
bodyWrap.append(massHaul.overlay, massHaul.handle);
// 오버레이의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
@@ -317,6 +344,34 @@ export function createRouteProfilePanel(
return samples[samples.length - 1][field];
}
/**
* (2 .2..(1), 2026-09-06).
* (`plan_radius_m`), .
* **** .
*/
function planCurveWarning(): {
count: number;
worstRadiusM: number;
limitM: number;
hairpin: number;
} | null {
const limit = alignment?.policy.min_plan_radius_m ?? 0;
const sections = detail?.cross_sections ?? [];
if (!limit || !sections.length) return null;
const hairpinLimit = alignment?.policy.hairpin_min_radius_m ?? 0;
let count = 0;
let hairpin = 0;
let worst = Number.POSITIVE_INFINITY;
sections.forEach((section) => {
const radius = section.plan_radius_m;
if (typeof radius !== "number" || !Number.isFinite(radius) || radius >= limit) return;
count += 1;
worst = Math.min(worst, radius);
if (hairpinLimit > 0 && radius < hairpinLimit) hairpin += 1;
});
return count ? { count, worstRadiusM: worst, limitM: limit, hairpin } : null;
}
function renderBalance(): void {
const minCoverViolations = findMinCoverViolations(
minCoverTargets,
@@ -324,6 +379,7 @@ export function createRouteProfilePanel(
(chainageM) => sampleAt(chainageM, "elevation_m"),
);
renderBalanceBar({
planCurve: planCurveWarning(),
minCoverViolations,
balanceBar,
tools: tools.render(),
@@ -590,14 +646,14 @@ export function createRouteProfilePanel(
function setCollapsed(collapsed: boolean): void {
root.classList.toggle("is-collapsed", collapsed);
panelHandle.setOpen(!collapsed);
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
writeStateRaw("profile-collapsed", String(collapsed));
if (!collapsed) requestAnimationFrame(draw);
}
panelHandle.root.addEventListener("click", () =>
setCollapsed(!root.classList.contains("is-collapsed")),
);
setCollapsed(sessionStorage.getItem(COLLAPSED_KEY) === "true");
setCollapsed(readStateRaw("profile-collapsed") === "true");
return {
root,
+9 -4
View File
@@ -8,6 +8,7 @@
* . ·· X .
* ========================================================================== */
import { dropStationsNear } from "./B05_Profile_Util_Station";
import {
createLongitudinalProfile,
longitudinalMinimumWidth,
@@ -237,9 +238,13 @@ export function renderProfile(ctx: ProfileRenderContext): void {
// 그대로 남는다(2026-08-02 사용자 보고).
const regular = graphData.stations.filter((station) => station.kind !== "irregular");
const injected = irregularGraphStations(irregularStations, maxChainageOf(longitudinal));
// 구조물 측점과 0.1m 안에서 겹치는 규칙 측점은 지운다 — 세로선·라벨이 두 겹으로
// 겹쳐 읽히지 않는다(2026-09-06). 남는 쪽은 구조물 측점이다.
const graphLongitudinal = {
...graphData,
stations: [...regular, ...injected].sort((a, b) => a.chainage_m - b.chainage_m),
stations: [...dropStationsNear(regular, injected), ...injected].sort(
(a, b) => a.chainage_m - b.chainage_m,
),
};
// 세로 자동 맞춤 — 지금 화면에 보이는 누가거리 구간만 보고 Y 창을 잡는다(2026-09-04
// 사용자 확정). 가로 스크롤 위치(`scrollLeft`)와 본문 폭이 곧 보이는 구간이다.
@@ -427,9 +432,9 @@ export function renderProfile(ctx: ProfileRenderContext): void {
padRight: LONG_PAD.right + originOffset,
// 축 선·눈금은 위 종단 그래프의 축과 같은 자리에 — 축이 두 개로 보이지 않게.
axisX: LONG_PAD.left,
// 범례·기준 버튼 오버레이(top 34px)가 곡선 위에 떠서 그만큼 상단 여유를 준다
// (2026-08-05 사용자 보고: 버튼과 커브 겹침).
padTop: 40,
// 범례·기준 버튼 오버레이(top 4px)가 곡선 위에 떠서 그 높이만큼만 여유를 준다
// (2026-08-05: 버튼과 커브 겹침 / 2026-09-06: 요약 막대가 빠져 상단이 너무 비었음).
padTop: 30,
// 유토곡선 Y 도 종단과 같은 창을 본다 — 전 구간 최대 토량으로 고정하면 확대해도
// 곡선이 납작하게 눌린다(2026-09-04 사용자 지시).
viewRange: { fromM: viewFromM, toM: viewToM },
+23 -50
View File
@@ -1,10 +1,11 @@
/* =============================================================================
* B05_Profile_UI_Profile_Table.ts
* ( 3 · 7 · 2 = 12).
* ( 1 · 3 · 2 = 6).
*
* .
* .
* - 구배: 연장 / / 3. .
* , ** **
* (2026-09-06 ). 6 L·H,
* , , , **B07 12 **( ).
* - 구배: 기울기(S) 1. .
* - 곡선: 곡선길이 / 2. R만 L = R × || .
*
* `L=` .
@@ -173,10 +174,9 @@ function createRow(className: string, label: string, unit: string): HTMLElement
return row;
}
/** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */
/** 1: (%). · 2026-09-06
* . */
const SEGMENT_ROWS: SegmentRowSpec[] = [
{ label: "구배 L", unit: "m 구간 연장", cell: (segment) => segment.length_m.toFixed(2) },
{ label: "구배 H", unit: "m 구간 고저차", cell: (segment) => segment.height_m.toFixed(2) },
{ label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) },
];
@@ -194,19 +194,9 @@ function buildStationRows(
display: { station: number; cumulative: number },
): StationRowSpec[] {
const stations = alignment.stations;
// 절토고·성토고·누가거리·거리 네 행은 2026-09-06 사용자 지시로 뺐다 — 절·성토는
// 그래프 음영과 유토곡선이, 누가거리는 측점 라벨이 이미 말해 준다.
return [
{
label: "절토고",
unit: "m",
modifier: "cut",
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
},
{
label: "성토고",
unit: "m",
modifier: "fill",
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
},
{
label: "계획고",
unit: "m",
@@ -214,16 +204,6 @@ function buildStationRows(
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
},
{ label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) },
{
label: "누가거리",
unit: "m",
cell: (index) => (stations[index].chainage_m + display.cumulative).toFixed(2),
},
{
label: "거리",
unit: "m 전 측점 대비",
cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""),
},
{
label: "측점",
unit: "측점번호+잔여거리",
@@ -233,7 +213,7 @@ function buildStationRows(
}
/**
* 3. ** ** .
* . ** ** .
*
* (BVC/EVC) ,
* "빈 셀" . ** R의
@@ -268,7 +248,7 @@ function buildSegmentRows(
const span = x(segment.to_m) - left;
const text = spec.cell(segment);
const node = element("span", "b05-profile-table__segment", "");
// 구배 3행(L·H·S)은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다
// 구배 은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다
// (2026-09-04 사용자 확정: 값 열을 접는 규칙에서 구배 행은 제외).
// 옛 규칙(2026-08-03: 구조물 구간은 고를 때만 값 표시)은 여기서 걷어냈고,
// 구조물 구간 표시(색·하이라이트)와 툴팁은 그대로 둔다.
@@ -499,27 +479,13 @@ function buildSelectedColumn(
const curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
// 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다.
const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined;
// 거리: 바로 앞 측점(계획선 측점)까지의 간격.
const previous = alignment.stations
.filter((row) => row.chainage_m < chainage - 1e-6)
.reduce<number | null>(
(acc, row) => (acc === null ? row.chainage_m : Math.max(acc, row.chainage_m)),
null,
);
const distance = previous !== null ? chainage - previous : null;
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변).
// 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변).
// 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
{ value: gradeCell((segment) => segment.length_m, 2), modifier: "" }, // 구배 L
{ value: gradeCell((segment) => segment.height_m, 2), modifier: "" }, // 구배 H
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
{ value: { text: cut !== null ? cut.toFixed(2) : "" }, modifier: "cut" }, // 절토고
{ value: { text: fill !== null ? fill.toFixed(2) : "" }, modifier: "fill" }, // 성토고
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
{ value: { text: (chainage + display.cumulative).toFixed(2) }, modifier: "" }, // 누가거리
{ value: { text: distance !== null ? distance.toFixed(2) : "" }, modifier: "" }, // 거리
{ value: { text: displayStationLabel(chainage, interval, display.station) }, modifier: "" }, // 측점
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
@@ -560,9 +526,16 @@ function buildSelectedColumn(
}
column.append(cell);
});
column.title = `${displayStationLabel(chainage, interval, display.station)} · ${(
// 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다.
const cutFillText =
cut !== null
? ` · 절토 ${cut.toFixed(2)}m`
: fill !== null
? ` · 성토 ${fill.toFixed(2)}m`
: "";
column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${(
chainage + display.cumulative
).toFixed(2)}m`;
).toFixed(2)}m${cutFillText}`;
return column;
}
@@ -10,18 +10,28 @@
* ). Profile_Panel의 draw() setTable로 .
* ========================================================================== */
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import { TABLE_ROW_COUNT } from "./B05_Profile_UI_Profile_Layout";
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
const OPEN_KEY = "b05:profile:table:open";
const HEIGHT_KEY = "b05:profile:table:height";
/* 펼침·높이는 화면 취향(①) — 키는 등록표(`b_page_state`)가 만든다. */
const HEIGHT_KEY = stateKey("table-height") ?? "";
const OVERLAY_MIN_HEIGHT = 140;
/** 테이블 오버레이 최소 높이 — 메인 패널 하한 계산(Profile_Panel)이 함께 쓴다. */
export const TABLE_OVERLAY_MIN_HEIGHT = OVERLAY_MIN_HEIGHT;
const OVERLAY_MAX_RATIO = 0.75;
/** CSS 기본 높이 — `var(--b05-table-height, 300px)`(_Style_MassHaul.css)와 같아야 한다. */
const OVERLAY_DEFAULT_HEIGHT = 300;
/** (px).
* 12 6
* (2026-09-06 : , ). */
const MAX_ROW_HEIGHT = 30;
/** 오버레이 높이 상한 = 6행 × 행 상한 + 가로 스크롤바·경계 몫. */
const OVERLAY_MAX_HEIGHT = TABLE_ROW_COUNT * MAX_ROW_HEIGHT + 24;
/** CSS 기본 높이 — `var(--b05-table-height, 204px)`(_Style_MassHaul.css)와 같아야 한다. */
const OVERLAY_DEFAULT_HEIGHT = OVERLAY_MAX_HEIGHT;
/** 어느 경로로 들어온 높이든 상한을 넘지 않게 한다(리사이저·패널 비례 연동 공용). */
const clampHeight = (px: number): number => Math.min(px, OVERLAY_MAX_HEIGHT);
/** 리사이저가 높이를 담는 CSS 변수. */
const HEIGHT_VAR = "--b05-table-height";
@@ -61,7 +71,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
// 상태 선언은 리사이저 생성보다 먼저 — 세션 높이 복원이 생성 중 onResize를 부른다
// (유토곡선 TDZ 크래시와 같은 함정, 2026-08-04 확인).
let open = sessionStorage.getItem(OPEN_KEY) !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
let open = readStateRaw("table-open") !== "false"; // 기본 펼침(기존 테이블 상시 표시 유지)
let bottomOffset = 0;
let resizeRedrawPending = false;
const resizer = createPanelResizer({
@@ -70,7 +80,8 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
cssVar: HEIGHT_VAR,
direction: -1,
min: OVERLAY_MIN_HEIGHT,
max: () => (overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO,
max: () =>
clampHeight((overlay.parentElement?.clientHeight ?? window.innerHeight) * OVERLAY_MAX_RATIO),
storageKey: HEIGHT_KEY,
onResize: () => {
syncHandlePosition();
@@ -99,7 +110,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
function applyOpen(next: boolean): void {
open = next;
sessionStorage.setItem(OPEN_KEY, String(next));
writeStateRaw("table-open", String(next));
handleControl.setOpen(next);
handleControl.root.title = next ? "테이블 접기" : "테이블 펼치기";
handle.classList.toggle("is-open", next);
@@ -131,12 +142,12 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
isOpen: () => open,
desiredHeight: () => {
const raw = parseFloat(overlay.style.getPropertyValue(HEIGHT_VAR));
return Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT;
return clampHeight(Number.isFinite(raw) && raw > 0 ? raw : OVERLAY_DEFAULT_HEIGHT);
},
commitHeight: (px) => {
const next = Math.round(px);
const next = Math.round(clampHeight(px));
overlay.style.setProperty(HEIGHT_VAR, `${next}px`);
sessionStorage.setItem(HEIGHT_KEY, String(next));
writeStateRaw("table-height", String(next));
syncHandlePosition();
},
contentHeight: () => Math.max(0, overlay.offsetHeight - 6),
@@ -12,6 +12,10 @@
* . .
*
* Shift+ .
*
* ** **(2026-09-06 )
* . · , ( ·
* · ) .
* ========================================================================== */
/** . 0.35 60fps 0.13
@@ -19,6 +23,14 @@
const EASE = 0.35;
/** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */
const SNAP_PX = 0.5;
/** 이만큼 넘게 움직여야 "끌기"로 본다(px). 그 아래는 클릭(측점 선택)으로 흘려보낸다. */
/** .
* 4px (2026-09-06 ). */
const DRAG_THRESHOLD_PX = 6;
/** .
* `pointerdown` . */
const DRAG_BLOCK_SELECTOR =
"button, input, select, textarea, a, .ui-resizer, .b05-profile-edit__btn";
/** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */
const BASE_FRAME_MS = 1000 / 60;
/** (ms). ms
@@ -83,4 +95,84 @@ export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
},
{ passive: false },
);
/*
* (0)· (1) . ,
* click을 "그림을 조금 움직였는데 측점 선택이 풀리는"
* .
*
* (`setPointerCapture`) ** ** (2026-09-06).
* click `<g>` ,
*
* .
* .
*/
let dragId: number | null = null;
let dragX = 0;
let dragY = 0;
let dragLeft = 0;
let dragTop = 0;
let dragMoved = false;
function endDrag(): void {
if (dragId === null) return;
if (scroller.hasPointerCapture(dragId)) scroller.releasePointerCapture(dragId);
dragId = null;
scroller.style.cursor = "";
scroller.style.userSelect = "";
// 끈 뒤의 click 한 번만 삼킨다(캡처 단계라 아래 리스너보다 먼저 잡는다).
if (dragMoved) {
const swallow = (event: MouseEvent): void => {
event.stopPropagation();
event.preventDefault();
};
scroller.addEventListener("click", swallow, { capture: true, once: true });
// 클릭이 안 오는 경우(휠 버튼 등)를 대비해 다음 프레임에 걷어낸다.
requestAnimationFrame(() =>
scroller.removeEventListener("click", swallow, { capture: true }),
);
}
dragMoved = false;
}
scroller.addEventListener("pointerdown", (event) => {
if (dragId !== null || (event.button !== 0 && event.button !== 1)) return;
if ((event.target as HTMLElement | null)?.closest(DRAG_BLOCK_SELECTOR)) return;
// 끌기가 시작되면 휠 관성 이동은 접는다 — 둘이 같은 scrollLeft를 다투면 튄다.
stop();
dragId = event.pointerId;
dragX = event.clientX;
dragY = event.clientY;
dragLeft = scroller.scrollLeft;
dragTop = scroller.scrollTop;
dragMoved = false;
// 여기서는 포인터를 잡지 않는다 — 위 주석 참고(클릭이 측점에 닿아야 한다).
// 휠 버튼은 브라우저 자동 스크롤이 뜨므로 막는다.
if (event.button === 1) event.preventDefault();
});
scroller.addEventListener("pointermove", (event) => {
if (dragId !== event.pointerId) return;
const dx = event.clientX - dragX;
const dy = event.clientY - dragY;
if (!dragMoved && Math.abs(dx) < DRAG_THRESHOLD_PX && Math.abs(dy) < DRAG_THRESHOLD_PX) return;
if (!dragMoved) {
dragMoved = true;
scroller.style.cursor = "grabbing";
// 이제부터 진짜 끌기다 — 창 밖으로 나가도 이어지도록 포인터를 잡는다.
scroller.setPointerCapture(event.pointerId);
// 끄는 동안 글자가 파랗게 잡히지 않게 한다. 포인터 잡기를 미룬 뒤로 브라우저가
// 기본 글자 선택을 시작했다(2026-09-06 사용자 지적). 이미 잡힌 것도 푼다.
scroller.style.userSelect = "none";
window.getSelection()?.removeAllRanges();
}
scroller.scrollLeft = dragLeft - dx;
scroller.scrollTop = dragTop - dy;
event.preventDefault();
});
scroller.addEventListener("pointerup", endDrag);
scroller.addEventListener("pointercancel", endDrag);
// 잡아 둔 포인터를 잃으면(창 전환 등) 끌기도 함께 끝낸다.
scroller.addEventListener("lostpointercapture", endDrag);
}
+692
View File
@@ -0,0 +1,692 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit.ts
* () () .
*
* (2026-09-06 ) [] ··
* ( 67 3).
* , ** **.
*
* (, ) (, ).
* [] ( ).
*
* (`B04_PreProcess_UI_MapRender`)
* , (m) .
* ========================================================================== */
import {
computeMapRect,
computeRouteView,
drawPreparedLayer,
createNormalizer,
metricToScreen,
prepareLayer,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { clearDrafts, clearResults } from "../A00_Common/b_page_state";
import { showToast } from "@ui/ui_template_elements";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan";
import type { RoutePlanCurve } from "./B05_Profile_Api_Replan";
import { dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
const NODE_HIT_PX = 9;
/** 노드 반지름(px). */
const NODE_R = 4;
/** 끌기로 볼 최소 이동(px) — 이보다 작으면 클릭으로 본다. */
const DRAG_THRESHOLD_PX = 3;
/** · (px) ** ** .
* 3.5px (2026-09-07 ). */
const CURVE_HANDLE_PX = 5;
type Vertex = [number, number];
/** 모달을 연다. [확인]·[예상노선으로]가 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
export async function openRouteEditModal(
projectId: string,
onApplied: () => void | Promise<void>,
): Promise<void> {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong> </strong>
<span class="b05-routeedit__hint">
, . .
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기"></button>
</div>
<div class="b05-routeedit__canvas-wrap"><canvas class="b05-routeedit__canvas"></canvas></div>
<div class="b05-routeedit__curve" hidden>
<span class="b05-routeedit__curve-label"> </span>
<label class="b05-routeedit__curve-field">R
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
<span>m</span>
</label>
<span class="b05-routeedit__curve-info"></span>
<button type="button" class="b05-routeedit__btn" data-act="curve-off"> </button>
<button type="button" class="b05-routeedit__btn" data-act="curve-on" hidden> </button>
<button type="button" class="b05-routeedit__btn" data-act="curve-auto"> </button>
</div>
<div class="b05-routeedit__foot">
<span class="b05-routeedit__status"> </span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> ()
<i class="is-planned"></i>
</span>
<button type="button" class="b05-routeedit__btn" data-act="reset"></button>
<button type="button" class="b05-routeedit__btn" data-act="cancel"></button>
<button type="button" class="b05-routeedit__btn is-primary" data-act="apply"></button>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
/** 그려 보이는 계획노선 — 원호가 섞인 폴리라인. **잡는 대상이 아니다.** */
let plannedLine: Vertex[] = [];
/** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */
let planned: Vertex[] = [];
/** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */
let nodeInfo: Array<{
radius_m: number | null;
inner_angle_deg: number | null;
violations: string[];
}> = [];
let minRadiusM = 0;
/** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */
let curveInfo: RoutePlanCurve[] = [];
/** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */
let curveOn: boolean[] = [];
let curveRadius: Array<number | null> = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
let meta: VWorldMeta | null = null;
let sheets: PreparedLayer[] = [];
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
let closed = false;
const close = (): void => {
closed = true;
window.removeEventListener("resize", resize);
overlay.remove();
};
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", close);
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
function resize(): void {
if (closed) return;
const wrap = canvas.parentElement!;
const ratio = window.devicePixelRatio || 1;
const width = wrap.clientWidth;
const height = wrap.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
draw();
}
window.addEventListener("resize", resize);
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
function toMetric(px: number, py: number): Vertex {
if (!meta) return [0, 0];
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1, y1] = metricToScreen(
meta,
view,
meta.x_min + meta.width_meters,
meta.y_min + meta.height_meters,
);
const sx = (x1 - x0) / (meta.width_meters || 1);
const sy = (y1 - y0) / (meta.height_meters || 1);
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void {
if (points.length < 2) return;
context.save();
context.setLineDash(dash);
context.strokeStyle = color;
context.lineWidth = width;
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.stroke();
context.restore();
}
function draw(): void {
if (closed) return;
const style = getComputedStyle(document.documentElement);
context.clearRect(0, 0, view.width, view.height);
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
context.fillRect(0, 0, view.width, view.height);
context.save();
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
context.lineWidth = 0.8;
for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot");
context.restore();
strokePolyline(
expected,
[6, 5],
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
1.6,
);
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
strokePolyline(
plannedLine.length ? plannedLine : planned,
[],
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.lineWidth = 1;
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0;
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
context.fill();
context.restore();
}
});
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
context.lineWidth = 2;
curveInfo.forEach((curve) => {
const on = curveOn[curve.node_first] !== false;
if (!on) return; // 곡선을 지운 자리에는 손잡이도 없다.
[curve.start, curve.end].forEach((point) => {
const [x, y] = toScreen([point[0], point[1]]);
context.beginPath();
context.rect(
x - CURVE_HANDLE_PX,
y - CURVE_HANDLE_PX,
CURVE_HANDLE_PX * 2,
CURVE_HANDLE_PX * 2,
);
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
context.fill();
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
context.stroke();
});
});
context.restore();
}
/** 화면 좌표에 가장 가까운 **곡선 손잡이**(시작·끝점). 없으면 null. */
function handleAt(px: number, py: number): { curve: number; end: "start" | "end" } | null {
let best: { curve: number; end: "start" | "end" } | null = null;
let bestDistance = NODE_HIT_PX + 2;
curveInfo.forEach((curve, index) => {
(["start", "end"] as const).forEach((which) => {
const point = which === "start" ? curve.start : curve.end;
const [x, y] = toScreen([point[0], point[1]]);
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) {
bestDistance = distance;
best = { curve: index, end: which };
}
});
});
return best;
}
/** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */
function dragHandleTo(
curveIndex: number,
which: "start" | "end",
to: Vertex,
): { apex: Vertex; radius: number } | null {
const curve = curveInfo[curveIndex];
if (!curve) return null;
const node = curve.node_first;
const before = planned[node - 1];
const after = planned[node + 1];
if (!before || !after) return null;
return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to);
}
/** 화면 좌표에 가장 가까운 노드. 문턱 밖이면 -1. */
function nodeAt(px: number, py: number): number {
let best = -1;
let bestDistance = NODE_HIT_PX;
planned.forEach((vertex, index) => {
const [x, y] = toScreen(vertex);
const distance = Math.hypot(x - px, y - py);
if (distance <= bestDistance) {
bestDistance = distance;
best = index;
}
});
return best;
}
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. */
function segmentAt(px: number, py: number): number {
let best = -1;
let bestDistance = 12;
for (let index = 0; index < planned.length - 1; index += 1) {
const [ax, ay] = toScreen(planned[index]);
const [bx, by] = toScreen(planned[index + 1]);
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance < bestDistance) {
bestDistance = distance;
best = index;
}
}
return best;
}
/** .
* [] R ( ). */
function markEdited(): void {
plannedLine = [];
nodeInfo = [];
curveInfo = []; // 손잡이 자리도 낡았다 — [확인] 때 서버가 다시 낸다.
}
/** 상태줄 꼬리 — 곡선 기준과 위반 수를 알린다. */
function curveHint(): string {
const off = curveOn.filter(
(on, index) => !on && index > 0 && index < planned.length - 1,
).length;
const forced = curveRadius.filter((value) => value !== null).length;
const edits = [off ? `곡선 지움 ${off}` : "", forced ? `R 지정 ${forced}` : ""]
.filter(Boolean)
.join(" · ");
if (!nodeInfo.length) {
const base = minRadiusM ? `곡선 기준 R ${minRadiusM}m — [확인] 때 반영` : "";
return edits ? `${base}${base ? " · " : ""}${edits}` : base;
}
const bad = nodeInfo.filter((node) => node.violations.length).length;
const curves = curveInfo.length || nodeInfo.filter((node) => node.radius_m !== null).length;
return (
`곡선 ${curves}곳(하한 R ${minRadiusM}m)` +
`${bad ? ` · 기준 미달 ${bad}` : ""}${edits ? ` · ${edits}` : ""}`
);
}
// ── 곡선 편집줄 — 고른 자리의 R 을 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시) ──
const curveBar = overlay.querySelector<HTMLElement>(".b05-routeedit__curve")!;
const curveLabel = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
const curveRadiusInput = curveBar.querySelector<HTMLInputElement>(
".b05-routeedit__curve-radius",
)!;
const curveInfoText = curveBar.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
const curveOffBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-off"]')!;
const curveOnBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-on"]')!;
const curveAutoBtn = curveBar.querySelector<HTMLButtonElement>('[data-act="curve-auto"]')!;
/** 고른 자리에 맞춰 편집줄을 다시 그린다. 끝점은 곡선이 없으므로 줄을 숨긴다. */
function syncCurveBar(): void {
const editable = picked > 0 && picked < planned.length - 1;
curveBar.hidden = !editable;
if (!editable) return;
const on = curveOn[picked] !== false;
curveLabel.textContent = `${picked + 1}번째 꺾임점`;
curveOffBtn.hidden = !on;
curveOnBtn.hidden = on;
curveRadiusInput.disabled = !on;
curveAutoBtn.disabled = !on || curveRadius[picked] === null;
const forced = curveRadius[picked];
const shown = forced ?? curveInfo.find((c) => c.node_first === picked)?.radius_m ?? null;
curveRadiusInput.value = shown === null ? "" : String(Math.round(shown * 10) / 10);
const inner = nodeInfo[picked]?.inner_angle_deg;
curveInfoText.textContent = on
? `${forced === null ? "자동" : "값 지정"}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}` +
` · 법정 하한 ${minRadiusM}m`
: "곡선 없음 — 직선이 그대로 꺾입니다";
}
curveRadiusInput.addEventListener("change", () => {
if (picked < 0) return;
const value = Number(curveRadiusInput.value);
curveRadius[picked] = Number.isFinite(value) && value > 0 ? value : null;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 반지름을 바꿨습니다. ${curveHint()}`;
draw();
});
curveOffBtn.addEventListener("click", () => {
if (picked < 0) return;
curveOn[picked] = false;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 곡선을 지웠습니다. ${curveHint()}`;
draw();
});
curveOnBtn.addEventListener("click", () => {
if (picked < 0) return;
curveOn[picked] = true;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 곡선을 넣었습니다. ${curveHint()}`;
draw();
});
curveAutoBtn.addEventListener("click", () => {
if (picked < 0) return;
curveRadius[picked] = null; // 서버가 예정노선에 맞춰 다시 고른다.
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 반지름을 자동으로 되돌렸습니다. ${curveHint()}`;
draw();
});
// ── 조작 — 노드 끌기 / 배경 끌기(팬) / 휠 확대 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
let dragNode = -1;
/** 끌고 있는 곡선 손잡이(시작·끝점). 노드 끌기보다 우선한다. */
let dragHandle: { curve: number; end: "start" | "end" } | null = null;
let panFrom: { x: number; y: number; offsetX: number; offsetY: number } | null = null;
canvas.addEventListener("pointerdown", (event) => {
if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
// 곡선 손잡이가 노드보다 먼저다 — 겹치면 손잡이를 잡는다(더 세밀한 조작).
dragHandle = handleAt(px, py);
dragNode = dragHandle ? -1 : nodeAt(px, py);
if (dragHandle) {
picked = curveInfo[dragHandle.curve]?.node_first ?? -1;
syncCurveBar();
draw();
} else if (dragNode >= 0) {
picked = dragNode; // 누른 자리를 고른다 — 편집줄이 그 곡선을 만진다.
syncCurveBar();
draw();
} else {
panFrom = { x: px, y: py, offsetX: view.offsetX, offsetY: view.offsetY };
}
canvas.setPointerCapture(event.pointerId);
});
canvas.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
if (dragHandle) {
// 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정).
const moved = dragHandleTo(dragHandle.curve, dragHandle.end, toMetric(px, py));
if (moved) {
const node = curveInfo[dragHandle.curve].node_first;
planned[node] = moved.apex;
curveRadius[node] = Math.round(moved.radius * 100) / 100;
curveOn[node] = true;
picked = node;
// 손잡이 자리도 따라 움직여야 계속 끌 수 있다 — 그림은 [확인] 때 서버가 다시 낸다.
const which = dragHandle.end === "start" ? "start" : "end";
curveInfo[dragHandle.curve] = {
...curveInfo[dragHandle.curve],
apex: [moved.apex[0], moved.apex[1]],
radius_m: moved.radius,
[which]: toMetric(px, py),
} as (typeof curveInfo)[number];
plannedLine = [];
nodeInfo = [];
syncCurveBar();
draw();
}
return;
}
if (dragNode >= 0) {
planned[dragNode] = toMetric(px, py);
markEdited(); // 폴리라인은 [확인] 때 서버가 다시 만든다 — 지금은 직선으로 미리 보인다.
draw();
return;
}
if (panFrom) {
if (Math.hypot(px - panFrom.x, py - panFrom.y) < DRAG_THRESHOLD_PX) return;
view = {
...view,
offsetX: panFrom.offsetX + (px - panFrom.x),
offsetY: panFrom.offsetY + (py - panFrom.y),
};
draw();
return;
}
canvas.style.cursor = handleAt(px, py) || nodeAt(px, py) >= 0 ? "grab" : "default";
});
const endDrag = (event: PointerEvent): void => {
if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId);
dragNode = -1;
dragHandle = null;
panFrom = null;
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("dblclick", (event) => {
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
// 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다.
curveOn.splice(segment + 1, 0, true);
curveRadius.splice(segment + 1, 0, null);
picked = segment + 1;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 새 노드를 넣었습니다(직선 추가). ${curveHint()}`;
draw();
});
canvas.addEventListener("contextmenu", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const index = nodeAt(event.clientX - rect.left, event.clientY - rect.top);
if (index < 0) return;
if (planned.length <= 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
planned.splice(index, 1);
curveOn.splice(index, 1);
curveRadius.splice(index, 1);
picked = -1;
markEdited();
syncCurveBar();
status.textContent = `노드 ${planned.length}개 — 노드를 지웠습니다(직선 삭제). ${curveHint()}`;
draw();
});
canvas.addEventListener(
"wheel",
(event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const px = event.clientX - rect.left;
const py = event.clientY - rect.top;
const factor = event.deltaY < 0 ? 1.2 : 1 / 1.2;
const nextScale = Math.max(1, Math.min(2000, view.scale * factor));
const ratio = nextScale / view.scale;
// 커서 아래 지점이 제자리에 남도록 이동량을 함께 고친다.
view = {
...view,
scale: nextScale,
offsetX: px - (px - view.offsetX) * ratio,
offsetY: py - (py - view.offsetY) * ratio,
};
draw();
},
{ passive: false },
);
async function runHeavy(label: string, task: () => Promise<unknown>): Promise<void> {
busy.hidden = false;
busy.querySelector("span")!.textContent =
`${label} — 배수유역부터 다시 계산 중입니다. 몇 분 걸립니다.`;
try {
await task();
// 노선이 바뀌면 세션 초안·조회 캐시는 옛 노선 것이라 남기지 않는다(PLAN 0-7 확정 5).
clearDrafts(projectId);
clearResults(projectId);
showToast("노선을 다시 계산했습니다.", "success");
close();
await onApplied();
} catch (error) {
busy.hidden = true;
showToast(error instanceof Error ? error.message : "노선 재계산에 실패했습니다.", "error");
}
}
overlay.querySelector('[data-act="apply"]')!.addEventListener("click", () => {
if (planned.length < 2) {
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
void runHeavy("계획노선 반영", () =>
replanRoute(
projectId,
planned.map(([x, y], index) => ({
x,
y,
curve: curveOn[index] !== false,
radius_m: curveRadius[index] ?? null,
})),
),
);
});
overlay.querySelector('[data-act="reset"]')!.addEventListener("click", () => {
void runHeavy("예상노선으로 되돌리기", () => resetRoutePlan(projectId));
});
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
try {
const [plan, drainage] = await Promise.all([
fetchRoutePlan(projectId),
fetchDrainageLayers(projectId, () => {}),
]);
if (closed) return;
expected = plan.expected as Vertex[];
plannedLine = (plan.planned as Vertex[]).map((vertex) => [vertex[0], vertex[1]]);
// 잡는 것은 **노드**다 — 폴리라인 정점에는 원호 위 점이 섞여 있어 편집 대상이 아니다
// (2026-09-06 사용자 지시: 노드를 제어해 계획노선을 고친다).
const nodes = plan.nodes ?? [];
minRadiusM = plan.min_radius_m ?? 0;
// 곡선 성분을 **편집할 수 있는 꼴로 펴 둔다**(2026-09-07).
//
// 서버는 처음 만들 때 이어진 꺾임을 한 곡선으로 묶는다 — 그 곡선의 교각점은 앞뒤 직선을
// 늘려 만나는 자리라 **원본 꺾임점 중 어느 것도 아니다**. 그런데 편집은 「꺾임점 하나 =
// 곡선 하나」로 표현되므로, 묶인 곡선을 그대로 두면 한 번만 손대도 그 묶음이 낱개로
// 흩어지고 **맞춰 둔 반지름이 전부 법정 하한으로 되돌아간다**(실측: R 12~199m → 전부 12m).
//
// 그래서 **묶인 구간을 그 교각점 하나로 갈아 끼운다** — 안쪽 꺾임점은 그 곡선이 대신하므로
// 뺀다. 앞뒤 직선과 반지름이 그대로라 **그려지는 선은 똑같고**, 이제 손대도 안 흩어진다.
const curves = plan.curves ?? [];
const replaced = new Map<number, (typeof curves)[number]>();
const dropped = new Set<number>();
curves.forEach((curve) => {
replaced.set(curve.node_first, curve);
for (let index = curve.node_first + 1; index <= curve.node_last; index += 1) {
dropped.add(index);
}
});
planned = [];
nodeInfo = [];
curveOn = [];
curveRadius = [];
curveInfo = [];
nodes.forEach((node, index) => {
if (dropped.has(index)) return;
const curve = replaced.get(index);
const at: Vertex = curve ? [curve.apex[0], curve.apex[1]] : [node.x, node.y];
const seat = planned.length;
planned.push(at);
nodeInfo.push({
radius_m: curve ? curve.radius_m : node.radius_m,
inner_angle_deg: curve ? curve.inner_angle_deg : node.inner_angle_deg,
violations: curve ? (curve.violations ?? []) : (node.violations ?? []),
});
curveOn.push(true);
// 서버가 고른 반지름을 **그대로 들고 간다** — 안 그러면 편집 한 번에 하한으로 눌린다.
curveRadius.push(curve ? Math.round(curve.radius_m * 100) / 100 : null);
if (curve) curveInfo.push({ ...curve, node_first: seat, node_last: seat });
});
picked = -1;
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
sheets = drainage.layers
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
const fitted = computeRouteView(
meta,
{
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
},
view.width,
view.height,
);
view = { ...view, ...fitted };
status.textContent =
`노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
curveHint();
draw();
} catch (error) {
status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다.";
}
}
@@ -0,0 +1,68 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Curve.ts
* (`B05_Profile_UI_RouteEdit.ts`)
* (700 , 2026-09-07). ·DOM .
*
* (`common_util/common_util_route_polyline.py`) ** **
* ·R , ·R .
* ****,
* `tmp/tests/test_route_polyline_handle_drag.py` .
* ========================================================================== */
export type Vertex = [number, number];
/** 두 **직선**(선분 아님)이 만나는 자리. 나란하면 null. */
export function intersect(a1: Vertex, a2: Vertex, b1: Vertex, b2: Vertex): Vertex | null {
const dx1 = a2[0] - a1[0];
const dy1 = a2[1] - a1[1];
const dx2 = b2[0] - b1[0];
const dy2 = b2[1] - b1[1];
const denominator = dx1 * dy2 - dy1 * dx2;
if (Math.abs(denominator) <= 1e-12) return null;
const t = ((b1[0] - a1[0]) * dy2 - (b1[1] - a1[1]) * dx2) / denominator;
return [a1[0] + dx1 * t, a1[1] + dy1 * t];
}
/** 세 점이 이루는 내각(도). 일직선이면 180. */
export function innerAngleDeg(before: Vertex, at: Vertex, after: Vertex): number {
const ax = before[0] - at[0];
const ay = before[1] - at[1];
const bx = after[0] - at[0];
const by = after[1] - at[1];
const la = Math.hypot(ax, ay);
const lb = Math.hypot(bx, by);
if (la <= 0 || lb <= 0) return 180;
const cosine = Math.max(-1, Math.min(1, (ax * bx + ay * by) / (la * lb)));
return (Math.acos(cosine) * 180) / Math.PI;
}
/** ** ** .
*
* (2026-09-07) · ** ** ****
* ( ).
*
* . ** ** .
* ** ** ,
* T = | | R = T / tan(/2) .
*/
export function dragHandleTo(
before: Vertex,
oldApex: Vertex,
after: Vertex,
which: "start" | "end",
to: Vertex,
): { apex: Vertex; radius: number } | null {
// 끈 쪽 직선만 돌아간다 — 반대쪽 직선은 옛 교각점을 지나는 그대로다.
const apex =
which === "start"
? intersect(before, to, oldApex, after) // 들어오는 직선이 끈 자리를 지나게 돌린다
: intersect(before, oldApex, to, after); // 나가는 직선을 돌린다
if (!apex) return null;
const inner = innerAngleDeg(before, apex, after);
const halfTan = Math.tan(((180 - inner) * Math.PI) / 360);
if (!(halfTan > 1e-9)) return null;
const tangent = Math.hypot(apex[0] - to[0], apex[1] - to[1]);
const radius = tangent / halfTan;
if (!(radius > 0) || !Number.isFinite(radius)) return null;
return { apex, radius };
}
@@ -9,6 +9,7 @@
* , .
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import type { StructurePick, StructurePickControls } from "./B05_Profile_UI_Viewer_Structure_Pick";
import type { StructuresSection } from "./B05_Profile_UI_Structures_Panel_Types";
import type { StructureInstance } from "./B05_Profile_Api_Structures";
@@ -19,21 +20,15 @@ export interface StructurePickHandoff {
key?: string;
}
const sessionKey = (projectId: string): string => `aislo:structure-pick:${projectId}`;
/* (`b_page_state`)
(2026-09-06 · ). */
/** 세션에 남긴다(선택 해제면 지운다). */
export function rememberStructurePick(projectId: string | null, pick: StructurePick | null): void {
if (!projectId) return;
try {
if (!pick) {
window.sessionStorage.removeItem(sessionKey(projectId));
return;
}
const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify(handoff));
} catch {
/* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */
}
if (!pick) return writeState("structure-pick", null, projectId);
const handoff: StructurePickHandoff = { at: pick.chainageM, key: pick.key };
writeState("structure-pick", handoff, projectId);
}
/** 화면에서 고른 것을 세션에 적는다 — B06 쪽 창구(측점만 고르면 부재키는 비운다). */
@@ -43,21 +38,20 @@ export function writeStructurePick(
key?: string,
): void {
if (!projectId) return;
try {
if (at === null) window.sessionStorage.removeItem(sessionKey(projectId));
else window.sessionStorage.setItem(sessionKey(projectId), JSON.stringify({ at, key }));
} catch {
/* 세션 저장소가 막힌 환경 — 화면 선택만 살고 넘김은 포기한다. */
}
if (at === null) return writeState("structure-pick", null, projectId);
// 부재키 없이 **측점만** 오는 길(사이드 목록·선택 동기화)이 3D 로 고른 부재키를 지웠다
// (2026-09-05 진단). 같은 측점이면 이미 적힌 부재키를 지킨다 — 3D 로 고른 직후 선택
// 동기화가 이 길을 타도 조정창이 그대로 열린다.
const kept = key ?? readStructurePick(projectId)?.key;
writeState("structure-pick", { at, key: kept }, projectId);
}
/** 세션에 남은 선택을 읽는다(지우지 않는다). 없으면 null. */
export function readStructurePick(projectId: string | null): StructurePickHandoff | null {
if (!projectId) return null;
try {
const raw = window.sessionStorage.getItem(sessionKey(projectId));
if (!raw) return null;
const value = JSON.parse(raw) as Partial<StructurePickHandoff>;
const value = readState<Partial<StructurePickHandoff>>("structure-pick", projectId);
if (!value) return null;
if (typeof value.at !== "number" || !Number.isFinite(value.at)) return null;
return { at: value.at, key: typeof value.key === "string" ? value.key : undefined };
} catch {
+19 -1
View File
@@ -28,6 +28,8 @@ export interface StructuresFormElements {
body: HTMLElement;
groupSelect: HTMLSelectElement;
typeSelect: HTMLSelectElement;
/** 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — `design_owner` 가 있을 때만 보인다. */
ownerNote: HTMLElement;
startFields: StationFields;
anchorFields: StationFields;
endFields: StationFields;
@@ -96,6 +98,13 @@ export function buildStructuresForm(options: {
typeRow.className = "b05-structure__grid";
typeRow.append(field("구조물군", groupSelect), field("종류", typeSelect));
// 「제원·수량은 다른 화면이 냅니다」 안내 한 줄 — 목록 항목은 [측점][이름]만 적는 규칙이라
// (2026-08-18 사용자 지시) 표시는 이 폼에 둔다. 측구(옆도랑)가 그 경우다(2026-09-07 사용자:
// 「두되 표시만 해줘」). 문구는 레지스트리 `design_owner` 값으로 만든다.
const ownerNote = document.createElement("p");
ownerNote.className = "b05-structure__owner-note";
ownerNote.hidden = true;
// 옵션 칸은 타입마다 다르므로 선택할 때마다 새로 그린다.
const optionRow = document.createElement("div");
optionRow.className = "b05-structure__grid";
@@ -129,13 +138,22 @@ export function buildStructuresForm(options: {
const positionDivider = document.createElement("hr");
positionDivider.className = "b05-structure__divider";
body.append(typeRow, positionRow, positionDivider, optionRow, facilityOptions.root, actions);
body.append(
typeRow,
ownerNote,
positionRow,
positionDivider,
optionRow,
facilityOptions.root,
actions,
);
return {
root,
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
+13 -8
View File
@@ -27,10 +27,11 @@ import { formatStation } from "./B05_Profile_Util_Station";
* (2026-08-19 ). */
const GRAB_SLACK_PX = 14;
/** 겹쳐 두 줄로 갈릴 때의 알약 높이(px) — 레인 높이를 정하는 값이다. */
const PILL_HEIGHT_PX = 24;
/** ( ) (px) 30%
* (2026-08-17 1: 32px는 ). */
const SOLO_PILL_HEIGHT_PX = 31;
/** (px) 2026-09-06 : . 24 18
* 54 42px . 10px (CSS). */
const PILL_HEIGHT_PX = 18;
/** 혼자 있는(가운데 한 줄) 알약 높이(px) — 겹칠 때보다 조금만 크게(옛 31 → 22). */
const SOLO_PILL_HEIGHT_PX = 22;
/** 두 줄 사이 간격(px). */
const ROW_GAP_PX = 2;
/** 레인 위아래 여백(px) — 그래프를 밀어내되 여유는 최소로. */
@@ -156,15 +157,19 @@ export function buildStructureLane(options: StructureMarksOptions): HTMLElement
? (STRUCTURE_LANE_HEIGHT_PX - SOLO_PILL_HEIGHT_PX) / 2
: LANE_PADDING_PX + row * (PILL_HEIGHT_PX + ROW_GAP_PX);
// 구간형은 고른 동안만 시~종점을 띠로 펼친다.
if (selected && structure.placement === "interval") {
// 구간형은 시~종점을 **늘 띠로** 펼친다(2026-09-07 사용자 지시 — B군을 넣어도 어디에
// 놓였는지 안 보였다). 예전에는 고른 동안만 폈는데, 측구·맹암거처럼 길게 이어지는
// 시설은 「어디부터 어디까지인가」가 곧 그 시설의 내용이라 늘 보여야 한다.
// 고른 것은 진하게, 나머지는 옅게 — 레인 높이(42px)는 그대로다.
if (structure.placement === "interval") {
const band = document.createElement("div");
band.className = "b05-structure__band";
band.className = `b05-structure__band${selected ? " is-selected" : ""}`;
const startPx = options.x(structure.start_m ?? 0);
const endPx = options.x(structure.end_m ?? 0);
band.style.left = `${Math.min(startPx, endPx)}px`;
band.style.width = `${Math.max(Math.abs(endPx - startPx), 2)}px`;
band.style.top = `${top + height / 2 - 1}px`;
// 알약 세로 가운데에 맞춘다 — 띠 두께가 골랐을 때만 4px 라 반값이 다르다.
band.style.top = `${top + height / 2 - (selected ? 2 : 1.5)}px`;
band.style.background = type?.style?.color ?? "#888";
lane.append(band);
}
+15 -1
View File
@@ -60,6 +60,7 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
body,
groupSelect,
typeSelect,
ownerNote,
startFields,
anchorFields,
endFields,
@@ -208,6 +209,12 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
startFields.wrap.hidden = !isInterval;
endFields.wrap.hidden = !isInterval;
primary.disabled = !type;
// 제원·수량 주인이 다른 화면인 타입은 그 사실을 폼에 적는다 — 목록에서 안 보이면
// 「측구가 왜 없지」로 헤매고, 그렇다고 수량에 넣으면 이중 계상이다(2026-09-07 사용자).
ownerNote.textContent = type?.design_owner
? `${type.design_owner}에서 관리 — 여기서 넣어도 제원·수량은 그쪽 값을 씁니다.`
: "";
ownerNote.hidden = !type?.design_owner;
anchorFields.wrap.querySelector("span")!.textContent = isInterval
? "기준 측점 (비우면 시작)"
: "기준 측점";
@@ -322,11 +329,18 @@ export function createStructuresSection(callbacks: StructuresCallbacks): Structu
input.value = String(preset ?? "");
}
const label = option.unit ? `${option.label} (${option.unit})` : option.label;
// `enabled:false` 는 칸을 남기고 잠그기만 한다 — 값은 기본값이 그대로 저장된다.
const locked = option.enabled === false;
if (locked) {
input.disabled = true;
input.classList.add("is-locked");
input.title = "지금은 고를 수 없는 항목입니다.";
}
optionRow.append(field(label, input));
input.addEventListener("change", () => liveCommit());
optionInputs.push({
key: option.key,
required: !!option.required,
required: !locked && !!option.required,
input,
read: () => (option.input === "number" ? Number(input.value) || 0 : input.value),
isEmpty: () => input.value.trim() === "",
+3 -1
View File
@@ -509,7 +509,9 @@
flex-wrap: nowrap;
gap: var(--spacing-16);
align-items: center;
height: 24px;
/* 유토곡선 총괄값이 줄로 올라와(2026-09-06) 항목이 늘었다 픽셀 높여 글자가
위아래로 눌리지 않게 한다. */
height: 28px;
padding: 0 var(--spacing-8);
overflow-x: auto;
overflow-y: hidden;
+10 -4
View File
@@ -70,6 +70,12 @@
top: -3px;
}
/* 요약 막대는 2026-09-06 종단 상단줄로 올라갔다 자리는 안내 문구가 있을 때만 쓴다.
비어 있으면 테두리 줄도 남기지 않고 접어 곡선이 그만큼 넓게 쓴다. */
.b05-profile__masshaul-bar:empty {
display: none;
}
.b05-profile__masshaul-bar {
display: flex;
flex: none;
@@ -95,7 +101,7 @@
공용 범례(.b06-masshaul__legend) 이미 absolute + right 배치라 top만 래퍼가 정한다. */
.b05-profile__masshaul-legend {
position: absolute;
top: 34px;
top: 4px;
right: 0;
left: 0;
z-index: 3;
@@ -114,10 +120,10 @@
/* 재계산 대기 표시 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에
** 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다
(2026-09-04 사용자 보고). 범례(top: 34px) 아래 줄에 앉혀 서로 가리지 않는다. */
(2026-09-04 사용자 보고). 범례(top: 4px) 아래 줄에 앉혀 서로 가리지 않는다. */
.b05-profile__masshaul-pending {
position: absolute;
top: 62px;
top: 32px;
right: var(--spacing-8);
z-index: 4;
padding: 2px var(--spacing-8);
@@ -167,7 +173,7 @@
z-index: 5;
display: flex;
flex-direction: column;
height: var(--b05-table-height, 300px);
height: var(--b05-table-height, 204px);
border-top: 1px solid var(--color-border);
background: var(--color-surface-raised);
box-shadow: 0 -4px 12px rgb(0 0 0 / 18%);
@@ -0,0 +1,183 @@
/* 계획노선 편집 모달 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다
(2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다. */
.b05-routeedit {
position: fixed;
inset: 0;
z-index: var(--z-modal, 1000);
display: flex;
align-items: center;
justify-content: center;
background: rgb(0 0 0 / 55%);
}
.b05-routeedit__box {
position: relative;
display: flex;
flex-direction: column;
width: min(1200px, 94vw);
height: min(820px, 92vh);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-16, 12px);
background: var(--color-surface-raised);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
}
.b05-routeedit__head {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-12) var(--spacing-16);
border-bottom: 1px solid var(--color-border);
}
.b05-routeedit__hint {
flex: 1 1 auto;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__close {
flex: none;
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 16px;
cursor: pointer;
}
.b05-routeedit__canvas-wrap {
position: relative;
flex: 1 1 auto;
min-height: 0;
background: var(--color-surface);
}
.b05-routeedit__canvas {
display: block;
width: 100%;
height: 100%;
touch-action: none;
}
.b05-routeedit__foot {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-12) var(--spacing-16);
border-top: 1px solid var(--color-border);
}
.b05-routeedit__status {
flex: 1 1 auto;
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__legend {
display: inline-flex;
align-items: center;
gap: var(--spacing-8);
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__legend i {
display: inline-block;
width: 22px;
height: 0;
margin-right: 2px;
vertical-align: middle;
}
.b05-routeedit__legend i.is-expected {
border-top: 2px dashed var(--color-text-secondary, #9ca3af);
}
.b05-routeedit__legend i.is-planned {
border-top: 2px solid var(--map-route, #f97316);
}
.b05-routeedit__btn {
flex: none;
padding: var(--spacing-8) var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
color: var(--color-text-body);
cursor: pointer;
}
.b05-routeedit__btn.is-primary {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
.b05-routeedit__busy {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
padding: var(--spacing-16);
background: rgb(0 0 0 / 55%);
color: #fff;
text-align: center;
}
/* 곡선 편집줄 고른 꺾임점의 R 바꾸고, 곡선을 지우고 넣는다(2026-09-07 사용자 지시).
바닥 단추줄과 같은 결로 두되, 고른 것이 없으면 통째로 숨는다. */
.b05-routeedit__curve {
/* 바닥줄과 같이 **줄어들지 않게** 둔다 그러면 캔버스(flex:1) 자리를 먹고
줄이 눌려 단추가 캔버스 밑에 깔린다(2026-09-07 실화면에서 클릭이 가로채였음). */
flex: none;
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-12);
border-top: 1px solid var(--color-border, #e5e7eb);
background: var(--color-surface-2, #f9fafb);
font-size: var(--font-size-13, 13px);
}
.b05-routeedit__curve-label {
font-weight: 600;
white-space: nowrap;
}
.b05-routeedit__curve-field {
display: inline-flex;
align-items: center;
gap: 4px;
white-space: nowrap;
}
.b05-routeedit__curve-radius {
width: 5.5rem;
padding: 2px 6px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: var(--radius-4, 4px);
font: inherit;
text-align: right;
}
.b05-routeedit__curve-radius:disabled {
background: var(--color-surface-3, #f3f4f6);
color: var(--color-text-muted, #9ca3af);
}
.b05-routeedit__curve-info {
flex: 1 1 auto;
color: var(--color-text-muted, #6b7280);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
+34 -13
View File
@@ -41,17 +41,18 @@
pointer-events: none;
}
/* 알약 — 종류를 3자 이하 이름으로 보여 준다. 높이는 구 서클마크와 같은 20px. */
/* 알약 종류를 3자 이하 이름으로 보여 준다. 2026-09-06 사용자 지시로 낮췄다
(높이 24 18, 글자 11 10) 그래프가 그만큼 세로를 쓴다. */
.b05-structure__mark {
position: absolute;
transform: translateX(-50%);
height: 24px;
min-width: 24px;
padding: 0 9px;
border-radius: 12px;
height: 18px;
min-width: 18px;
padding: 0 7px;
border-radius: 9px;
border: 1.5px solid currentColor;
background: var(--color-surface, #fff);
font-size: 11px;
font-size: 10px;
font-weight: 700;
line-height: 1;
white-space: nowrap;
@@ -62,13 +63,13 @@
justify-content: center;
}
/* 혼자 있는 알약 — 겹치는 알약보다 30%만 키운다(2026-08-17 사용자 지시 1). */
/* 혼자 있는 알약 — 겹치는 알약보다 조금만 키운다(옛 31px 는 과했다, 2026-09-06). */
.b05-structure__mark.is-solo {
height: 31px;
min-width: 31px;
padding: 0 12px;
border-radius: 16px;
font-size: 12px;
height: 22px;
min-width: 22px;
padding: 0 9px;
border-radius: 11px;
font-size: 11px;
}
.b05-structure__mark:hover {
@@ -85,12 +86,21 @@
}
/* 구간 띠 — 고른 동안에만 시~종점을 펼쳐 보여 준다. */
/* 구간형 시설이 놓인 자리 보인다(2026-09-07). 고르지 않은 것은 옅은 실선으로 깔려
있고, 고른 것만 두껍고 진해진다. 길게 이어지는 시설(측구·맹암거) 많아도 알약을 가리지
않도록 얇게 둔다. */
.b05-structure__band {
position: absolute;
height: 3px;
border-radius: 1.5px;
opacity: 0.45;
pointer-events: none;
}
.b05-structure__band.is-selected {
height: 4px;
border-radius: 2px;
opacity: 0.55;
pointer-events: none;
}
/* 값 벌룬 — 유토곡선 벌룬과 같은 결(테두리 도형 + 여러 줄 텍스트). */
@@ -193,6 +203,17 @@
border-top: 1px solid var(--color-border, #3a3f4a);
}
/* 제원·수량은 다른 화면이 냅니다 안내 경고가 아니라 안내라 색은 흐리게,
* 왼쪽 하나로만 구분한다(2026-09-07). */
.b05-structure__owner-note {
margin: 0;
padding: 4px 0 4px 8px;
border-left: 2px solid var(--color-border, #3a3f4a);
color: var(--color-text-muted, #9aa1ad);
font-size: var(--text-caption, 12px);
line-height: 1.5;
}
/* 시작·기준·종료 측점 = 3행. 행은 [라벨][측점][+거리] 가로 배치
* (2026-08-17 사용자 지시 2). */
.b05-structure__position-row {
+87 -14
View File
@@ -37,6 +37,31 @@ type ViewKind = "iso" | "top" | "front" | "side";
const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa;
const DARK_VIEWER_BACKGROUND = 0x251f38;
/**
* ** **.
*
* B05 `renderB05Route`
* . 8MB ,
* ** 14.4**(2026-09-06 3).
* .
*
* (GPU ).
* `disposeObject` .
*/
const cachedTerrain: {
key: string | null;
object: THREE.Object3D | null;
/** 높이 격자 색인 — 만드는 값이 O(삼각형)이라 지형과 한 벌로 쥔다. */
heightIndex: TerrainHeightIndex | null;
} = { key: null, object: null, heightIndex: null };
declare global {
interface Window {
/** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */
__surfaceTiming?: Array<{ step: string; ms: number }>;
}
}
/** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */
function countTriangles(root: THREE.Object3D | null): number {
let total = 0;
@@ -184,6 +209,9 @@ export function createRouteViewer(): RouteViewer {
let terrain: THREE.Object3D | null = null;
// 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23).
//
// 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시
// 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다).
let corridorBuild: CorridorBuildResult | null = null;
let corridorGroup: THREE.Group | null = null;
let clippedTerrain: THREE.Object3D | null = null;
@@ -205,6 +233,17 @@ export function createRouteViewer(): RouteViewer {
function terrainElevation(x: number, y: number): number | null {
if (!terrain || !bounds) return null;
const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds);
// 격자 색인으로 찾는다 — Raycaster 는 한 번 쏠 때마다 삼각형을 전부 훑는다.
// 마커·측점선이 점마다 부르는 자리라, B05 진입에서 **13.5초 중 11.6초**가 여기였다
// (2026-09-06 CPU 프로파일: getVertexPosition·intersectTriangle·checkGeometryIntersection).
// 같은 교훈을 2026-08-23 비탈 투영에서 이미 한 번 겪어 색인을 만들어 뒀다.
const index = ensureHeightIndex();
if (index) {
const height = index.heightAt(origin.x, origin.z);
return height === null
? null
: sceneToModel(new THREE.Vector3(origin.x, height, origin.z), bounds).z;
}
const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0));
const hit = raycaster.intersectObject(terrain, true)[0];
return hit ? sceneToModel(hit.point, bounds).z : null;
@@ -476,7 +515,12 @@ export function createRouteViewer(): RouteViewer {
function ensureHeightIndex(): TerrainHeightIndex | null {
if (heightIndex) return heightIndex;
if (!terrain) return null;
heightIndex = new TerrainHeightIndex(terrain);
// 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다.
heightIndex =
cachedTerrain.object === terrain && cachedTerrain.heightIndex
? cachedTerrain.heightIndex
: new TerrainHeightIndex(terrain);
if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex;
return heightIndex.triangleCount > 0 ? heightIndex : null;
}
@@ -609,38 +653,67 @@ export function createRouteViewer(): RouteViewer {
markers,
structurePick,
async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) {
const step = (label: string, from: number): void => {
timing.push({ step: label, ms: Math.round(performance.now() - from) });
};
const timing: Array<{ step: string; ms: number }> = [];
const started = performance.now();
bounds = nextBounds;
current = { projectId, modelId, smooth, interval };
if (terrain) {
scene.remove(terrain);
disposeObject(terrain);
if (terrain !== cachedTerrain.object) disposeObject(terrain);
}
heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다.
bandSplit?.dispose();
bandSplit = null;
const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`;
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") {
const geometry = new PLYLoader().parse(buffer);
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
} else {
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
const key = `${projectId}:${modelId}:${method}:${smooth}`;
if (cachedTerrain.key === key && cachedTerrain.object) {
// 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06).
terrain = cachedTerrain.object;
step("reuse", started);
} else {
const fetched = performance.now();
// 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다).
const buffer = await fetchCachedBytes(projectId, url);
step("fetch", fetched);
const parsed = performance.now();
terrain = await new Promise<THREE.Object3D>((resolve, reject) => {
if (method === "meshfree") {
const geometry = new PLYLoader().parse(buffer);
resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 })));
} else {
new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject);
}
});
terrain.traverse((child) => {
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
});
step("parse", parsed);
// 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다.
if (cachedTerrain.object && cachedTerrain.object !== terrain) {
disposeObject(cachedTerrain.object);
}
});
terrain.traverse((child) => {
if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide;
});
cachedTerrain.key = key;
cachedTerrain.object = terrain;
cachedTerrain.heightIndex = null; // 새 지형이면 색인도 새로.
}
scene.add(terrain);
// 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다.
applySurfaceGrayscale();
// 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단
// 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다.
if (corridorBuild) setCorridor(corridorBuild);
const fitted = performance.now();
fit("top");
markers.renderMarkers();
step("fit+markers", fitted);
const contoured = performance.now();
await reloadContours(interval);
step("contours", contoured);
step("total", started);
window.__surfaceTiming = timing;
// 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가
// 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다.
status.textContent = "";
+21
View File
@@ -52,3 +52,24 @@ export function parseStationText(text: string, intervalM: number): number | null
const direct = Number(trimmed);
return Number.isFinite(direct) && direct >= 0 ? direct : null;
}
/** 구조물 측점과 겹쳤다고 볼 거리(m) — 이보다 가까우면 규칙 측점을 지운다. */
export const STATION_MERGE_TOLERANCE_M = 0.1;
/**
* ** **(2026-09-06).
*
* . (20m) 0.1m
* · .
*/
export function dropStationsNear<T extends { chainage_m: number }>(
stations: readonly T[],
anchors: ReadonlyArray<{ chainage_m: number }>,
toleranceM: number = STATION_MERGE_TOLERANCE_M,
): T[] {
if (anchors.length === 0) return [...stations];
return stations.filter(
(station) =>
!anchors.some((anchor) => Math.abs(anchor.chainage_m - station.chainage_m) <= toleranceM),
);
}
+40 -3
View File
@@ -16,6 +16,7 @@
* .
* ========================================================================== */
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import type {
CrossDesign,
@@ -58,10 +59,43 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
}
/** 최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 조회한다. */
/**
* (2026-09-06 ). `clearSectionContextCache` . */
export async function fetchSectionContext(projectId: string): Promise<SectionContextResponse> {
return requestJson<SectionContextResponse>(`/projects/${projectId}/sections/context`, {
method: "GET",
});
const cached = readState<SectionContextResponse>("section-context", projectId);
if (cached) return seedStandardCross(projectId, cached);
const fresh = await requestJson<SectionContextResponse>(
`/projects/${projectId}/sections/context`,
{ method: "GET" },
);
writeState("section-context", fresh, projectId);
return seedStandardCross(projectId, fresh);
}
/**
* ** ** (2026-09-07).
*
* `세션 ?? config 기본값` ,
* . ** ** config ,
* ( route 169 **5%**·
* **0.9m**, config **3%**·**0.69m**).
*
* (B05·B06) .
* ** ** .
*/
function seedStandardCross(
projectId: string,
context: SectionContextResponse,
): SectionContextResponse {
const stored = context.stored_standard_cross_section;
if (stored && readState<unknown>("std-cross", projectId) === null) {
writeState("std-cross", stored, projectId);
}
return context;
}
export function clearSectionContextCache(projectId: string): void {
clearState("section-context", projectId);
}
/** 경로의 종단면 요약을 조회한다. */
@@ -219,6 +253,8 @@ export async function previewCrossDesigns(
fullDesigns?: boolean;
/** 측점별 암 경계 오프셋 세션값(chainage 키 → m). DB 저장분보다 우선한다. */
rockBoundaryOffsets?: Record<string, number>;
/** 측점별 소단 제원(chainage 키 → 폭·간격·기울기). 값이 없는 측점은 소단 없음. */
berms?: Record<string, { width_m: number; interval_m: number; slope_deg: number }>;
},
): Promise<CrossDesignPreviewResponse> {
return requestJson<CrossDesignPreviewResponse>(
@@ -230,6 +266,7 @@ export async function previewCrossDesigns(
standard_cross_section: standardCrossSection ?? null,
full_designs: options?.fullDesigns ?? false,
rock_boundary_offsets: options?.rockBoundaryOffsets ?? null,
berms: options?.berms ?? null,
}),
},
);
+42 -1
View File
@@ -45,7 +45,14 @@ export interface StandardCrossGroup {
/** 표준 횡단면 설정 패널 그룹 키. */
export type StandardCrossKey = "soil" | "rock" | "paved";
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup>;
export type StandardCrossSection = Record<StandardCrossKey, StandardCrossGroup> & {
/** 절토 법정 기울기 판정에 쓸 별표2 줄 — 지반유형(리핑암·발파암) → `soft_rock`/`hard_rock`.
*
* 2 **·** **·**
* · . ** **
* (2026-09-07 ). . */
cut_slope_class?: Record<string, string>;
};
// 지반유형·토량환산계수·운반장비 한계거리는 B05 계획 유토곡선과 공유하므로 정의처를
// `@util/common_util_mass_haul_types` 한 곳에 두고 여기서는 재수출만 한다(사본 금지).
@@ -74,6 +81,16 @@ export interface SectionContextResponse {
defaults: SectionOptionDefaults;
/** 표준 횡단면 설정 패널(토사/암/포장) 기본값. */
standard_cross_section: StandardCrossSection;
/** **** ( ). null.
* `standard_cross_section` config (2026-09-07). */
stored_standard_cross_section?: StandardCrossSection | null;
/** (2) `hard_rock`·`soft_rock`·`soil` [, ].
* ** ** (2026-09-07). */
cut_slope_limits?: Record<string, number[]>;
/** 지반유형(리핑암·발파암) → 별표2 줄. **사용자가 바꿀 수 있는 설정값**의 기본값이다. */
cut_slope_class_default?: Record<string, string>;
/** 절토 기울기 규정이 없는 등급(작업임도). */
cut_slope_exempt_grades?: string[];
/** 암 경계선 기본 오프셋(m)과 상/하 제어 스텝(m). */
rock_boundary_default_offset_m: number;
rock_boundary_step_m: number;
@@ -116,6 +133,12 @@ export interface SectionStation {
structure?: string;
center_z: number | null;
azimuth_deg: number | null;
/** 평면 곡선반경(m). 직선이거나 잴 수 없으면 null — 곡선부 확폭·법정 최소반경 판정용. */
plan_radius_m?: number | null;
/** 곡선 바깥쪽 — 확폭이 붙는 쪽(2026-09-06 사용자 확정). 직선이면 null. */
curve_outer_side?: "left" | "right" | null;
/** 확폭량(m) — 표값에 곡선 앞뒤 테이퍼를 얹은 값. 설계는 이 값을 우선 쓴다. */
curve_widening_m?: number | null;
center_x: number;
center_y: number;
/** 횡단 기준 등고가 높은 쪽(측구 설계 기본 방향). B05 solve 자동 판정 + 사용자 변경. */
@@ -399,6 +422,11 @@ export interface CrossDesign {
fill_slope_ratio: number;
roadbed_width_m: number;
carriageway_width_m: number;
/** 규격 차도 폭(확폭 전, m) — 확폭 라벨·수량이 둘을 나눠 쓴다(2026-09-06). */
carriageway_standard_width_m?: number;
/** 곡선부 확폭(m) — 붙은 쪽만 값이 있다. */
widening_left_m?: number;
widening_right_m?: number;
cross_slope_pct: number;
ditch:
| { type: "standard"; top_width_m: number; bottom_width_m: number; depth_m: number }
@@ -495,4 +523,17 @@ export interface CrossSectionPatch {
/** 연동 해제(측점별)·종단경사 반영(전체 공통) — 2026-08-24 사용자. */
revet_link_detached?: boolean;
revet_follow_grade?: boolean;
/** (2026-09-06) ·.
* []·[] patch . */
ground_type?: string;
section_mode?: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
two_stage_slope?: boolean;
/** 절·성토 면적(㎡) — 브라우저가 계산해 보낸다(2026-09-06 사용자 확정). */
cut_area_m2?: number;
fill_area_m2?: number;
cut_soil_area_m2?: number;
cut_rock_area_m2?: number;
}
@@ -0,0 +1,67 @@
/* =============================================================================
* B06_Section_Cross_Design_Session.ts
* (····2 ) ** **.
*
* (2026-09-06 )
* `POST …/sections/{route}/cross-design` ** **.
* []·[] .
* (`common_util_cross_design.ts`), .
*
* ** ** (·) .
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
/** 카드에서 고를 수 있는 값 한 벌 — 서버 `CrossDesignRequest` 와 같은 이름을 쓴다. */
export interface CrossDesignChoice {
ground_type: string;
section_mode: string;
ditch_side?: string | null;
ditch_type?: string | null;
paved?: boolean;
two_stage_slope?: boolean;
}
type ChoiceMap = Record<string, CrossDesignChoice>;
/** 측점 키 — 암 경계선 저장소와 같은 규칙(0.01m 단위). */
const keyOf = (chainageM: number): string => chainageM.toFixed(2);
function readAll(projectId: string, routeId: number): ChoiceMap {
return readState<ChoiceMap>("crossdesign", projectId, routeId) ?? {};
}
/** 이 측점의 선택값(없으면 null). */
export function readCrossDesignChoice(
projectId: string,
routeId: number,
chainageM: number,
): CrossDesignChoice | null {
return readAll(projectId, routeId)[keyOf(chainageM)] ?? null;
}
/** 선택값을 세션에 담는다. 같은 측점의 앞선 값은 덮어쓴다. */
export function writeCrossDesignChoice(
projectId: string,
routeId: number,
chainageM: number,
choice: CrossDesignChoice,
): void {
const all = readAll(projectId, routeId);
all[keyOf(chainageM)] = choice;
writeState("crossdesign", all, projectId, routeId);
}
/** [저장]·[확정]이 patch 로 실을 목록 — 누가거리(숫자) → 선택값. */
export function crossDesignChoices(
projectId: string | null,
routeId: number | null,
): Map<number, CrossDesignChoice> {
const choices = new Map<number, CrossDesignChoice>();
if (!projectId || routeId === null) return choices;
for (const [key, value] of Object.entries(readAll(projectId, routeId))) {
const chainage = Number(key);
if (Number.isFinite(chainage) && value) choices.set(chainage, value);
}
return choices;
}

Some files were not shown because too many files have changed in this diff Show More