Merge remote-tracking branches 'origin/main_laptop_1' and 'origin/sub_laptop_5' into sub_laptop_1

This commit is contained in:
2026-10-16 11:24:59 +09:00
35 changed files with 1394 additions and 414 deletions
+88 -37
View File
@@ -31,9 +31,19 @@ export interface CachedAsset {
let dbPromise: Promise<IDBDatabase | null> | null = null;
/** 보관함 실패는 화면을 막지 않되 한 줄은 남긴다 — 같은 까닭은 한 번만. */
const warned = new Set<string>();
function warnOnce(what: string, error?: unknown): void {
const name = (error as { name?: string } | null)?.name ?? String(error ?? "");
const key = `${what}|${name}`;
if (warned.has(key)) return;
warned.add(key);
console.warn(`[asset-cache] ${what} 실패 — 보관 없이 받음`, name);
}
function openDatabase(): Promise<IDBDatabase | null> {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve) => {
const opening = new Promise<IDBDatabase | null>((resolve) => {
if (!("indexedDB" in window)) {
resolve(null);
return;
@@ -46,9 +56,30 @@ function openDatabase(): Promise<IDBDatabase | null> {
const store = db.createObjectStore(STORE, { keyPath: "key" });
store.createIndex("projectId", "projectId", { unique: false });
};
request.onsuccess = () => resolve(request.result);
request.onsuccess = () => {
const db = request.result;
// 다른 탭이 번호를 올리면 닫아 비켜 준다 — 안 닫으면 그 탭이 막혀 기다린다.
db.onversionchange = () => {
db.close();
dbPromise = null;
};
resolve(db);
};
// 옛 번호로 연 다른 탭이 안 닫으면 막힘 — 기다리지 않고 이번엔 보관 없이 간다.
request.onblocked = () => {
warnOnce("열기(다른 탭이 막음)");
resolve(null);
};
// 사생활 보호 모드 등으로 열리지 않으면 보관함 없이 동작한다(항상 새로 받는다).
request.onerror = () => resolve(null);
request.onerror = () => {
warnOnce("열기", request.error);
resolve(null);
};
});
// 실패(null)를 페이지 끝까지 기억하지 않는다 — 다음 부름에서 다시 열어 본다.
dbPromise = opening.then((db) => {
if (!db) dbPromise = null;
return db;
});
return dbPromise;
}
@@ -67,9 +98,19 @@ function runTransaction<T>(
try {
const transaction = db.transaction(STORE, mode);
const request = work(transaction.objectStore(STORE));
request.onsuccess = () => resolve(request.result ?? null);
request.onerror = () => resolve(null);
} catch {
// 쓰기의 끝 = 트랜잭션 완료 — 요청 성공 뒤에도 용량 초과 등으로 통째 취소될 수 있다.
let result: T | null = null;
request.onsuccess = () => {
result = request.result ?? null;
if (mode === "readonly") resolve(result);
};
transaction.oncomplete = () => resolve(result);
transaction.onabort = () => {
warnOnce(mode === "readonly" ? "읽기" : "저장", transaction.error ?? request.error);
resolve(null);
};
} catch (error) {
warnOnce(mode === "readonly" ? "읽기" : "저장", error);
resolve(null);
}
}),
@@ -88,26 +129,42 @@ async function writeAsset(asset: CachedAsset): Promise<void> {
await runTransaction("readwrite", (store) => store.put(asset) as IDBRequest<unknown>);
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
/** 지우기 트랜잭션 하나 — 끝(완료 · 취소)까지 기다린다. */
async function deleteWhere(work: (store: IDBObjectStore) => void): 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();
work(transaction.objectStore(STORE));
transaction.oncomplete = () => resolve();
transaction.onabort = () => {
warnOnce("지우기", transaction.error);
resolve();
};
} catch (error) {
warnOnce("지우기", error);
resolve();
}
});
}
/** 다른 프로젝트 자료를 모두 지운다. B그룹 페이지에 들어올 때 호출한다.
* 색인 열쇠만 훑는다 — 값(수십 MB 바이트)을 읽지 않는다. */
export async function purgeOtherProjects(projectId: string): Promise<void> {
await deleteWhere((store) => {
const index = store.index("projectId");
for (const range of [
IDBKeyRange.upperBound(projectId, true),
IDBKeyRange.lowerBound(projectId, true),
]) {
const cursorRequest = index.openKeyCursor(range);
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
const value = cursor.value as CachedAsset;
if (value.projectId !== projectId) cursor.delete();
store.delete(cursor.primaryKey);
cursor.continue();
};
transaction.oncomplete = () => resolve();
transaction.onerror = () => resolve();
} catch {
resolve();
}
});
}
@@ -151,26 +208,9 @@ export async function writeCachedBytes(
* 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(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();
}
});
// 열쇠 = `${projectId}|${url}` — 앞머리 범위로 한 번에 지운다(값을 읽지 않음).
const low = cacheKey(projectId, prefix);
await deleteWhere((store) => store.delete(IDBKeyRange.bound(low, `${low}\uffff`)));
}
export interface CachedFetchOptions {
@@ -269,6 +309,17 @@ export async function fetchCachedJson<T>(
return JSON.parse(new TextDecoder().decode(bytes)) as T;
}
/** 등고선 주소(유일한 정의처) — 같은 파일은 같은 주소여야 보관함 · 미리 받기를 함께 쓴다. */
export function contourAssetUrl(
projectId: string,
modelId: number,
interval: number,
smooth: boolean,
recalculate = false,
): string {
return `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${smooth}&recalculate=${recalculate}`;
}
/** 배수유역도 배경으로 쓰는 도엽 레이어(유일한 정의처 — 준비화면과 B05 패널이 함께 쓴다). */
export const DRAINAGE_SHEET_LAYERS = ["도엽_등고선", "도엽_하천중심선"] as const;
@@ -377,7 +428,7 @@ export async function preloadSurfaceAssets(
report("등고선을 준비하는 중…", null);
await fetchCachedBytes(
projectId,
`${base}/contour?interval=${interval}&smooth=${smooth}&recalculate=false`,
contourAssetUrl(projectId, confirmed.model_id, interval, smooth),
{ onProgress: (ratio) => report("등고선을 준비하는 중…", ratio) },
);
+3 -3
View File
@@ -10,8 +10,8 @@
display: none;
}
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단 보기에서 종단도는 3D 위에 떠 있는
판(absolute · 3D 크기가 안 바뀜)이고, 횡단 보기에서는 카드 칸 아래 흐름에 선다. */
/* 가운데 = 위 칸(보기마다 다름) + 아래 종단도 한 벌. 종단도는 두 보기 모두 위 칸 아래 흐름에
선다(345-3 — 종단 보기 위 칸 = 평면 노선 편집 · 3D 는 모달) · 높이 끌기는 그대로. */
.design-page__main {
position: relative;
display: flex;
@@ -35,7 +35,7 @@
min-height: 0;
}
.design-page__main[data-view="cross"] > .design-page__profile > .b05-route-profile {
.design-page__main > .design-page__profile > .b05-route-profile {
position: relative;
flex: none;
}
+25 -18
View File
@@ -1,6 +1,7 @@
/* =============================================================================
* design_view_profile.ts
* 한 페이지의 **종단 보기**(계획서 24-3b) — 3D 화면은 [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 ·
* 한 페이지의 **종단 보기**(계획서 24-3b) — 가운데 위 칸 = 평면(노선 편집 · 345-3) · 3D 화면은
* [3D] 큰 모달(345-2). 좌측 패널 · 아래 단추 ·
* 종단도(유토곡선 · 배수유역도)는 한 벌(`design_shared`)을 횡단 보기와 같은 노드로 쓴다.
*
* 여기서는 종단 보기 몫만 건다 — 3D · 좌측 종단 칸 재료 · 구조물 다리 · 종단도 알림 · 저장 몫.
@@ -18,7 +19,7 @@ import { createRoutePanel } from "../../B05_Profile/B05_Profile_UI_Panel";
import { createRouteProfilePanel } from "../../B05_Profile/B05_Profile_UI_Profile_Panel";
import { leaveForDashboard } from "../b_missing_data_guard";
import { currentRoute, remountRoute } from "../router";
import { openRouteEditModal } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { mountRouteEditor } from "../../B05_Profile/B05_Profile_UI_RouteEdit";
import { createSelectionSync, graphTargetAt } from "../../B05_Profile/B05_Profile_UI_Selection";
import {
readStructurePick,
@@ -46,6 +47,7 @@ import {
rememberStandardDefaults,
} from "../../B06_Section/B06_Section_UI_Standard_Panel";
import { fetchSectionContext } from "../../B06_Section/B06_Section_Api_Fetch";
import { invalidateSectionDetail } from "../../B06_Section/B06_Section_Section_Store";
import {
onStaleStructureTypes,
structureAnchorM,
@@ -270,18 +272,6 @@ export async function mountProfileView(
const panel = createRoutePanel(
{
// 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하고 그 결과가 새 초기값이
// 된다. 옛 노선 캐시는 모달이 비우고, 페이지는 통째로 다시 읽는다 — 제자리 다시 세우기는
// 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아 [저장]에 실렸다(PLAN 77).
onEditPlannedRoute: () =>
void openRouteEditModal(activeProjectId, () => location.reload(), {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 모달이 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: state.confirmedSurface?.model_id ?? null,
contourIntervalM: state.latest?.surface_params.contour_interval_m,
smooth: state.latest?.surface_params.smooth,
}),
onContourApply: (interval) => view3d.applyContours(interval),
...view3d.viewCallbacks,
onInputChange: markStale,
@@ -420,6 +410,16 @@ export async function mountProfileView(
},
});
/** [노선 반영] 끝 — 서버가 배수유역부터 다시 셌고 그 결과가 새 초기값이다. 새로고침 대신 페이지를
* 통째로 다시 세운다(345-3) — 제자리 고침은 옛 노선 관 목록 등 메모리 · 진행 중 요청이 살아남아
* [저장]에 실렸다(PLAN 77). 초안 · 결과 칸은 [노선 반영]이 비웠고 옛 노선 캐시는 여기서 비운다 ·
* 떠나는 페이지의 늦은 응답은 `host.disposed()` 로 버려진다. */
const rebuildAfterReplan = (): void => {
clearRouteLatestCache(activeProjectId);
invalidateSectionDetail(activeProjectId);
remountRoute(currentRoute());
};
/* ── 진입 로딩 ─────────────────────────────────────────────────────────
* 화면 틀을 먼저 띄우고 자료가 끝나는 순서대로 채운다(2026-08-01 사용자 지시).
* 3D 지형은 **보조 자료라 로딩에 넣지 않는다**(2026-09-04 사용자 지시) — [3D] 모달을 처음 열 때
@@ -427,10 +427,7 @@ export async function mountProfileView(
const LOAD_STEP_COUNT = 3;
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
const planTools = document.createElement("div");
planTools.className = "b05-route__plan-tools";
planTools.append(view3d.modal.button);
mainContent.append(planTools, view3d.modal.park);
mainContent.append(view3d.modal.park);
center.append(mainContent);
const progress = createProgressCircle({ label: "노선 정보를 불러오는 중…", overlay: true });
mainContent.append(progress.root);
@@ -481,6 +478,16 @@ export async function mountProfileView(
return;
}
advanceLoading("종단면 자료를 불러오는 중…");
// 평면(노선 편집) — 가운데 위 본문 · 페이지 수명(345-3). 자료는 페이지 열기와 함께 읽는다.
void mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan, {
// 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 편집기가 따로 굳히지 않는다.
stationIntervalM: panel.values().stationInterval ?? undefined,
// 바탕 등고선 — 확정 지표면이 있으면 LAS 등고선을 쓴다(계획서 0-9 ⑥).
surfaceModelId: state.confirmedSurface.model_id,
contourIntervalM: latestResponse.surface_params.contour_interval_m,
smooth: latestResponse.surface_params.smooth,
tools: [view3d.modal.button],
});
// ③ 종단면·횡단 자료 — 하단 패널을 3D보다 먼저 채운다.
if (latestResponse.route) await restoreSections(latestResponse.route.id);
+38 -5
View File
@@ -18,6 +18,7 @@ import {
API_TIMEOUT_MS,
} from "@config/config_frontend";
import { showToast } from "@ui/ui_template_elements";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
@@ -244,17 +245,49 @@ export async function listSurfaceInputFiles(
});
}
/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */
export async function fetchSurfacePointCloud(
projectId: string,
filter?: string,
): Promise<SurfacePointCloudSampleResponse> {
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
return requestJson<SurfacePointCloudSampleResponse>(
`/projects/${projectId}/surface/point-cloud${query}`,
{
method: "GET",
},
const bytes = await fetchCachedBytes(
projectId,
`${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`,
);
return { status: "success", project_id: projectId, ...decodePointPreview(bytes) };
}
/**
* 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`.
* [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n].
*/
export function decodePointPreview(
bytes: ArrayBuffer,
): Omit<SurfacePointCloudSampleResponse, "status" | "project_id"> {
const headLength = new DataView(bytes).getUint32(0, true);
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as {
point_count: number;
sampled_count: number;
bounds: SurfaceBounds;
has_rgb: boolean;
};
const count = head.sampled_count;
const offset = Math.ceil((4 + headLength) / 4) * 4;
const xyz = new Float32Array(bytes, offset, count * 3);
const points: [number, number, number][] = new Array(count);
for (let index = 0; index < count; index += 1) {
points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]];
}
let rgb: [number, number, number][] | undefined;
if (head.has_rgb) {
const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3);
rgb = new Array(count);
for (let index = 0; index < count; index += 1) {
rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]];
}
}
return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb };
}
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
+9
View File
@@ -334,6 +334,15 @@ def run_surface_analysis(
):
continue
cache_ground_points(structured_path, filter_key, mask)
# 점군 미리보기 고정 표본(이진) — 원천이 그대로면 머리만 보고 건너뜀
try:
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
ensure_point_preview(structured_path, None, data["xyz"])
for filter_key in masks:
ensure_point_preview(processed_dir / f"ground_points_{filter_key}.npz", filter_key)
except Exception as exc: # noqa: BLE001 — 미리보기 실패가 분석을 막지 않음
logger.warning("B04 점군 미리보기 이진 생성 실패: %s", exc)
# 3. 지표면 5종 모델 빌드
_report(70, "surface_model", "지표면 모델 생성 중")
@@ -0,0 +1,97 @@
"""B04 점군 미리보기 — 고정 표본 이진 파일(전처리 때 한 번 · ETag 로 내보냄).
JSON(27.8 MB · 요청마다 점군 풀고 표본)을 대신한다. 표본 규칙은 JSON 길과 같다
(`sample_points` · 같은 씨앗) — 같은 점 · 같은 float32 값.
파일 = [머리 길이 uint32 LE][머리 JSON utf-8][0 채움 → 4 배수][xyz float32 × 3n][rgb uint16 × 3n].
머리 = {version, source(원천 npz mtime_ns:크기), point_count, sampled_count, bounds, has_rgb}.
TS 짝(읽기) = `B04_PreProcess_Api_Fetch.ts` `decodePointPreview`.
"""
from __future__ import annotations
import json
import struct
from pathlib import Path
from typing import Any
import numpy as np
from common_util.common_util_atomic import atomic_write_bytes
POINT_CLOUD_SAMPLE_LIMIT = 500_000
PREVIEW_VERSION = 1
_SAMPLE_SEED = 20260710
def sample_points(xyz: np.ndarray, rgb: np.ndarray | None) -> tuple[np.ndarray, np.ndarray | None]:
"""화면용 고정 표본 — 상한을 넘으면 같은 씨앗으로 고른다(열 때마다 같은 그림)."""
source_count = int(len(xyz))
if rgb is not None and (rgb.ndim == 0 or len(rgb) != source_count):
rgb = None
if source_count <= POINT_CLOUD_SAMPLE_LIMIT:
return xyz, rgb
indexes = np.random.default_rng(_SAMPLE_SEED).choice(
source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False
)
return xyz[indexes], (rgb[indexes] if rgb is not None else None)
def preview_path(processed_dir: Path, filter_key: str | None) -> Path:
return processed_dir / f"preview_points_{filter_key or 'raw'}.bin"
def _stamp(path: Path) -> str:
stat = path.stat()
return f"{stat.st_mtime_ns}:{stat.st_size}"
def read_preview_header(path: Path) -> dict[str, Any] | None:
"""머리만 읽는다 — 없거나 깨졌으면 None."""
try:
with path.open("rb") as file:
(length,) = struct.unpack("<I", file.read(4))
return json.loads(file.read(length).decode("utf-8"))
except (OSError, ValueError, struct.error):
return None
def ensure_point_preview(source_path: Path, filter_key: str | None, xyz_source: Any = None) -> Path:
"""원천 npz(structured · ground_points_*) 그대로면 있는 파일 · 아니면 한 번 만든다.
`xyz_source` — 이미 메모리에 있는 xyz(또는 쓸 때 푸는 대리자) · 없으면 npz 에서 푼다.
"""
target = preview_path(source_path.parent, filter_key)
stamp = _stamp(source_path)
header = read_preview_header(target)
if header and header.get("version") == PREVIEW_VERSION and header.get("source") == stamp:
return target
with np.load(source_path) as data:
xyz = np.asarray(data["xyz"] if xyz_source is None else xyz_source, dtype=np.float32)
bounds = np.asarray(data["bounds"], dtype=np.float64)
point_count = int(data["point_count"]) if "point_count" in data else int(len(xyz))
rgb = np.asarray(data["rgb"]) if "rgb" in data else None
sample, rgb_sample = sample_points(xyz, rgb)
header = {
"version": PREVIEW_VERSION,
"source": stamp,
"point_count": point_count,
"sampled_count": int(len(sample)),
"bounds": {
"x_min": float(bounds[0, 0]),
"x_max": float(bounds[0, 1]),
"y_min": float(bounds[1, 0]),
"y_max": float(bounds[1, 1]),
"z_min": float(bounds[2, 0]),
"z_max": float(bounds[2, 1]),
},
"has_rgb": rgb_sample is not None,
}
head = json.dumps(header).encode("utf-8")
pad = (-(4 + len(head))) % 4
parts = [struct.pack("<I", len(head)), head, b"\0" * pad]
parts.append(np.ascontiguousarray(sample, dtype="<f4").tobytes())
if rgb_sample is not None:
parts.append(np.ascontiguousarray(rgb_sample, dtype="<u2").tobytes())
atomic_write_bytes(target, b"".join(parts))
return target
+64 -50
View File
@@ -8,8 +8,8 @@ from typing import Any
from uuid import UUID
import numpy as np
from fastapi import APIRouter, Depends
from fastapi.responses import JSONResponse
from fastapi import APIRouter, Depends, Request
from fastapi.responses import JSONResponse, Response
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B04_PreProcess.B04_PreProcess_Engine import (
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
project_epsg_from_prj,
)
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
from B04_PreProcess.B04_PreProcess_Repository import (
clear_confirmed_surface_models,
get_input_file,
@@ -63,6 +64,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
)
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
from common_util.common_util_auth import require_system_admin
from common_util.common_util_http_cache import cached_file_response
from common_util.common_util_storage import resolve_stored_project_path
from common_util.common_util_surface_confirmation import (
get_surface_confirmation_params,
@@ -77,7 +79,6 @@ from config.config_db import get_db_pool
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
POINT_CLOUD_SAMPLE_LIMIT = 500_000
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
@@ -298,43 +299,70 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
)
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
async with get_db_pool().acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
if filter is None:
return structured_path
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
return source_path
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
async def get_surface_point_cloud_binary(
project_id: UUID, request: Request, filter: str | None = None
) -> Response:
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
try:
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
return cached_file_response(request, target, "application/octet-stream")
except LookupError as exc:
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
except Exception:
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
return JSONResponse(
status_code=500,
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
)
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
async def get_surface_point_cloud(
project_id: UUID,
filter: str | None = None,
) -> SurfacePointCloudSampleResponse | JSONResponse:
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
pool = get_db_pool()
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
try:
async with pool.acquire() as connection:
stored_path = await get_project_storage_relative_path(connection, project_id)
project_root = Path(resolve_stored_project_path(stored_path))
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
if not structured_path.is_file():
return JSONResponse(
status_code=404,
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
)
source_path = structured_path
if filter is not None:
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
if filter not in available_filters():
return JSONResponse(
status_code=400,
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
)
source_path = structured_path.parent / f"ground_points_{filter}.npz"
cache_is_current = False
if source_path.is_file():
with np.load(source_path) as cached:
cache_is_current = (
"cache_version" in cached
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
)
if not cache_is_current:
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
source_path = await _point_cloud_source(project_id, filter)
if isinstance(source_path, JSONResponse):
return source_path
with np.load(source_path) as structured:
xyz = np.asarray(structured["xyz"], dtype=np.float32)
@@ -342,22 +370,8 @@ async def get_surface_point_cloud(
point_count = (
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
)
source_count = int(len(xyz))
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rng = np.random.default_rng(20260710)
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
sample = xyz[indexes]
else:
sample = xyz
rgb_sample = None
if "rgb" in structured:
rgb_arr = np.asarray(structured["rgb"])
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
rgb_sample = rgb_arr[indexes]
else:
rgb_sample = rgb_arr
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
sample, rgb_sample = sample_points(xyz, rgb)
return SurfacePointCloudSampleResponse(
project_id=str(project_id),
+19 -16
View File
@@ -161,25 +161,28 @@ async def get_project_geojson(
if layer == "등고선":
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
if simplified_filepath.exists():
# 원본이 새로 받혔으면(전처리 다시) 단순화본도 다시 만든다.
fresh = (
simplified_filepath.exists()
and simplified_filepath.stat().st_mtime_ns >= filepath.stat().st_mtime_ns
)
if not fresh:
try:
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception:
pass
import geopandas as gpd
try:
import geopandas as gpd
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
fresh = True
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
if fresh:
filepath = simplified_filepath
gdf = gpd.read_file(filepath)
gdf["geometry"] = gdf["geometry"].simplify(
tolerance=0.00003, preserve_topology=True
)
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
except Exception as e:
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
return json.loads(filepath.read_text(encoding="utf-8"))
# 파일 그대로 + ETag — 다시 묶지 않고 두 번째부터 304.
return cached_file_response(request, filepath, "application/geo+json")
except Exception as exc:
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
@@ -7,6 +7,9 @@
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch";
import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender";
export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
@@ -45,6 +48,37 @@ export const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
도엽_옹벽석축: false,
};
/** 받는 중인 층 — 같은 층을 겹쳐 받지 않는다. */
const inFlight = new Map<string, Promise<GeoJsonCollection | null>>();
/** 레이어 하나 받기 — 도엽은 보관함(IndexedDB) · 나머지는 서버 ETag. 실패면 null.
* 꺼진 층은 켤 때 부른다(처음 열기에 도엽 등고선 37 MB 를 받지 않음). */
export function fetchGisLayer(
projectId: string,
layer: GisLayer,
): Promise<GeoJsonCollection | null> {
const key = `${projectId}|${layer}`;
const pending = inFlight.get(key) ?? downloadLayer(projectId, layer);
inFlight.set(key, pending);
void pending.finally(() => inFlight.delete(key));
return pending;
}
async function downloadLayer(
projectId: string,
layer: GisLayer,
): Promise<GeoJsonCollection | null> {
try {
return (
layer.startsWith("도엽_")
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
: await fetchGisGeoJson(projectId, layer)
) as GeoJsonCollection;
} catch {
return null;
}
}
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
export const CONTOUR_LABEL_DEFAULT_ON = false;
+20 -20
View File
@@ -1,9 +1,7 @@
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
import { createProgressCircle } from "@ui/ui_template_progress";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import {
fetchGisGeoJson,
fetchPlannedRoute,
fetchVWorldMeta,
getVWorldMapUrl,
@@ -19,6 +17,7 @@ import {
CONTOUR_LABEL_KEYS,
GIS_DEFAULT_ON,
GIS_LAYERS,
fetchGisLayer,
gisLayerColor,
type BackgroundLayer,
type GisLayer,
@@ -36,7 +35,6 @@ import {
drawPreparedLayer,
routeLineColor,
ROUTE_LINE_WIDTH,
type GeoJsonCollection,
type MapRect,
type Normalizer,
type PlanBounds,
@@ -330,6 +328,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
backgroundImages.forEach((image, layer) => {
image.hidden = !activeBackgrounds.has(layer);
});
activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer));
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
scheduleDraw();
}
@@ -453,6 +452,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
});
}
/** 켠 층이 아직 없으면 그때 받는다(같은 층 겹쳐 받기는 fetchGisLayer 가 막음). */
async function loadLayerOnDemand(layer: GisLayer): Promise<void> {
const projectId = currentProjectId;
if (!projectId || !meta || preparedLayers.has(layer)) return;
const data = await fetchGisLayer(projectId, layer);
if (!data || projectId !== currentProjectId || !normalizer) return;
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
scheduleDraw();
}
async function loadLayers(): Promise<void> {
if (!currentProjectId) return;
const projectId = currentProjectId;
@@ -471,26 +480,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
fetchVWorldMeta(projectId, "satellite"),
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
]);
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
// 켜진 층만 처음에 받는다(꺼진 층은 켤 때) — 끝나는 대로 진행률을 올린다.
const firstLayers = GIS_LAYERS.filter((layer) => activeGisLayers.has(layer));
let done = 0;
const loadedLayers = await Promise.all(
GIS_LAYERS.map(async (layer) => {
try {
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
const data = (
layer.startsWith("도엽_")
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
: await fetchGisGeoJson(projectId, layer)
) as GeoJsonCollection;
return [layer, data] as const;
} catch {
return [layer, null] as const;
} finally {
done += 1;
if (sequence === loadSequence) {
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
}
firstLayers.map(async (layer) => {
const data = await fetchGisLayer(projectId, layer);
done += 1;
if (sequence === loadSequence) {
showProgress(done / firstLayers.length, `도엽 레이어 ${done}/${firstLayers.length}`);
}
return [layer, data] as const;
}),
);
if (sequence !== loadSequence) return;
@@ -9,8 +9,7 @@
import * as THREE from "three";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
const MAX_CONTOUR_LABELS = 40;
@@ -43,7 +42,7 @@ export async function loadContourLinesInto(
): Promise<boolean> {
const interval = parseFloat(ctx.intervalInput.value) || 1.0;
const projectId = ctx.projectId();
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
const contourUrl = contourAssetUrl(projectId, modelId, interval, isSmooth, recalculate);
try {
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
+2 -6
View File
@@ -3,7 +3,7 @@
* 종단 보기(B05) 좌측 칸 재료 — 한 벌 좌측 패널(`design_shared`)에 종단 보기 몫을 넣는다.
*
* 칸 정의는 `_Panel_Sections`(종단 보기 전용) · `design_sections`(두 보기 공용), 재료는
* `_Panel_Settings` · `_Panel_Route` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields`
* `_Panel_Settings` · `_Panel_View`, 값 · 상수 · 입력 칸 만들기는 `_Panel_Fields`
* 에 있다(700줄 제한으로 나눔, 계획서 24-2b). 값을 읽고 되살리는 창구(`values` · `restore`)만
* 여기 남는다. 아래 고정 영역(단추 넷) · 「구조물 배치」 폼은 한 벌이 두 보기에 같은 노드로 준다.
* ========================================================================== */
@@ -23,15 +23,12 @@ import {
parseOptional,
type RoutePanelValues,
} from "./B05_Profile_UI_Panel_Fields";
import { createRoutePart } from "./B05_Profile_UI_Panel_Route";
import { createSettingsPart } from "./B05_Profile_UI_Panel_Settings";
import { createViewControls } from "./B05_Profile_UI_Panel_View";
export type { RoutePanelValues } from "./B05_Profile_UI_Panel_Fields";
interface PanelCallbacks {
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
onContourApply: (interval: number) => void | Promise<void>;
onSurfaceVisible: (visible: boolean) => void;
/** [예상형상] — 계획 코리도 서피스 + 클리핑 지형(공사 후) ↔ 원지반 완전체 전환. */
@@ -74,7 +71,6 @@ interface PanelCallbacks {
export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageShared) {
const { viewControls, setCorridorPending } = createViewControls(callbacks);
const settings = createSettingsPart(callbacks);
const route = createRoutePart(callbacks);
const {
contourInterval,
gradeClass,
@@ -123,7 +119,7 @@ export function createRoutePanel(callbacks: PanelCallbacks, shared: DesignPageSh
// 좌측 패널 = 한 벌 등록부(`design_shared`) — 종단 보기 칸 재료만 넣는다. 아래 단추 넷 · 저장 ·
// [확정]은 한 벌이 두 보기 몫을 모아 낸다.
Object.assign(shared.parts, { settings, route });
Object.assign(shared.parts, { settings });
sharedPanel(shared);
return {
-26
View File
@@ -1,26 +0,0 @@
/* =============================================================================
* B05_Profile_UI_Panel_Route.ts
* 좌측 패널의 **노선 쪽 칸 재료** — 계획노선 편집 단추. 칸 틀은
* `B05_Profile_UI_Panel_Sections.ts` 가 씌운다(등록부). 「경로 계산 설정」은 `Old/` 로 옮김(54-24).
* 「선택 포인트 상세」(3D 노선 점 옮김 · 지움)는 지움(345-2).
* `B05_Profile_UI_Panel.ts` 에서 떼어냈다(계획서 24-2b). 본문 로직과 수치는 그대로다.
* ========================================================================== */
import { button } from "./B05_Profile_UI_Panel_Fields";
export interface RoutePartCallbacks {
/** [계획노선 편집] — 큰 모달을 열어 노선을 고친다(계산은 모달 [확인]에서만 돈다). */
onEditPlannedRoute: () => void;
}
export function createRoutePart(callbacks: RoutePartCallbacks) {
// 계획노선 편집 — 노선을 고치는 유일한 입구(2026-09-06 사용자 확정). 자동탐색
// (「경로 계산 설정」)은 사용 중단이라 이 자리가 노선을 바꾸는 길이다.
const plannedRouteBtn = button("계획노선 편집", () => callbacks.onEditPlannedRoute());
plannedRouteBtn.classList.add("b05-route__btn");
plannedRouteBtn.title = "예상노선 위에서 계획노선을 고칩니다. [확인] 때만 다시 계산합니다.";
return { plannedRouteButton: plannedRouteBtn };
}
export type RoutePart = ReturnType<typeof createRoutePart>;
+5 -17
View File
@@ -1,16 +1,16 @@
/* =============================================================================
* B05_Profile_UI_Panel_Sections.ts
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정 · 계획노선.
* 종단 보기 좌측 패널의 **칸 정의**(등록부 `registerPanelSection`) — 페이지 설정.
* 「구조물 배치」는 두 보기 공용이라 `design_sections.ts` 에 있다.
*
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 계획노선 > 아래 고정 영역.
* 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2). 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` · `_Panel_Route` 가 만들어
* `ctx.parts` 로 건넨다.
* 칸 차례(2026-08-18 사용자 확정): 구조물 배치 > 페이지 설정 > 아래 고정 영역.
* 「선택 포인트 상세 설정」(3D 노선 점 옮김 · 지움 · 저장 안 되는 길)은 지움(345-2) ·
* 「계획노선」 칸 · [계획노선 편집]은 지움(345-3 — 노선 편집은 가운데 위 본문).
* 「경로 계산 설정」은 `Old/` 로 옮김(54-24). 재료는 `_Panel_Settings` 가 만들어 `ctx.parts` 로 건넨다.
* ========================================================================== */
import "../A00_Common/design/design_sections";
import { createSectionFrame, part, registerPanelSection } from "../A00_Common/design/design_panel";
import type { RoutePart } from "./B05_Profile_UI_Panel_Route";
import type { SettingsPart } from "./B05_Profile_UI_Panel_Settings";
registerPanelSection({
@@ -24,15 +24,3 @@ registerPanelSection({
return frame.root;
},
});
registerPanelSection({
id: "planned-route",
title: "계획노선",
views: ["profile"],
order: 30,
build: (ctx, view) => {
const frame = createSectionFrame(view, "계획노선", true); // 311 기본 접힘
frame.body.append(part<RoutePart>(ctx, "route").plannedRouteButton);
return frame.root;
},
});
@@ -32,6 +32,7 @@ import { createFreeDraw } from "../A00_Common/design/design_profile_free";
import { createFreeView, type FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
import { arrowsBelongToProfile } from "../B06_Section/B06_Section_UI_Cross_Panel_Base";
import { stepStructureStation } from "../common_util/common_util_structure_station";
import { undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner";
/** 방향키 ▲▼ 한 번에 움직이는 계획고(m) — 좌우(구조물 측점)는 1 m 걸음(234-1 · `stepStructureStation`). */
const KEY_STEP_M = 0.1;
@@ -320,6 +321,7 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
return;
}
if (isCtrlKey(event, "z")) {
if (undoOwner() === "plan") return; // 평면(노선 편집)을 마지막에 만졌다(345-3).
event.preventDefault();
flushHold();
if (event.shiftKey) history.redo();
+51 -24
View File
@@ -1,10 +1,11 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit.ts
* 계획노선 편집 모달 — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
* 계획노선 편집(평면) — 예상노선(점선) 위에 계획노선(실선)을 고쳐 그린다.
*
* 왜 모달인가(2026-09-06 사용자 확정) — [확인]을 누르면 배수유역부터 종·횡단·유토곡선까지
* 전 단계가 다시 도는 무거운 작업이다(용화 67측점 3분대). 신중히 하라는 뜻으로 큰 모달을
* 쓰고, **편집 중에는 아무 계산도 나가지 않는다**.
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)이고 **페이지 수명**이다.
* 이력 · 키 듣기는 보기의 보임 · 숨김 · 떠남(`host`)을 따른다. [노선 반영]을 누르면 배수유역부터
* 종·횡단·유토곡선까지 다시 돌고(무거움), 끝나면 부른 쪽이 새로고침 없이 페이지를 다시 세운다.
* **편집 중에는 아무 계산도 나가지 않는다**(띠 · 기울기 · 횡단 미리보기 묻기만).
*
* 노선은 두 벌이다 — 예상노선(원본, 안 바뀜)과 계획노선(수정본, 사용자가 고침).
* [초기화]는 마지막에 적용한 초기노선 갈래로 편집값만 되돌린다(계획서 20 · `_Initial`).
@@ -82,6 +83,7 @@ import {
type RouteEditHistory,
type RouteEditSnapshot,
} from "./B05_Profile_UI_RouteEdit_History";
import type { DesignPageHost } from "../A00_Common/design/design_page";
import "./B05_Profile_UI_Style_RouteEdit.css";
/** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다
@@ -91,7 +93,7 @@ const CONTOUR_LEVEL_BUDGET = 1200;
type Vertex = [number, number];
/** 모달을 연다. [확인]이 끝나면 `onApplied`를 부른다(화면 다시 읽기). */
/** 편집기 곁값. [노선 반영]이 끝나면 `onApplied`를 부른다(화면 다시 세우기). */
export interface RouteEditOptions {
/** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */
stationIntervalM?: number;
@@ -100,15 +102,23 @@ export interface RouteEditOptions {
/** 등고선 간격(m)·평활 여부 — 3D 뷰어가 쓰는 값 그대로. */
contourIntervalM?: number;
smooth?: boolean;
/** 머리 도구줄 끝에 앉힐 부른 쪽 단추([3D] 등). */
tools?: HTMLElement[];
}
export async function openRouteEditModal(
/** 편집기가 사는 보기 — 보임 · 숨김 · 떠남. */
export type RouteEditHost = Pick<DesignPageHost, "visible" | "onShow" | "onHide" | "onDispose">;
/** 편집기를 `parent` 에 세운다 — 자료를 읽고 나면 끝난다(떠날 때 정리는 `host.onDispose`). */
export async function mountRouteEditor(
parent: HTMLElement,
host: RouteEditHost,
projectId: string,
onApplied: () => void | Promise<void>,
options: RouteEditOptions = {},
): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome();
const chrome = createRouteEditChrome(parent, options.tools);
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const { minorContourButton, terrainSelect, stationSnapButton } = chrome;
const context = canvas.getContext("2d")!;
@@ -248,12 +258,13 @@ export async function openRouteEditModal(
meta && viewReady ? captureView(meta, view, rotation.radians()) : null,
);
const close = (): void => {
/** 떠날 때 — 페이지가 버린다(345-3 · 닫기 단추 없음). */
const dispose = (): void => {
viewMemory.flush();
closed = true;
window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
sizeWatch.disconnect();
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 떠난 뒤에도 산다.
curveLabelBox.destroy();
overlay.remove();
};
/** 초기노선 갈래 — 로직 단추 · [초기화] · 비교값(계획서 20). 서버 묻기는 `_Initial` 몫. */
@@ -279,22 +290,36 @@ export async function openRouteEditModal(
criteria: initial.current().criteria,
}),
});
const cancel = (): void => (initial.cancel(), close()); // 이 창에서 바꾼 초기값도 버린다.
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", cancel);
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", cancel);
// 배경 클릭으로 닫지 않는다 — 고치던 노선을 실수로 날리지 않게.
// [반영 전으로] — 마지막 반영(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
const snapshot = history?.reset();
if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info");
initial.restoreTag(snapshot.rec);
restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false);
});
function resize(): void {
if (closed) return;
if (closed || !overlay.isConnected) return;
// 크기가 바뀌어도 가운데 자리 · 배율을 지킨다 — px 이동량을 그대로 두면 지도가 밀려
// 노선이 화면 밖으로 나가고, 그 자리가 보던 화면으로 저장돼 다시 열어도 빈 화면이 된다.
const kept = meta && viewReady ? captureView(meta, view, rotation.radians()) : null;
const { width, height } = fitCanvas(canvas, context);
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
if (meta && kept) view = { ...view, ...restoreSavedView(meta, view, kept) };
if (!viewReady) openView(); // 숨은 채 섰으면(크기 0) 처음 보일 때 맞춘다.
draw();
}
window.addEventListener("resize", resize);
// 칸 크기를 따라감 — 창 크기 · 아래 종단 판 끌기 · 좌측 접기 모두(345-3).
const sizeWatch = new ResizeObserver(() => resize());
sizeWatch.observe(overlay.querySelector<HTMLElement>(".b05-routeedit__canvas-wrap")!);
let viewWanted = false; // 자료를 읽었다 — 칸 크기가 서면 보던 화면(18 ③) · 없으면 노선 전체로.
function openView(): void {
if (!viewWanted || !meta || view.width <= 0) return;
const opening = openingView(projectId, meta, view, planned);
view = opening.view;
rotation.set(opening.rotationRad);
viewReady = true;
}
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
@@ -401,6 +426,7 @@ export async function openRouteEditModal(
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
const curveBar = createCurveBar({
canvas,
container: overlay,
state: () => ({
picked,
planned,
@@ -566,10 +592,15 @@ export async function openRouteEditModal(
stationSnap: () => stationSnap,
initial,
profileShown: strip.settle,
close,
onApplied,
});
// 페이지 수명 — 보일 때만 키를 듣고 숨으면 뗌 · 떠나면 정리(design_page host · 345-3).
historyControls.listen(host.visible());
host.onShow(() => (historyControls.listen(true), resize()));
host.onHide(() => (historyControls.listen(false), viewMemory.flush()));
host.onDispose(dispose);
// ── 자료 읽기 — 노선 두 벌 + 등고선 도엽(배수유역도와 같은 것) ──
try {
const [plan, drainage, sources] = await Promise.all([
@@ -654,12 +685,8 @@ export async function openRouteEditModal(
draw();
});
});
resize();
// 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다.
const opening = openingView(projectId, meta, view, planned);
view = opening.view;
rotation.set(opening.rotationRad);
viewReady = true;
viewWanted = true;
resize(); // 칸 크기가 서 있으면 여기서 보던 화면을 연다(아니면 처음 보일 때).
showStatus();
draw();
strip.refresh();
@@ -1,9 +1,10 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Apply.ts
* 계획노선 편집 모달의 **[확인]** — 무거운 재계산과 대기 표시.
* 계획노선 편집(평면)의 **[노선 반영]**(옛 [확인] · 346-3 까지 남김) — 무거운 재계산과 대기 표시.
*
* 다시 계산은 **노선이 바뀐 뒤 [확인]에서만** 돈다(계획서 20 ④). 노선이 창을 연 그대로면
* 계산 없이 닫는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`).
* 다시 계산은 **노선이 바뀐 뒤 [노선 반영]에서만** 돈다(계획서 20 ④). 노선이 마지막 반영 그대로면
* 계산하지 않는다. [초기화](옛 「예상노선으로」)는 편집값만 되돌린다(`_Initial`). 끝나면 부른 쪽이
* 페이지를 새로고침 없이 다시 세운다(`onApplied`).
*
* 누르면 서버가 배수유역부터 종·횡단·유토곡선까지 전 단계를 다시 돈다(약 90초). 중간 취소는
* 만들지 않기로 했으므로(계획서 0-2, 2026-09-09) **얼마나 지났는지**를 초로 보여 사람이
@@ -23,7 +24,7 @@ export interface RouteApplyParams {
projectId: string;
/** 지금 편집값 — 누른 순간에 읽는다. */
nodes: () => { planned: Vertex[]; curveOn: boolean[]; curveRadius: Array<number | null> };
/** 창을 연 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
/** 마지막 반영 뒤 노선이 바뀌었나 — 안 바뀌었으면 다시 계산하지 않는다. */
changed: () => boolean;
/** 설계 지형으로 보낼 분석 지형(PLAN 133-2) — 고르기가 안 보이면 null(지금 설계 지형 그대로). */
terrain: () => string | null;
@@ -36,12 +37,11 @@ export interface RouteApplyParams {
};
/** 종단 띠 — 응답을 기다려 띠에 보인 계획선(균형 종단 · 지반고)을 그대로 적용한다(PLAN 75-1). */
profileShown: () => Promise<boolean>;
/** 성공하면 모달을 닫고 화면을 다시 읽는다. */
close: () => void;
/** 성공하면 화면을 다시 세운다(대기 막은 다시 세울 때까지 남음). */
onApplied: () => void | Promise<void>;
}
/** [확인]을 붙인다. 리스너는 모달과 수명이 같다. */
/** [노선 반영]을 붙인다. 리스너는 편집기와 수명이 같다. */
export function bindRouteApply(params: RouteApplyParams): void {
const { overlay, busy, projectId } = params;
@@ -64,7 +64,6 @@ export function bindRouteApply(params: RouteApplyParams): void {
clearResults(projectId);
params.initial.keep(); // 초기값은 새 노선의 출발점 — 옛 노선 결과와 함께 버리지 않는다.
showToast("노선을 다시 계산했습니다.", "success");
params.close();
await params.onApplied();
} catch (error) {
busy.hidden = true;
@@ -82,7 +81,6 @@ export function bindRouteApply(params: RouteApplyParams): void {
}
if (!params.changed()) {
showToast("노선이 그대로라 다시 계산하지 않았습니다.", "info");
params.close();
return;
}
void runHeavy("계획노선 반영", async () =>
+34 -20
View File
@@ -1,9 +1,8 @@
import { createButton } from "@ui/ui_template_elements";
import "@ui/ui_template_modal.css";
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
* 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다.
* 계획노선 편집(평면)의 **뼈대** — 칸·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다.
*
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
@@ -12,8 +11,9 @@ import "@ui/ui_template_modal.css";
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
*
* 겉틀 — 배경 · 제목줄은 공용 모달 조각(`ui-modal__backdrop` · `ui-modal__top`). `openModal` 은
* 안 씀 — 바깥 누름 · Esc 로 닫혀 고치던 노선을 날리고, 제목줄에 단추 묶음 · 오른쪽 횡단 칸이 못 앉음.
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 닫기 단추는 걷었다 · [취소] → [반영 전으로] · [확인] → [노선 반영] · 오른쪽 횡단 칸은 접는 칸.
* 머리 끝 `tools` 자리에 부른 쪽 단추([3D])가 앉는다.
* ========================================================================== */
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
@@ -42,16 +42,20 @@ export interface RouteEditChrome {
terrainSelect: HTMLSelectElement;
}
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(): RouteEditChrome {
/** 뼈대를 만들어 `parent` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(
parent: HTMLElement,
tools: HTMLElement[] = [],
): RouteEditChrome {
const overlay = document.createElement("div");
overlay.className = "ui-modal__backdrop b05-routeedit";
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box ui-popup" role="dialog" aria-label="계획노선 편집">
<div class="ui-modal__top b05-routeedit__head">
<h3>계획노선 편집</h3>
<div class="b05-routeedit__box" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<h3>계획노선</h3>
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<span data-slot="tools"></span>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 영선 · 토공 최소 · 기울기 최소 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
@@ -76,12 +80,12 @@ export function createRouteEditChrome(): RouteEditChrome {
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset" disabled
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [확인]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
title="전처리 때 만든 초기값 노선(예상노선 추적)으로 되돌립니다 — 다시 계산은 [노선 반영]에서">초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
title="마지막 [노선 반영] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">반영 전으로</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply"
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">확인</button>
title="이 노선으로 배수유역부터 다시 계산합니다 — 결과가 새 초기값이 되고 종횡단 · 구조물 · 관 옵션 편집은 새 노선 기본값으로 바뀝니다">노선 반영</button>
</span>
<span data-slot="close"></span>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
@@ -117,13 +121,23 @@ export function createRouteEditChrome(): RouteEditChrome {
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
<aside class="b05-routeedit__side-wrap">
<button type="button" class="ui-btn ui-btn--ghost b05-routeedit__side-toggle"
data-act="side-toggle" aria-expanded="true" title="오른쪽 횡단 미리보기 칸 접기 · 펴기">횡단</button>
<div class="b05-routeedit__side"></div>
</aside>`;
parent.append(overlay);
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
const closeButton = createButton({ label: "✕", variant: "ghost" });
closeButton.classList.add("b05-routeedit__close");
closeButton.setAttribute("aria-label", "닫기");
overlay.querySelector('[data-slot="close"]')!.replaceWith(closeButton);
// 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
const sideWrap = overlay.querySelector<HTMLElement>(".b05-routeedit__side-wrap")!;
const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
const setSide = (open: boolean): void => {
sideWrap.classList.toggle("is-collapsed", !open);
sideToggle.setAttribute("aria-expanded", String(open));
};
setSide(window.innerWidth >= 1500);
sideToggle.addEventListener("click", () => setSide(sideWrap.classList.contains("is-collapsed")));
const measureCloseButton = createButton({ label: "✕", variant: "ghost" });
measureCloseButton.classList.add("b05-routeedit__measure-close");
@@ -42,6 +42,8 @@ export interface CurveBarState {
export interface CurveBarParams {
canvas: HTMLCanvasElement;
/** 패널이 앉을 칸(편집기 뿌리 · 345-3). */
container: HTMLElement;
state: () => CurveBarState;
/** 그리기 좌표로 옮긴다. 돌린 지도에서는 **돌린 뒤 자리**를 줘야 패널이 노드 옆에 붙는다. */
toScreen: (vertex: Vertex) => [number, number];
@@ -58,62 +60,67 @@ export interface CurveBar {
}
export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
const label = createCurveLabel(
{
onClose: () => params.onUnselect(),
onRadius: (value) => {
const { picked, curveRadius } = params.state();
if (picked < 0) return;
curveRadius[picked] = value;
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.",
);
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
},
onArcLength: (value) => {
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
if (picked < 0) return;
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
curveArc[picked] = value;
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
params.applyEdit(
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
);
},
onLock: (lock) => {
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
if (picked < 0) return;
curveLock[picked] = lock;
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
if (lock === "arc") {
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
}
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
params.applyEdit(
lock === "radius"
? "반지름을 고정했습니다."
: lock === "arc"
? "곡선 길이를 고정했습니다."
: "고정을 풀었습니다.",
);
},
onApexLock: (locked) => {
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
if (picked < 0) return;
curveOn[picked] = on;
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
},
});
params.container,
);
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
@@ -169,7 +176,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
// 패널 자리는 **화면 좌표**로 넘긴다(칸 안 좌표로는 `_Label` 이 바꿔 적음).
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
@@ -11,6 +11,7 @@
* ========================================================================== */
import { keyLetter } from "../A00_Common/key_shortcut";
import { setPlanUndoListening, undoOwner } from "./B05_Profile_UI_RouteEdit_UndoOwner";
export type Vertex = [number, number];
@@ -126,10 +127,12 @@ export interface HistoryControlsParams {
/** [되돌리기]·[다시하기] 단추와 단축키(Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z)를 붙인다.
*
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. `dispose` 는 창을
* 닫을 때 부른다 — 단축키를 창(window)에 달았기 때문에 안 떼면 닫힌 뒤에도 살아 있다. */
* 돌려주는 `sync` 를 편집이 끝날 때마다 부르면 단추 켜짐이 맞춰진다. 단축키는 `listen(true)` 부터
* 듣는다(보기가 보일 때 · 345-3) — 창(window)에 달았기 때문에 숨을 때 · 떠날 때 `listen(false)`.
* 종단도도 같은 키를 들어 **마지막에 만진 판**만 받는다(`_UndoOwner`). */
export function bindHistoryControls(params: HistoryControlsParams): {
sync: () => void;
listen: (on: boolean) => void;
dispose: () => void;
} {
const { overlay, getHistory, restore } = params;
@@ -155,14 +158,18 @@ export function bindHistoryControls(params: HistoryControlsParams): {
redoBtn.addEventListener("click", () => step("redo"));
const onKey = (event: KeyboardEvent): void => {
if (!(event.ctrlKey || event.metaKey)) return;
if (!(event.ctrlKey || event.metaKey) || undoOwner() !== "plan") return;
const key = keyLetter(event);
if (key === "z" && !event.shiftKey) step("undo");
else if (key === "y" || (key === "z" && event.shiftKey)) step("redo");
else return;
event.preventDefault();
};
window.addEventListener("keydown", onKey);
const listen = (on: boolean): void => {
window.removeEventListener("keydown", onKey);
if (on) window.addEventListener("keydown", onKey);
setPlanUndoListening(on);
};
return { sync, dispose: () => window.removeEventListener("keydown", onKey) };
return { sync, listen, dispose: () => listen(false) };
}
+19 -16
View File
@@ -10,8 +10,8 @@
* **고정 단추**로 정한다(둘 다 붙들 수는 없다 — `_Edits.ts` 설명 참고).
*
* **자리**(2026-09-07 사용자 지시)
* · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라
* 안에 두면 가장자리에서 **잘린다**.
* · 몸통은 편집 칸(`container`) 안에 `position: absolute` 로 띄운다(345-3 · 본문 평면) — 셈은
* 화면(viewport) 좌표로 하고 적을 때만 칸 왼쪽 위만큼 뺀다.
* · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단
* 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와
* 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다.
@@ -97,7 +97,7 @@ export interface CurveLabelHandlers {
export interface CurveLabel {
show: (state: CurveLabelState) => void;
hide: () => void;
/** 창을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
/** 편집기를 버릴 때 — 몸통을 칸에서 뗀다. */
destroy: () => void;
}
@@ -121,8 +121,11 @@ function unitLabel(text: string): HTMLSpanElement {
return span;
}
/** 패널을 만든다. 몸통은 `document.body` 에 붙어 모달 밖으로도 넘어간다. */
export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
/** 패널을 만든다. 몸통은 `container`(편집 칸 · position 이 있는 칸) 안에 붙는다. */
export function createCurveLabel(
handlers: CurveLabelHandlers,
container: HTMLElement = document.body,
): CurveLabel {
const root = document.createElement("div");
root.className = "b05-routeedit__label";
root.hidden = true;
@@ -138,7 +141,13 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<span data-slot="lock-apex"></span>
</div>
<span class="b05-routeedit__curve-info"></span>`;
document.body.append(root);
container.append(root);
/** 화면 좌표 → 칸 안 좌표로 적는다. */
const moveTo = (left: number, top: number): void => {
const origin = container.getBoundingClientRect();
root.style.left = `${Math.round(left - origin.left)}px`;
root.style.top = `${Math.round(top - origin.top)}px`;
};
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
const seatText = root.querySelector<HTMLElement>(".b05-routeedit__curve-label")!;
@@ -229,12 +238,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
head.addEventListener("pointerdown", (event) => {
if ((event.target as HTMLElement).closest("button")) return; // 단추는 단추대로.
dragFrom = {
x: event.clientX,
y: event.clientY,
left: root.offsetLeft,
top: root.offsetTop,
};
const box = root.getBoundingClientRect();
dragFrom = { x: event.clientX, y: event.clientY, left: box.left, top: box.top };
head.setPointerCapture(event.pointerId);
event.preventDefault();
});
@@ -248,8 +253,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
root.offsetHeight,
limit,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
moveTo(left, top);
// 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다.
manual = [left - anchor[0], top - anchor[1]];
});
@@ -278,8 +282,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
height,
state.bounds,
);
root.style.left = `${Math.round(left)}px`;
root.style.top = `${Math.round(top)}px`;
moveTo(left, top);
}
/** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */
@@ -0,0 +1,33 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_UndoOwner.ts
* 한 화면 두 이력(345-3) — 평면(노선 편집)과 종단도가 둘 다 창(window)에서 Ctrl+Z · Y 를 듣는다.
* **마지막에 만진 판 하나만** 받는다: 누른 자리(평면 `.b05-routeedit` · 종단 판 `.b05-route-profile`)를
* 기억해 두고, 각 듣는 쪽이 받기 전에 묻는다. 평면이 안 듣는 동안(보기 숨김 · 떠남)은 늘 종단도.
* 한 줄 되돌리기로 합치는 일은 346-4.
* ========================================================================== */
export type UndoPanel = "plan" | "profile";
let last: UndoPanel = "profile";
let planListening = false;
let bound = false;
function remember(event: Event): void {
const target = event.target as Element | null;
if (!target?.closest) return;
if (target.closest(".b05-routeedit")) last = "plan";
else if (target.closest(".b05-route-profile")) last = "profile";
}
/** 평면 이력이 듣기 시작 · 멈춤 — 보기가 보일 때 켜고 숨을 때 끈다. */
export function setPlanUndoListening(on: boolean): void {
planListening = on;
if (bound) return;
bound = true;
window.addEventListener("pointerdown", remember, true);
}
/** 지금 Ctrl+Z · Y 를 받을 판. */
export function undoOwner(): UndoPanel {
return planListening && last === "plan" ? "plan" : "profile";
}
+2 -11
View File
@@ -36,9 +36,8 @@
}
/* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다.
3D 뷰포트를 밀어내지 않고 그 위를 덮는 오버레이로 띄운다 — 패널을 여닫아도 3D 뷰
크기가 변하지 않아 카메라 시점과 렌더 비용이 그대로 유지된다.
접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */
페이지 안에서는 위 칸(평면 · 횡단 카드) 아래 **흐름**에 선다(`design_page.css` · 345-3) —
여기 absolute 는 페이지 밖 쓰임 꼴. 접기 핸들로 내리면 위 칸이 전체 영역을 갖는다. */
.b05-route-profile {
position: absolute;
z-index: 3;
@@ -639,11 +638,3 @@ html[data-size="compact"] .b05-route__view-group {
min-height: 0;
border-radius: var(--radius-md);
}
/* 가운데 위 [3D] 단추 자리 — 노선 편집 본문이 서기 전 임시 칸. */
.b05-route__plan-tools {
display: flex;
flex: none;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-16);
}
+45 -26
View File
@@ -1,10 +1,13 @@
/* 계획노선 편집 모달 — 큰 모달 하나. 계산이 오래 걸리는 조작이라 화면을 통째로 덮는다
(2026-09-06 사용자 확정). 색은 전부 테마 토큰을 쓴다.
배경(덮기 · 겹 · 색)은 공용 `.ui-modal__backdrop` — 여기는 칸 사이 · 가장자리만. */
/* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
.b05-routeedit {
gap: var(--spacing-12);
padding: var(--spacing-12);
position: relative;
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
min-height: 0;
padding: var(--spacing-8);
}
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
@@ -18,18 +21,25 @@
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-xl);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
box-shadow: var(--shadow-lg);
}
/* 제목줄 꼴(가로 · 가운데 · 간격)은 공용 `.ui-modal__top` — 여기는 띠 테두리만. */
/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
.b05-routeedit__head {
display: flex;
flex: none;
padding: var(--spacing-12) var(--spacing-16);
align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.b05-routeedit__head > h3 {
margin: 0;
font-size: var(--text-body);
}
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions {
@@ -131,21 +141,31 @@
border-top: 2px solid var(--map-route, #f97316);
}
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
.b05-routeedit__side {
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
.b05-routeedit__side-wrap {
display: flex;
flex: none;
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: 100%;
gap: var(--spacing-8);
min-height: 0;
}
@media (width < 1500px) {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
.b05-routeedit__side {
display: none;
}
.b05-routeedit__side-toggle {
align-self: flex-end;
}
.b05-routeedit__side {
display: flex;
flex: 1 1 auto;
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
min-height: 0;
}
.b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side {
display: none;
}
.b05-routeedit__cross {
@@ -280,11 +300,11 @@
line-height: 1.5;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(노선 재탐색 · CLAUDE.md 5장). */
.b05-routeedit__busy {
position: absolute;
position: fixed;
inset: 0;
z-index: 2;
z-index: var(--z-modal, 1000);
display: flex;
align-items: center;
justify-content: center;
@@ -298,10 +318,9 @@
예전에는 모달 맨 아랫줄이라 지금 고른 것이 지도 어디인지 눈으로 안 이어졌다.
자리는 `_Label.ts` 가 매 프레임 잡아 준다 — 여기서는 모양만 정한다. */
.b05-routeedit__label {
/* `document.body` 에 붙여 **화면 기준**으로 띄운다 — 모달이 `overflow: hidden` 이라
안에 두면 가장자리에서 잘린다(2026-09-07 사용자 지시 1). */
position: fixed;
z-index: calc(var(--z-modal, 1000) + 1);
/* 편집 칸 안 절대 위치(345-3) — 자리는 지도 칸 안으로 가둔다(`_Label`). */
position: absolute;
z-index: 3;
display: flex;
flex-direction: column;
gap: 4px;
+2 -5
View File
@@ -176,9 +176,6 @@ const panel = design.createDesignPanel(
parts: {
structures: { root: node("section"), listRoot: node() },
settings: { nodes: [text("a")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") },
standardSlot: text("std"),
},
@@ -189,7 +186,7 @@ const panel = design.createDesignPanel(
const byTitle = (title) =>
panel.root.querySelectorAll(".ui-collapsible").find((s) => titleOf(s) === title);
out.profileFirst = folds(panel.root);
const route = byTitle("계획노선");
const route = byTitle("페이지 설정"); // 345-3 계획노선 칸 지움 — 접힌 채 서는 칸
press(panel.root, route); // 사용자가 폄(공용 attachCollapsible)
panel.setView("cross");
out.crossFirst = folds(panel.root);
@@ -197,7 +194,7 @@ press(panel.root, byTitle("B06_View_Title"));
panel.setView("profile");
panel.setView("cross");
out.afterRedraw = folds(panel.root);
out.sameRouteNode = byTitle("계획노선") === route;
out.sameRouteNode = byTitle("페이지 설정") === route;
// ── B07 도면 목록 ─────────────────────────────────────────────────────────────
const b07 = loadTs(path.join(ROOT, "B07_DesignDetail", "B07_DesignDetail_UI_Panels.ts"));
@@ -0,0 +1,262 @@
/* 343-3 브라우저 보관함(b_asset_cache) — 진짜 TS 를 가짜 IndexedDB 위에서 돌려 JSON 으로 냄.
* 저장 끝 = 트랜잭션 완료 · 실패 한 줄 경고 · 열기 막힘 · 실패 null 안 기억 · 색인 열쇠 지우기.
* 끝에 점군 이진 풀기(decodePointPreview) — 인자로 받은 파이썬 이진 파일을 풀어 냄. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const load = (rel, requireStub) => {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, rel), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const exports = {};
new Function("exports", "require", "window", js)(exports, requireStub, global.window);
return exports;
};
const later = (fn) => setTimeout(fn, 0);
global.IDBKeyRange = {
upperBound: (v, open) => ({ test: (k) => (open ? k < v : k <= v) }),
lowerBound: (v, open) => ({ test: (k) => (open ? k > v : k >= v) }),
bound: (lo, hi) => ({ test: (k) => k >= lo && k <= hi }),
};
function makeIdb(options = {}) {
const data = new Map();
const stats = { valueReads: 0, opens: 0 };
let failLeft = options.failOpenTimes ?? 0;
let created = false;
function makeTx() {
const tx = { oncomplete: null, onabort: null, error: null };
let pending = 0;
let aborted = false;
let done = false;
const finish = () =>
later(() => {
if (pending !== 0 || done) return;
done = true;
if (aborted) tx.onabort && tx.onabort();
else tx.oncomplete && tx.oncomplete();
});
const req = (fn) => {
const r = { onsuccess: null, onerror: null, result: undefined };
pending += 1;
later(() => {
r.result = fn();
if (r.onsuccess) r.onsuccess();
pending -= 1;
finish();
});
return r;
};
const store = {
get: (key) =>
req(() => {
stats.valueReads += 1;
return data.get(key);
}),
put: (value) =>
req(() => {
// 용량 초과 — 요청은 성공으로 끝나도 트랜잭션이 통째 취소됨(실제 브라우저와 같음)
if (options.abortPuts) {
aborted = true;
tx.error = { name: "QuotaExceededError" };
} else data.set(value.key, value);
return value.key;
}),
delete: (keyOrRange) =>
req(() => {
for (const k of [...data.keys()]) {
if (typeof keyOrRange === "string" ? k === keyOrRange : keyOrRange.test(k)) {
data.delete(k);
}
}
}),
openCursor: () => {
throw new Error("값 커서를 쓰면 안 됨(값을 읽음)");
},
index: () => ({
openKeyCursor: (range) => {
const keys = [...data.entries()]
.filter(([, v]) => range.test(v.projectId))
.map(([k]) => k);
let i = 0;
const r = { onsuccess: null, result: null };
pending += 1;
const step = () =>
later(() => {
if (i < keys.length) {
r.result = { primaryKey: keys[i], continue: () => ((i += 1), step()) };
r.onsuccess && r.onsuccess();
} else {
r.result = null;
r.onsuccess && r.onsuccess();
pending -= 1;
finish();
}
});
step();
return r;
},
}),
};
tx.objectStore = () => store;
return tx;
}
const indexedDB = {
open: () => {
stats.opens += 1;
const r = {};
later(() => {
if (options.blockOpen) return r.onblocked && r.onblocked();
if (failLeft > 0) {
failLeft -= 1;
r.error = { name: "UnknownError" };
return r.onerror && r.onerror();
}
const db = {
objectStoreNames: { contains: () => created },
createObjectStore: () => ({ createIndex() {} }),
deleteObjectStore() {},
transaction: () => makeTx(),
close() {},
};
r.result = db;
if (!created) {
created = true;
r.onupgradeneeded && r.onupgradeneeded();
}
r.onsuccess && r.onsuccess();
});
return r;
},
};
return { indexedDB, data, stats };
}
const warnings = [];
console.warn = (...args) => warnings.push(args.join(" "));
let fetches = 0;
global.fetch = async (url, init) => {
fetches += 1;
const conditional = init && init.headers && init.headers["If-None-Match"];
return {
ok: !conditional,
status: conditional ? 304 : 200,
headers: { get: (name) => (name.toLowerCase() === "etag" ? '"e1"' : null) },
body: null,
arrayBuffer: async () => new TextEncoder().encode(`body:${url}`).buffer,
};
};
const stub = (id) => {
if (id.includes("config_frontend")) return { API_BASE_URL: "http://x/api" };
return {};
};
const fresh = (options) => {
const idb = makeIdb(options);
global.window = { indexedDB: idb.indexedDB };
warnings.length = 0;
fetches = 0;
return { idb, cache: load("A00_Common/b_asset_cache.ts", stub) };
};
const text = (buffer) => new TextDecoder().decode(buffer);
(async () => {
const out = {};
// 1. 받고 저장 → 두 번째는 보관본(네트워크는 뒤 확인 한 번뿐)
{
const { idb, cache } = fresh();
const first = text(await cache.fetchCachedBytes("P", "http://x/a"));
const afterFirst = fetches;
const second = text(await cache.fetchCachedBytes("P", "http://x/a"));
out.store = {
first,
second,
stored: idb.data.has("P|http://x/a"),
afterFirst,
warnings: [...warnings],
};
}
// 2. 용량 초과 — 저장이 취소되면 한 줄 경고 · 다음엔 다시 받음
{
const { idb, cache } = fresh({ abortPuts: true });
await cache.fetchCachedBytes("P", "http://x/a");
await cache.writeCachedBytes("P", "http://x/b", new ArrayBuffer(4));
const before = fetches;
await cache.fetchCachedBytes("P", "http://x/a");
out.quota = { stored: idb.data.size, warnings: [...warnings], refetched: fetches - before };
}
// 3. 열기 실패 한 번 — null 을 기억하지 않고 다음 부름에서 다시 엶
{
const { idb, cache } = fresh({ failOpenTimes: 1 });
const missed = await cache.readCachedBytes("P", "http://x/a");
await cache.writeCachedBytes("P", "http://x/a", new TextEncoder().encode("v").buffer);
const hit = await cache.readCachedBytes("P", "http://x/a");
out.retry = {
missed,
hit: hit ? text(hit) : null,
opens: idb.stats.opens,
warnings: [...warnings],
};
}
// 4. 다른 탭이 막음 — 기다리지 않고 보관 없이 받음
{
const { cache } = fresh({ blockOpen: true });
const body = text(await cache.fetchCachedBytes("P", "http://x/a"));
out.blocked = { body, warnings: [...warnings] };
}
// 5. 지우기 — 색인 열쇠만(값 안 읽음) · 앞머리 범위
{
const { idb, cache } = fresh();
const buf = new ArrayBuffer(2);
await cache.writeCachedBytes("P", "http://x/corridor/1", buf);
await cache.writeCachedBytes("P", "http://x/corridor/2", buf);
await cache.writeCachedBytes("P", "http://x/keep", buf);
await cache.writeCachedBytes("Q", "http://x/keep", buf);
await cache.writeCachedBytes("A", "http://x/keep", buf);
const readsBefore = idb.stats.valueReads;
await cache.purgeOtherProjects("P");
await cache.purgeAssetsWithPrefix("P", "http://x/corridor/");
out.purge = {
keys: [...idb.data.keys()].sort(),
valueReads: idb.stats.valueReads - readsBefore,
};
}
// 6. 등고선 주소 한 벌
{
const { cache } = fresh();
out.contour = [
cache.contourAssetUrl("P", 7, 1, false),
cache.contourAssetUrl("P", 7, 2, true, true),
];
}
// 7. 점군 이진 풀기(파이썬이 만든 파일)
if (process.argv[2]) {
global.window = {};
const api = load("B04_PreProcess/B04_PreProcess_Api_Fetch.ts", stub);
const bytes = fs.readFileSync(process.argv[2]);
const buffer = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
const decoded = api.decodePointPreview(buffer);
out.preview = {
point_count: decoded.point_count,
sampled_count: decoded.sampled_count,
bounds: decoded.bounds,
first: decoded.points[0],
last: decoded.points[decoded.points.length - 1],
rgbFirst: decoded.rgb ? decoded.rgb[0] : null,
};
}
process.stdout.write(JSON.stringify(out));
})().catch((error) => {
process.stderr.write(String(error && error.stack ? error.stack : error));
process.exit(1);
});
@@ -0,0 +1,91 @@
/* 345-3 Ctrl+Z · Y 한 판만 헬퍼 — `_RouteEdit_History`(평면 이력 단추 · 단축키)와 `_RouteEdit_UndoOwner` 를
* 트랜스파일해 가짜 창(window)에서 돌림. 종단도 쪽 듣는 자리는 `_Profile_Panel_Tools` 와 같은 꼴
* (`undoOwner() === "plan"` 이면 안 받음)로 흉내. 결과는 JSON 한 벌(표준출력).
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const listeners = {};
global.window = {
addEventListener: (type, fn) => (listeners[type] ??= new Set()).add(fn),
removeEventListener: (type, fn) => listeners[type]?.delete(fn),
};
const fire = (type, event) => [...(listeners[type] ?? [])].forEach((fn) => fn(event));
const cache = new Map();
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) =>
request.startsWith(".")
? loadTs(`${path.resolve(path.dirname(full), request)}.ts`)
: require(request);
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const page = path.join(ROOT, "B05_Profile");
const { bindHistoryControls } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_History.ts"));
const { undoOwner } = loadTs(path.join(page, "B05_Profile_UI_RouteEdit_UndoOwner.ts"));
const button = () => ({ disabled: false, addEventListener: () => undefined });
const buttons = { '[data-act="undo"]': button(), '[data-act="redo"]': button() };
const overlay = { querySelector: (sel) => buttons[sel] };
const plan = [];
const history = {
undo: () => (plan.push("undo"), { tag: "u" }),
redo: () => (plan.push("redo"), { tag: "r" }),
canUndo: () => true,
canRedo: () => true,
};
const controls = bindHistoryControls({
overlay,
getHistory: () => history,
restore: () => undefined,
});
// 종단도 듣는 자리 흉내 — `_Profile_Panel_Tools` 의 Ctrl+Z 줄과 같은 거름.
const profile = [];
window.addEventListener("keydown", (event) => {
if (!(event.ctrlKey && event.key === "z")) return;
if (undoOwner() === "plan") return;
profile.push(event.shiftKey ? "redo" : "undo");
});
const key = (k, shift = false) =>
fire("keydown", { ctrlKey: true, key: k, code: "", shiftKey: shift, preventDefault() {} });
const press = (cls) =>
fire("pointerdown", { target: { closest: (sel) => (sel === `.${cls}` ? {} : null) } });
const snap = () => ({ plan: [...plan], profile: [...profile], owner: undoOwner() });
const out = {};
key("z"); // 듣기 전 — 종단도만
out.beforeListen = snap();
controls.listen(true);
key("z"); // 아직 아무 판도 안 만짐 — 종단도
out.untouched = snap();
press("b05-routeedit");
key("z");
key("y");
out.afterPlanPress = snap();
press("b05-route-profile");
key("z");
out.afterProfilePress = snap();
press("b05-routeedit");
controls.listen(false); // 보기 숨김 — 평면 이력은 안 들음
key("z");
out.hidden = snap();
controls.listen(true);
key("z", true); // 다시 보임 · 마지막에 만진 판 = 평면 · Shift+Z = 다시하기
out.shown = snap();
controls.dispose();
key("z");
out.disposed = snap();
console.log(JSON.stringify(out));
@@ -197,9 +197,6 @@ const built = panel.createDesignPanel(
parts: {
structures: sample(),
settings: { nodes: [text("a"), text("b")] },
route: {
plannedRouteButton: text("plan"),
},
crossView: { row: text("cross-row") },
standardSlot: text("std"),
},
+4 -3
View File
@@ -42,15 +42,16 @@ def out():
def test_B05_B06_첫_그림_접힘(out):
assert out["profileFirst"] == {
"페이지 설정": True,
"계획노선": True,
}
assert out["crossFirst"]["B06_View_Title"] is True
assert out["crossFirst"]["B06_Std_Title"] is True # 옛 기본 접힘 그대로
def test_B05_B06_편_뒤_다시_그림_유지(out):
assert out["crossFirst"]["계획노선"] is False # 편 것 → 보기 바꿔도 그대로
assert out["afterRedraw"]["계획노선"] is False
assert (
out["crossFirst"]["페이지 설정"] is False
) # 편 것 → 보기 바꿔도 그대로(345-3 계획노선 칸 지움)
assert out["afterRedraw"]["페이지 설정"] is False
assert out["afterRedraw"]["B06_View_Title"] is False
assert out["sameRouteNode"] is True
@@ -0,0 +1,197 @@
"""343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 켤 때 받기 · ETag.
`helper_343_3_asset_cache.cjs` = 진짜 TS(b_asset_cache · decodePointPreview) + 가짜 IndexedDB.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import numpy as np
import pytest
import B04_PreProcess.B04_PreProcess_Engine_PointPreview as preview
ROOT = Path(__file__).resolve().parents[2]
HERE = Path(__file__).resolve().parent
def _npz(path: Path, count: int, rgb: bool = True) -> tuple[np.ndarray, np.ndarray]:
rng = np.random.default_rng(3)
xyz = np.column_stack(
(
rng.uniform(200000, 200100, count),
rng.uniform(500000, 500100, count),
rng.uniform(90, 120, count),
)
).astype(np.float32)
colors = rng.integers(0, 65535, (count, 3)).astype(np.uint16)
bounds = np.array([[200000, 200100], [500000, 500100], [90, 120]], dtype=np.float64)
arrays = {"xyz": xyz, "bounds": bounds}
if rgb:
arrays["rgb"] = colors
np.savez_compressed(path, **arrays)
return xyz, colors
def _decode(path: Path) -> tuple[dict, np.ndarray, np.ndarray | None]:
raw = path.read_bytes()
length = int.from_bytes(raw[:4], "little")
head = json.loads(raw[4 : 4 + length])
offset = -(-(4 + length) // 4) * 4
count = head["sampled_count"]
xyz = np.frombuffer(raw, "<f4", count * 3, offset).reshape(-1, 3)
rgb = None
if head["has_rgb"]:
rgb = np.frombuffer(raw, "<u2", count * 3, offset + count * 12).reshape(-1, 3)
return head, xyz, rgb
def test_고정_표본은_JSON_길과_같은_점(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(preview, "POINT_CLOUD_SAMPLE_LIMIT", 50)
source = tmp_path / "structured.npz"
xyz, colors = _npz(source, 200)
head, sample, rgb = _decode(preview.ensure_point_preview(source, None))
# 옛 JSON 길과 같은 규칙(같은 씨앗 · 같은 float32)
indexes = np.random.default_rng(20260710).choice(200, 50, replace=False)
assert head["point_count"] == 200 and head["sampled_count"] == 50
assert np.array_equal(sample, xyz[indexes]) and np.array_equal(rgb, colors[indexes])
assert head["bounds"]["x_min"] == 200000 and head["bounds"]["z_max"] == 120
def test_원천이_그대로면_다시_안_만듦(tmp_path: Path) -> None:
source = tmp_path / "ground_points_csf.npz"
_npz(source, 30, rgb=False)
target = preview.ensure_point_preview(source, "csf")
assert target.name == "preview_points_csf.bin"
stamp = target.stat().st_mtime_ns
assert preview.ensure_point_preview(source, "csf").stat().st_mtime_ns == stamp
_npz(source, 40, rgb=False)
head, _, rgb = _decode(preview.ensure_point_preview(source, "csf"))
assert head["sampled_count"] == 40 and rgb is None
@pytest.fixture(scope="module")
def out(tmp_path_factory: pytest.TempPathFactory) -> dict:
folder = tmp_path_factory.mktemp("p343")
source = folder / "structured.npz"
xyz, colors = _npz(source, 25)
target = preview.ensure_point_preview(source, None)
proc = subprocess.run(
["node", str(HERE / "helper_343_3_asset_cache.cjs"), str(target)],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
data = json.loads(proc.stdout)
data["expected"] = {
"first": xyz[0].tolist(),
"last": xyz[-1].tolist(),
"rgb": colors[0].tolist(),
}
return data
def test_보관함_저장_뒤_두번째는_보관본(out: dict) -> None:
store = out["store"]
assert store["stored"] and store["first"] == store["second"] == "body:http://x/a"
assert store["warnings"] == []
def test_용량_초과면_한_줄_경고_다음엔_다시_받음(out: dict) -> None:
quota = out["quota"]
assert quota["stored"] == 0 and quota["refetched"] == 1
assert len(quota["warnings"]) == 1 # 같은 까닭은 한 번만
assert "저장" in quota["warnings"][0] and "QuotaExceededError" in quota["warnings"][0]
def test_열기_실패를_끝까지_기억하지_않음(out: dict) -> None:
retry = out["retry"]
assert retry["missed"] is None and retry["hit"] == "v" and retry["opens"] == 2
assert any("열기" in line for line in retry["warnings"])
def test_다른_탭이_막아도_멈추지_않음(out: dict) -> None:
blocked = out["blocked"]
assert blocked["body"] == "body:http://x/a"
assert any("막음" in line for line in blocked["warnings"])
def test_지우기는_색인_열쇠만(out: dict) -> None:
purge = out["purge"]
assert purge["keys"] == ["P|http://x/keep"] and purge["valueReads"] == 0
def test_등고선_주소_한_벌(out: dict) -> None:
assert out["contour"] == [
"http://x/api/projects/P/surface/models/7/contour?interval=1&smooth=false&recalculate=false",
"http://x/api/projects/P/surface/models/7/contour?interval=2&smooth=true&recalculate=true",
]
source = (ROOT / "B04_PreProcess/B04_PreProcess_UI_TerrainViewer_Contours.ts").read_text(
encoding="utf-8"
)
assert "contourAssetUrl(" in source and "/contour?interval" not in source
def test_TS_가_파이썬_이진을_같은_값으로_풂(out: dict) -> None:
decoded, expected = out["preview"], out["expected"]
assert decoded["point_count"] == decoded["sampled_count"] == 25
assert decoded["first"] == expected["first"] and decoded["last"] == expected["last"]
assert decoded["rgbFirst"] == expected["rgb"]
assert decoded["bounds"]["y_max"] == 500100
def test_꺼진_층은_켤_때_받음() -> None:
viewer = (ROOT / "B04_PreProcess/B04_PreProcess_UI_MapViewer.ts").read_text(encoding="utf-8")
assert "GIS_LAYERS.filter((layer) => activeGisLayers.has(layer))" in viewer
assert "activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer))" in viewer
assert len(viewer.splitlines()) <= 700
def test_남은_층은_파일_그대로_ETag() -> None:
source = (ROOT / "B04_PreProcess/B04_PreProcess_Router_GIS.py").read_text(encoding="utf-8")
start = source.index("async def get_project_geojson")
body = source[start : source.index("@tiles_router", start)]
assert "json.loads(filepath" not in body and "json.loads(simplified" not in body
assert body.rstrip().count("cached_file_response(") >= 2
def test_화면은_이진_길로_점군을_받음() -> None:
api = (ROOT / "B04_PreProcess/B04_PreProcess_Api_Fetch.ts").read_text(encoding="utf-8")
assert "/surface/point-cloud/binary" in api and "fetchCachedBytes(" in api
def test_전처리가_미리보기_이진을_한_번_만듦(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
import laspy
import B04_PreProcess.B04_PreProcess_Engine as engine
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
rng = np.random.default_rng(1)
header = laspy.LasHeader(point_format=3, version="1.2")
header.offsets = [200000, 500000, 0]
header.scales = [0.01, 0.01, 0.01]
las_data = laspy.LasData(header)
las_data.x = rng.uniform(200000, 200060, 3000)
las_data.y = rng.uniform(500000, 500060, 3000)
las_data.z = rng.uniform(100, 101, 3000)
las_data.classification = np.full(3000, 2, np.uint8)
las_path = tmp_path / "B03_FileInput" / "input" / "t.las"
las_path.parent.mkdir(parents=True)
las_data.write(las_path)
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: None)
monkeypatch.setattr(sheet, "build_sheet_surface_from_route", lambda *a, **k: [])
result = engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
processed = tmp_path / "B04_PreProcess" / "processed"
made = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")}
assert "preview_points_raw.bin" in made
assert {f"preview_points_{key}.bin" for key in result["source_filters"]} <= set(made)
engine.run_surface_analysis(tmp_path, las_path, source_filters=None, methods=["dtm"])
again = {path.name: path.stat().st_mtime_ns for path in processed.glob("preview_points_*.bin")}
assert again == made
+1 -1
View File
@@ -64,6 +64,6 @@ def test_배선_3D_상주_없음_죽은_길_지움():
for gone in ("beginMoveSelected", "deleteSelected", "updateSelected", "panel.setSelected"):
assert gone not in view
assert "selected-point" not in _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts")
assert "selectedNodes" not in _read("B05_Profile/B05_Profile_UI_Panel_Route.ts")
assert not (ROOT / "B05_Profile/B05_Profile_UI_Panel_Route.ts").exists() # 345-3 칸째 지움
assert "beginMoveSelected" not in _read("B05_Profile/B05_Profile_UI_Viewer.ts")
assert len(view.splitlines()) <= 700
+93
View File
@@ -0,0 +1,93 @@
"""345-3 노선 편집 → 종단 보기 가운데 위 본문 — Ctrl+Z · Y 는 마지막에 만진 판 하나 · 종단도 판 흐름 배치 ·
왼쪽 「계획노선」 칸 · 편집 단추 지움 · [노선 반영] 뒤 새로고침 없이 다시 세움.
Ctrl+Z 몫은 Node 헬퍼 `helper_345_3_undo_owner.cjs` 가 진짜 `_RouteEdit_History` · `_UndoOwner` 로 돌림 ·
배선 · CSS 는 글로 봄(화면 확인은 브레인).
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
def _read(rel: str) -> str:
return (ROOT / rel).read_text(encoding="utf-8")
def _block(css: str, selector: str) -> str:
found = re.search(rf"^{re.escape(selector)} \{{([^}}]*)\}}", css, re.M)
assert found, selector
return found.group(1)
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run(
["node", str(HERE / "helper_345_3_undo_owner.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
check=True,
)
return json.loads(run.stdout)
def test_Ctrl_Z_는_마지막에_만진_판_하나(out):
assert out["beforeListen"] == {"plan": [], "profile": ["undo"], "owner": "profile"}
assert out["untouched"]["plan"] == [] and out["untouched"]["profile"] == ["undo", "undo"]
# 평면을 누른 뒤 — 평면만(종단도 안 움직임)
assert out["afterPlanPress"]["plan"] == ["undo", "redo"]
assert out["afterPlanPress"]["profile"] == ["undo", "undo"]
# 종단 판을 누른 뒤 — 종단도만
assert out["afterProfilePress"]["plan"] == ["undo", "redo"]
assert out["afterProfilePress"]["profile"] == ["undo", "undo", "undo"]
# 보기 숨김(평면 안 들음) — 평면을 마지막에 만졌어도 종단도가 받음
assert out["hidden"]["owner"] == "profile"
assert out["hidden"]["profile"] == ["undo", "undo", "undo", "undo"]
# 다시 보임 — 평면 · Shift+Z = 다시하기
assert out["shown"]["plan"] == ["undo", "redo", "redo"]
assert out["shown"]["profile"] == out["hidden"]["profile"]
# 떠남 — 평면 단축키 뗌
assert out["disposed"]["plan"] == out["shown"]["plan"]
def test_종단도_Ctrl_Z_줄이_평면을_묻는다():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z")) {')
assert 'if (undoOwner() === "plan") return;' in tools[at : at + 160]
history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts")
assert 'undoOwner() !== "plan"' in history
assert (
'window.addEventListener("keydown", onKey);\n\n return' not in history
) # 만들 때 안 붙음
def test_편집기는_본문_페이지_수명():
view = _read("A00_Common/design/design_view_profile.ts")
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
assert "tools: [view3d.modal.button]," in view
assert "openRouteEditModal" not in view and "location.reload()" not in view
assert len(view.splitlines()) <= 700
def test_종단도_판은_두_보기_모두_흐름():
css = _read("A00_Common/design/design_page.css")
rule = _block(css, ".design-page__main > .design-page__profile > .b05-route-profile")
assert "position: relative;" in rule and "flex: none;" in rule
assert '[data-view="cross"] > .design-page__profile > .b05-route-profile' not in css
def test_계획노선_칸_편집_단추_지움():
sections = _read("B05_Profile/B05_Profile_UI_Panel_Sections.ts")
assert '"planned-route"' not in sections and "plannedRouteButton" not in sections
panel = _read("B05_Profile/B05_Profile_UI_Panel.ts")
assert "onEditPlannedRoute" not in panel and "createRoutePart" not in panel
assert not (ROOT / "B05_Profile/B05_Profile_UI_Panel_Route.ts").exists()
@@ -1,4 +1,10 @@
"""계획노선 편집 겉틀 = 공용 모달 조각(PLAN 127-1) — 배경 · 제목줄은 공용 클래스, 닫기 규칙은 그대로."""
"""계획노선 편집 겉틀 — 345-3 부터 모달이 아니라 종단 보기 가운데 위 본문(PLAN 127-1 의 공용 모달 조각은 걷음).
· 덮개(`ui-modal__backdrop`) · 전체 화면 · 닫기 단추 없음 · 부모를 받아 붙음(`document.body` 아님)
· [취소] → [반영 전으로](이력 처음으로) · [확인] → [노선 반영] · 머리 끝 `tools` 자리([3D])
· 오른쪽 횡단 미리보기 = 접는 칸 · 재계산 대기 막은 화면 전체(fixed) · R 라벨은 칸 안 절대 위치
· 바깥 누름 · Esc 로 안 닫힘(모달 아님)
"""
from __future__ import annotations
@@ -15,34 +21,54 @@ def _block(css: str, selector: str) -> str:
return found.group(1)
def test_겉틀은_공용_모달_조각():
chrome = (PAGE / "B05_Profile_UI_RouteEdit_Chrome.ts").read_text(encoding="utf-8")
assert 'import "@ui/ui_template_modal.css";' in chrome
assert '"ui-modal__backdrop b05-routeedit"' in chrome
assert '<div class="ui-modal__top b05-routeedit__head">' in chrome
assert "<h3>계획노선 편집</h3>" in chrome
assert "<strong>계획노선 편집</strong>" not in chrome
def _read(name: str) -> str:
return (PAGE / name).read_text(encoding="utf-8")
def test_겉틀은_본문_칸():
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
assert "ui-modal" not in chrome
assert "document.body.append" not in chrome
assert "parent.append(overlay);" in chrome
assert 'overlay.className = "b05-routeedit";' in chrome
assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome
assert '"tools"' in chrome and "replaceWith(...tools)" in chrome
assert ">반영 전으로</button>" in chrome and ">노선 반영</button>" in chrome
assert ">취소</button>" not in chrome and ">확인</button>" not in chrome
# 시험 · 배선이 집는 자리는 남김
for hook in (
'data-act="apply"',
'data-act="cancel"',
"b05-routeedit__close",
"b05-routeedit__measure-close",
):
for hook in ('data-act="apply"', 'data-act="cancel"', "b05-routeedit__measure-close"):
assert hook in chrome
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
def test_겹치는_CSS는_공용에_맡김():
css = (PAGE / "B05_Profile_UI_Style_RouteEdit.css").read_text(encoding="utf-8")
overlay = _block(css, ".b05-routeedit")
for prop in ("position:", "z-index:", "background:", "align-items:"):
assert prop not in overlay
head = _block(css, ".b05-routeedit__head")
for prop in ("display:", "align-items:", "gap:"):
assert prop not in head
def test_CSS_덮개_걷음_대기막은_전체():
css = _read("B05_Profile_UI_Style_RouteEdit.css")
root = _block(css, ".b05-routeedit")
assert "position: relative;" in root and "flex: 1 1 auto;" in root
assert "z-index" not in root and "background" not in root
busy = _block(css, ".b05-routeedit__busy")
assert "position: fixed;" in busy and "z-index: var(--z-modal, 1000);" in busy
label = _block(css, ".b05-routeedit__label")
assert "position: absolute;" in label and "position: fixed" not in label
assert "display: none;" in _block(
css, ".b05-routeedit__side-wrap.is-collapsed > .b05-routeedit__side"
)
assert "@media (width < 1500px)" not in css # 접기는 단추로(처음만 폭으로)
def test_바깥_누름_Esc_로_안_닫힘():
main = (PAGE / "B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert "openModal" not in main
assert "Escape" not in main
def test_페이지_수명_닫기_없음():
main = _read("B05_Profile_UI_RouteEdit.ts")
assert "openModal" not in main and "Escape" not in main
assert "openRouteEditModal" not in main
assert "export async function mountRouteEditor(" in main
assert 'window.addEventListener("resize"' not in main # 칸 크기는 ResizeObserver
assert "new ResizeObserver(" in main
assert "host.onDispose(dispose);" in main
assert "historyControls.listen(host.visible());" in main
assert "history?.reset();" in main # [반영 전으로]
assert len(main.splitlines()) <= 700
label = _read("B05_Profile_UI_RouteEdit_Label.ts")
assert "document.body.append" not in label and "container.append(root);" in label
assert "container: overlay," in main
apply = _read("B05_Profile_UI_RouteEdit_Apply.ts")
assert "close" not in apply.split("export function bindRouteApply", 1)[1]
+16 -4
View File
@@ -98,13 +98,25 @@ def test_진행_단계_두_칸이_보기를_따라감():
def test_초기화_계획노선_편집은_다시_세움():
"""[초기화] · 계획노선 편집 [확인] = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
"""[초기화] · [노선 반영](옛 계획노선 편집 [확인]) = 새 초기값 — 옛 캐시를 다 버리고 페이지를 통째로 다시 읽는다(PLAN 77)."""
reset = _read("A00_Common/design/design_reset.ts")
assert "clearDrafts(projectId);" in reset and "clearResults(projectId);" in reset
assert "location.reload()" in reset
assert "openRouteEditModal(activeProjectId, () => location.reload()" in _read(
"A00_Common/design/design_view_profile.ts"
)
view = _read("A00_Common/design/design_view_profile.ts")
assert "location.reload()" not in view # [노선 반영]은 새로고침 대신 다시 세움(345-3)
rebuild = view[view.index("const rebuildAfterReplan") :]
rebuild = rebuild[: rebuild.index("};")]
for step in (
"clearRouteLatestCache(activeProjectId);",
"invalidateSectionDetail(activeProjectId);",
"remountRoute(currentRoute());",
):
assert step in rebuild
assert rebuild.index("invalidateSectionDetail") < rebuild.index("remountRoute(")
assert "mountRouteEditor(mainContent, host, activeProjectId, rebuildAfterReplan," in view
apply = _read("B05_Profile/B05_Profile_UI_RouteEdit_Apply.ts")
assert apply.index("clearDrafts(projectId);") < apply.index("await params.onApplied();")
assert "clearResults(projectId);" in apply
def test_종단_보기_확정은_횡단_보기를_숨긴_채_세움():
+2 -4
View File
@@ -59,9 +59,8 @@ def results():
def test_보기를_오가도_좌측_패널_단추_구조물_배치는_같은_노드(results):
assert results["sameNodes"] == {"cross": True, "back": True}
# 보기마다 새로 만들지 않는다 — 칸은 처음 보일 때 한 번만 서고 뒤로는 숨김 · 보임만.
assert (
results["childCount"] == 1 + 3 + 2
) # 아래 고정 영역 + 종단 칸 셋 + 횡단 칸 둘(345-2 선택 포인트 지움)
# 아래 고정 영역 + 종단 칸 둘 + 횡단 칸 둘(345-2 선택 포인트 · 345-3 계획노선 칸 지움)
assert results["childCount"] == 1 + 2 + 2
def test_구조물_배치는_두_보기_모두_맨_위_같은_DOM(results):
@@ -81,7 +80,6 @@ def test_보기마다_제_칸만_보인다(results):
assert views["profile"]["shownTitles"] == [
"구조물 배치",
"페이지 설정",
"계획노선",
]
assert views["cross"]["shownTitles"] == ["구조물 배치", "횡단 보기 설정", "표준 횡단면 설정"]
assert views["back"]["shownTitles"] == views["profile"]["shownTitles"]