Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 !== "";
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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%는 남아야 한다. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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를 다시 읽었다 — 다른 탭의 재업로드로
|
||||
* 옛 노선번호가 남을까 봐 넣은 안전장치인데, 그 탓에 B05↔B06을 오갈 때마다 화면이
|
||||
* 처음부터 다시 섰다. 이제 서명(노선번호·지표면)이 달라졌을 때만 다시 그린다.
|
||||
*/
|
||||
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);
|
||||
|
||||
@@ -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,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 {
|
||||
/* 세션 저장 실패는 무시 — 값은 메모리에 유지된다. */
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다.
|
||||
* 60→16ms(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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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() === "",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user