Merge remote-tracking branches 'origin/main_laptop_1', 'origin/sub_laptop_4', 'origin/sub_laptop_5' and 'origin/sub_laptop_6' into sub_laptop_2
This commit is contained in:
@@ -4,6 +4,12 @@ import {
|
||||
ROUTES,
|
||||
type RoutePath,
|
||||
} from "@config/config_frontend";
|
||||
import {
|
||||
hideLoadingOverlay,
|
||||
setLoadingOverlayProgress,
|
||||
showLoadingOverlay,
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import type { WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||
import {
|
||||
fetchConfirmedSurface,
|
||||
@@ -93,9 +99,9 @@ export async function dropGoneProject(): Promise<boolean> {
|
||||
*/
|
||||
const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, ROUTES.B05_PROFILE];
|
||||
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
export function goToWorkflowStage(projectId: string, route: RoutePath): Promise<void> {
|
||||
// 다른 프로젝트에 들어가면 그 밖 프로젝트 캐시를 먼저 지운다(290-1).
|
||||
void enterProject(
|
||||
return enterProject(
|
||||
projectId,
|
||||
async (id) => (await fetchWorkflowState(id)).project_name || id,
|
||||
).then(() => {
|
||||
@@ -106,10 +112,84 @@ export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||
navigateTo(route);
|
||||
return;
|
||||
}
|
||||
void routeAfterPreloadCheck(projectId, route);
|
||||
return routeAfterPreloadCheck(projectId, route);
|
||||
});
|
||||
}
|
||||
|
||||
/** 페이지 넘김 한 단계(520-3) — 잠금판에 「n/N 이름 · 지난 초」. */
|
||||
export interface StageStep {
|
||||
name: string;
|
||||
/** false = 머묾(까닭은 단계가 알림) · 던지면 까닭 토스트 뒤 머묾 */
|
||||
run: () => Promise<boolean | void>;
|
||||
/** 서버 진행 묻기(선택 · 1 s 마다) — 비율 0~1 · 글 */
|
||||
progress?: () => Promise<{ ratio: number | null; label?: string } | null>;
|
||||
}
|
||||
|
||||
const PROGRESS_POLL_MS = 1000;
|
||||
let moving = false;
|
||||
|
||||
/** 페이지 넘김 공용(520-3) — 잠금 → 단계 차례 → 실패면 잠금 풀고 머묾 → 성공이면 이동.
|
||||
* target = 워크플로 단계 주소 또는 이동 함수(B09 → 인쇄 미리보기). 참 = 옮김. */
|
||||
export async function moveStage(
|
||||
projectId: string,
|
||||
target: RoutePath | (() => void),
|
||||
steps: readonly StageStep[],
|
||||
): Promise<boolean> {
|
||||
if (moving) return false;
|
||||
moving = true;
|
||||
const started = Date.now();
|
||||
let index = 0;
|
||||
let server: { ratio: number | null; label?: string } | null = null;
|
||||
const paint = (): void => {
|
||||
const step = steps[index];
|
||||
const seconds = Math.floor((Date.now() - started) / 1000);
|
||||
const head = step ? `${index + 1}/${steps.length} ${step.name}` : "페이지 여는 중";
|
||||
const tail = step && server?.label ? ` — ${server.label}` : "";
|
||||
const ratio = step ? (index + (server?.ratio ?? 0)) / steps.length : 1;
|
||||
setLoadingOverlayProgress(ratio, `${head}${tail} · ${seconds}초`);
|
||||
};
|
||||
showLoadingOverlay();
|
||||
let asking = false;
|
||||
const timer = window.setInterval(() => {
|
||||
const at = index;
|
||||
const ask = steps[at]?.progress;
|
||||
if (!ask) return paint();
|
||||
if (asking) return;
|
||||
asking = true;
|
||||
void ask()
|
||||
.then(
|
||||
(next) => {
|
||||
if (at === index) server = next;
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
.finally(() => {
|
||||
asking = false;
|
||||
paint();
|
||||
});
|
||||
}, PROGRESS_POLL_MS);
|
||||
try {
|
||||
for (; index < steps.length; index += 1) {
|
||||
server = null;
|
||||
paint();
|
||||
if ((await steps[index].run()) === false) return false;
|
||||
}
|
||||
paint();
|
||||
if (typeof target === "function") target();
|
||||
else await goToWorkflowStage(projectId, target);
|
||||
return true;
|
||||
} catch (error) {
|
||||
const name = steps[index]?.name ?? "페이지 이동";
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
showToast(`${name}에서 멈췄습니다 — ${reason}`, "error");
|
||||
return false;
|
||||
} finally {
|
||||
window.clearInterval(timer);
|
||||
hideLoadingOverlay();
|
||||
moving = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 담아 둔 자료가 지금 확정본과 같은지 확인하고 화면을 정한다.
|
||||
*
|
||||
|
||||
@@ -333,6 +333,7 @@ export function mountNoteCad(
|
||||
let closed = false;
|
||||
const cad = createCadHost<CadHostDrawing>({
|
||||
title: "표 위 CAD",
|
||||
bare: true,
|
||||
onChanged: (dirty) => {
|
||||
if (!dirty || closed) return;
|
||||
cad
|
||||
|
||||
@@ -243,12 +243,15 @@ export function saveCrossLayout(projectId: string, layout: CrossLayout): Promise
|
||||
|
||||
/** 도면 축척 종류(485) — 표준 횡단 · 평면도 · 구조물은 뺌. */
|
||||
export type DrawingScaleKind = "longitudinal" | "cross" | "mass_haul" | "development";
|
||||
/** 축척 분모 — null = 자동(토적 가로 · 전개도) · 범위 = [최소, 최대]. */
|
||||
export type DrawingScaleAxes = { x: number | null; y: number | null };
|
||||
/** 축척 분모 — null = 자동(토적 가로 · 전개도 · 종단 평면 위치도) · 범위 = [최소, 최대]. */
|
||||
export type DrawingScaleAxes = { x: number | null; y: number | null; plan?: number | null };
|
||||
export interface DrawingScales {
|
||||
scales: Record<DrawingScaleKind, DrawingScaleAxes>;
|
||||
defaults: Record<DrawingScaleKind, DrawingScaleAxes>;
|
||||
ranges: Record<DrawingScaleKind, { x: [number, number]; y: [number, number] }>;
|
||||
ranges: Record<
|
||||
DrawingScaleKind,
|
||||
{ x: [number, number]; y: [number, number]; plan?: [number, number] }
|
||||
>;
|
||||
/** 종류마다 저장본(확정) 장 수 — 바꾸면 풀리므로 화면이 먼저 물음. */
|
||||
saved: Record<DrawingScaleKind, number>;
|
||||
/** PUT 응답만 — 이번에 풀린 저장본 장 수. */
|
||||
|
||||
@@ -42,6 +42,11 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import (
|
||||
LONG_PLAN_LAYER_ID,
|
||||
long_plan_entities,
|
||||
plan_box_overlaps,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
drawing_scale,
|
||||
entities_bbox,
|
||||
@@ -93,12 +98,14 @@ def _span(stations: list[dict[str, Any]], index: int, take: int) -> dict[str, fl
|
||||
|
||||
|
||||
def _fits_sheet(longitudinal: dict[str, Any], span: dict[str, float]) -> bool:
|
||||
"""이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 드는가."""
|
||||
bbox = entities_bbox(_long_content(longitudinal, "probe", span))
|
||||
"""이 구간을 고정 축척으로 그렸을 때 A1 작도 영역에 들고 평면 위치도 칸을 안 덮는가."""
|
||||
content = _long_content(longitudinal, "probe", span)
|
||||
bbox = entities_bbox(content)
|
||||
if bbox is None:
|
||||
return True
|
||||
usable_w, usable_h = usable_area()
|
||||
return bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6
|
||||
fits = bbox[2] - bbox[0] <= usable_w + 1e-6 and bbox[3] - bbox[1] <= usable_h + 1e-6
|
||||
return fits and not plan_box_overlaps(longitudinal, content, bbox)
|
||||
|
||||
|
||||
def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
@@ -124,9 +131,10 @@ def longitudinal_chunks(longitudinal: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
break
|
||||
index += take - 1 # 경계 1측점 중복
|
||||
if len(spans) == 1:
|
||||
return [{"id": "longitudinal", "label": "종단도", **spans[0]}]
|
||||
return [{"id": "longitudinal", "label": "종단도", "number": 1, "total": 1, **spans[0]}]
|
||||
return [
|
||||
{"id": f"longitudinal_{number}", "label": f"종단도({number})", **span}
|
||||
{"id": f"longitudinal_{number}", "label": f"종단도({number})", "number": number, **span}
|
||||
| {"total": len(spans)}
|
||||
for number, span in enumerate(spans, start=1)
|
||||
]
|
||||
|
||||
@@ -660,6 +668,7 @@ def build_longitudinal_drawing(
|
||||
# A1 도각: 콘텐츠가 이미 종이 mm라 도각도 실치수(1:1)로 두고 위치만 맞춘다.
|
||||
# 그림 좌하단 = 작도영역 좌하단 — 장마다 같은 자리에서 시작한다.
|
||||
bbox = entities_bbox(entities)
|
||||
entities.extend(long_plan_entities(longitudinal, drawing_id, chunk, bbox)) # 521-2
|
||||
if bbox:
|
||||
entities.extend(
|
||||
frame_entities(
|
||||
@@ -682,6 +691,7 @@ def build_longitudinal_drawing(
|
||||
_layer(DESIGN_LAYER_ID, "계획선"),
|
||||
_layer(LONG_GRID_LAYER_ID, "그래프 격자", locked=True),
|
||||
_layer(LONG_TABLE_LAYER_ID, "측점표"),
|
||||
_layer(LONG_PLAN_LAYER_ID, "평면 위치도"),
|
||||
_layer(FRAME_LAYER_ID, "도각", locked=True),
|
||||
],
|
||||
}
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"""B07 종단도 우측 상단 평면 위치도(521-2 · 요구 340-2) — 장마다 작도 영역 우상단 칸 하나.
|
||||
|
||||
칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm(작도 영역 좌하단 = 그림 좌하단 · `frame_entities`
|
||||
bottom_left). 척도 = 종단 축척 `plan` 칸(None = 자동). 방위표만 도각 층.
|
||||
- 자동: 전체 노선을 칸에 맞춤(분모 500 단위 올림) · 전체 노선 가는 선 · 이 장 구간 굵게.
|
||||
- 척도 정함: 이 장 중심 확대 · 이 장 굵게 · 밖 노선 흐린 파선 · 칸 밖은 잘림.
|
||||
칸 내용 = 노선 · 이 장 시 · 종점 측점 글 · 방위표 · 「평면도 S=1/n」 · 「n/N 장」.
|
||||
좌표 = 종단 측점 center_x · center_y — 없으면 칸 안 그림. 서버 단독(CLAUDE.md 5장 ③).
|
||||
"""
|
||||
|
||||
import math
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from shapely.geometry import LineString, Point
|
||||
from shapely.geometry import box as shapely_box
|
||||
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad import (
|
||||
DESIGN_COLOR,
|
||||
TABLE_LABEL_COLOR,
|
||||
TABLE_LINE_COLOR,
|
||||
_stations,
|
||||
_text_entity,
|
||||
infer_station_interval,
|
||||
polyline_entity,
|
||||
station_no_label,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_PlanLabels import text_rect
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
compass_entities,
|
||||
drawing_scale,
|
||||
usable_area,
|
||||
)
|
||||
|
||||
LONG_PLAN_LAYER_ID = "b07-long-plan"
|
||||
BOX_W, BOX_H = 150.0, 100.0 # 칸 크기(종이 mm)
|
||||
_INSET = 5.0 # 작도 영역 테두리에서 안쪽
|
||||
_MAP_PAD = (4.0, 10.0, 4.0, 4.0) # 칸 안 노선 자리 여백(좌 · 하 · 우 · 상) — 아래는 글 줄
|
||||
_AUTO_STEP = 500 # 자동 분모 올림 단위
|
||||
_FONT = 2.5
|
||||
_COMPASS = 12.0
|
||||
_ROUTE_COLOR = "#7d8590" # 전체 · 밖 노선 — 흐리게
|
||||
|
||||
|
||||
def plan_box(content_bbox: tuple[float, float, float, float]) -> tuple[float, float, float, float]:
|
||||
"""칸 (x0, y0, x1, y1) — 그림 좌표(작도 영역 좌하단 = 그림 좌하단)."""
|
||||
usable_w, usable_h = usable_area()
|
||||
x1 = content_bbox[0] + usable_w - _INSET
|
||||
y1 = content_bbox[1] + usable_h - _INSET
|
||||
return (x1 - BOX_W, y1 - BOX_H, x1, y1)
|
||||
|
||||
|
||||
def _plan_points(longitudinal: dict[str, Any]) -> list[tuple[float, float, float]]:
|
||||
"""종단 측점의 (누가거리 m, x, y) — `Router_Support_Basin.plan_stations` 와 같은 셈."""
|
||||
points: list[tuple[float, float, float]] = []
|
||||
for station in _stations(longitudinal):
|
||||
x, y = station.get("center_x"), station.get("center_y")
|
||||
if all(isinstance(v, (int, float)) and not isinstance(v, bool) for v in (x, y)):
|
||||
points.append((float(station["chainage_m"]), float(x), float(y)))
|
||||
return sorted(points)
|
||||
|
||||
|
||||
def _shapes(entities: list[dict[str, Any]]) -> Iterator[Any]:
|
||||
"""엔티티 → shapely 도형(선 · 글 사각 · 원) — 칸 겹침 판정용."""
|
||||
for entity in entities:
|
||||
yield from _shapes(entity.get("children") or [])
|
||||
shape = entity.get("shapeData") or {}
|
||||
if "startPoint" in shape:
|
||||
a, b = shape["startPoint"], shape["endPoint"]
|
||||
yield LineString([(a["x"], a["y"]), (b["x"], b["y"])])
|
||||
elif "basePoint" in shape:
|
||||
base, options = shape["basePoint"], shape.get("options") or {}
|
||||
direction = options.get("textDirection") or {}
|
||||
yield shapely_box(
|
||||
*text_rect(
|
||||
str(shape.get("label") or ""),
|
||||
base["x"],
|
||||
base["y"],
|
||||
float(options.get("fontSize", _FONT)),
|
||||
str(options.get("textAlign", "center")),
|
||||
(float(direction.get("x", 1.0)), float(direction.get("y", 0.0))),
|
||||
)
|
||||
)
|
||||
elif "center" in shape:
|
||||
center = shape["center"]
|
||||
yield Point(center["x"], center["y"]).buffer(float(shape.get("radius", 0.0)))
|
||||
|
||||
|
||||
def plan_box_overlaps(
|
||||
longitudinal: dict[str, Any],
|
||||
entities: list[dict[str, Any]],
|
||||
content_bbox: tuple[float, float, float, float] | None,
|
||||
) -> bool:
|
||||
"""그래프 · 측점표가 칸을 덮는가 — 칸을 안 그리는 종단(좌표 없음)은 늘 거짓."""
|
||||
if content_bbox is None or len(_plan_points(longitudinal)) < 2:
|
||||
return False
|
||||
x0, y0, x1, y1 = plan_box(content_bbox)
|
||||
if content_bbox[2] < x0 or content_bbox[3] < y0:
|
||||
return False
|
||||
region = shapely_box(x0, y0, x1, y1)
|
||||
return any(region.intersects(shape) for shape in _shapes(entities))
|
||||
|
||||
|
||||
def _auto_denominator(points: list[tuple[float, float, float]], map_w: float, map_h: float) -> int:
|
||||
width = max(p[1] for p in points) - min(p[1] for p in points)
|
||||
height = max(p[2] for p in points) - min(p[2] for p in points)
|
||||
need = max(width / map_w, height / map_h) * 1000.0
|
||||
return max(_AUTO_STEP, math.ceil(need / _AUTO_STEP) * _AUTO_STEP)
|
||||
|
||||
|
||||
def _clipped(
|
||||
drawing_id: str,
|
||||
points: list[tuple[float, float]],
|
||||
region: Any,
|
||||
suffix: str,
|
||||
color: str,
|
||||
dash: list[int] | None = None,
|
||||
width: int = 1,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""종이 점열을 칸 노선 자리로 잘라 PolyLine 들로."""
|
||||
if len(points) < 2:
|
||||
return []
|
||||
clipped = LineString(points).intersection(region)
|
||||
parts = getattr(clipped, "geoms", [clipped])
|
||||
out = []
|
||||
for index, part in enumerate(parts):
|
||||
if isinstance(part, LineString) and not part.is_empty:
|
||||
line = polyline_entity(
|
||||
drawing_id,
|
||||
list(part.coords),
|
||||
LONG_PLAN_LAYER_ID,
|
||||
color,
|
||||
suffix=f"{suffix}:{index}",
|
||||
dash=dash,
|
||||
width=width,
|
||||
)
|
||||
if line:
|
||||
out.append(line)
|
||||
return out
|
||||
|
||||
|
||||
def long_plan_entities(
|
||||
longitudinal: dict[str, Any],
|
||||
drawing_id: str,
|
||||
chunk: dict[str, Any],
|
||||
content_bbox: tuple[float, float, float, float] | None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""이 장의 평면 위치도 칸 — 좌표 없거나 그림이 비면 빈 목록."""
|
||||
points = _plan_points(longitudinal)
|
||||
if content_bbox is None or len(points) < 2:
|
||||
return []
|
||||
start_m, end_m = chunk.get("start_m"), chunk.get("end_m")
|
||||
if start_m is None or end_m is None:
|
||||
start_m, end_m = points[0][0], points[-1][0]
|
||||
inside = [p for p in points if start_m - 1e-6 <= p[0] <= end_m + 1e-6]
|
||||
x0, y0, x1, y1 = plan_box(content_bbox)
|
||||
left, bottom, right, top = _MAP_PAD
|
||||
map_box = (x0 + left, y0 + bottom, x1 - right, y1 - top)
|
||||
map_w, map_h = map_box[2] - map_box[0], map_box[3] - map_box[1]
|
||||
|
||||
fixed = drawing_scale("longitudinal").get("plan")
|
||||
denominator = fixed or _auto_denominator(points, map_w, map_h)
|
||||
focus = inside if fixed and inside else points
|
||||
center_x = (min(p[1] for p in focus) + max(p[1] for p in focus)) / 2.0
|
||||
center_y = (min(p[2] for p in focus) + max(p[2] for p in focus)) / 2.0
|
||||
mm = 1000.0 / denominator
|
||||
box_cx, box_cy = (map_box[0] + map_box[2]) / 2.0, (map_box[1] + map_box[3]) / 2.0
|
||||
|
||||
def paper(x: float, y: float) -> tuple[float, float]:
|
||||
return (box_cx + (x - center_x) * mm, box_cy + (y - center_y) * mm)
|
||||
|
||||
region = shapely_box(*map_box)
|
||||
border = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)]
|
||||
entities = [polyline_entity(drawing_id, border, LONG_PLAN_LAYER_ID, TABLE_LINE_COLOR, ":box")]
|
||||
entities += _clipped(
|
||||
drawing_id,
|
||||
[paper(x, y) for _c, x, y in points],
|
||||
region,
|
||||
":all",
|
||||
_ROUTE_COLOR,
|
||||
dash=[3, 3] if fixed else None,
|
||||
)
|
||||
entities += _clipped(
|
||||
drawing_id, [paper(x, y) for _c, x, y in inside], region, ":this", DESIGN_COLOR, width=3
|
||||
)
|
||||
interval_m = infer_station_interval(_stations(longitudinal))
|
||||
for name, (chainage, x, y) in (("start", inside[0]), ("end", inside[-1])) if inside else ():
|
||||
px, py = paper(x, y)
|
||||
if region.contains(Point(px, py)):
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:lplan:{name}",
|
||||
station_no_label(chainage, interval_m),
|
||||
px,
|
||||
py + _FONT,
|
||||
LONG_PLAN_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
)
|
||||
)
|
||||
number, total = int(chunk.get("number") or 1), int(chunk.get("total") or 1)
|
||||
for name, label, x, align in (
|
||||
("scale", f"평면도 S=1/{denominator:,}", x0 + left, "left"),
|
||||
("page", f"{number}/{total} 장", x1 - right, "right"),
|
||||
):
|
||||
entities.append(
|
||||
_text_entity(
|
||||
f"{drawing_id}:lplan:{name}",
|
||||
label,
|
||||
x,
|
||||
y0 + bottom / 2.0,
|
||||
LONG_PLAN_LAYER_ID,
|
||||
_FONT,
|
||||
TABLE_LABEL_COLOR,
|
||||
align=align,
|
||||
)
|
||||
)
|
||||
# 방위표 = 다른 평면 도면처럼 도각 층(잠금)
|
||||
compass = compass_entities(
|
||||
f"{drawing_id}:lplan",
|
||||
(x0 + left + _COMPASS / 2.0, y1 - top - _COMPASS / 2.0),
|
||||
_COMPASS,
|
||||
)
|
||||
return [entity for entity in entities if entity] + compass
|
||||
@@ -97,7 +97,8 @@ def add_title_fields(extra: dict[str, str]) -> None:
|
||||
# 읽는 프로젝트 = 위 문맥 변수(라우터가 세움) — 엔진 서명을 고치지 않는다.
|
||||
DRAWING_SCALES_FILE = "drawing_scales.json"
|
||||
DRAWING_SCALE_DEFAULTS: dict[str, dict[str, int | None]] = {
|
||||
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V},
|
||||
# plan = 종단 장 우측 상단 평면 위치도(521-2) · None = 자동(노선을 칸에 맞춤)
|
||||
"longitudinal": {"x": DRAWING_SCALE_LONG_H, "y": DRAWING_SCALE_LONG_V, "plan": None},
|
||||
"cross": {"x": DRAWING_SCALE_CROSS, "y": DRAWING_SCALE_CROSS},
|
||||
# 토적 세로 = 1 mm 당 50 ㎥ → 표기 1:50,000 (분모 / 1000 = 종이 1 mm 의 ㎥)
|
||||
"mass_haul": {"x": None, "y": int(DRAWING_SCALE_MASSHAUL_V_M3_MM * 1000)},
|
||||
|
||||
@@ -90,6 +90,7 @@ from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_read_manifest,
|
||||
_station_map,
|
||||
_write_manifest,
|
||||
release_old_long_sheets,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Plan import (
|
||||
PLAN_KIND_ID,
|
||||
@@ -196,6 +197,7 @@ def write_cross_layout(
|
||||
def _drawing_list(project_root: Path, longitudinal_path: Path) -> list[DesignDrawingItem]:
|
||||
longitudinal = _read_json(longitudinal_path)
|
||||
station_by_chainage = _station_map(longitudinal)
|
||||
release_old_long_sheets(project_root) # 521-2
|
||||
manifest_drawings = _read_manifest(project_root)["drawings"]
|
||||
# 종단도: 측점 30개 초과 시 30개 단위 분할 도면을 각각 목록에 노출한다(N-1-1).
|
||||
drawings = [
|
||||
|
||||
@@ -80,3 +80,31 @@ def _write_manifest(project_root: Path, manifest: dict[str, Any]) -> None:
|
||||
temporary = path.with_suffix(".tmp")
|
||||
temporary.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def release_old_long_sheets(project_root: Path) -> None:
|
||||
"""평면 위치도(521-2) 앞 확정 종단 저장본 — 종단만 모두 한 번 풂(칸 · 장 나눔이 바뀜).
|
||||
|
||||
풀린 장의 저장본은 `_write_manifest` 가 걷으니 다음부터는 새 저장본(칸 층 있음)만 남음.
|
||||
"""
|
||||
# 엔진 → 양식 → 이 모듈 차례라 위에서 부르면 돌고 돎
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import LONG_PLAN_LAYER_ID
|
||||
|
||||
manifest = _read_manifest(project_root)
|
||||
confirmed = [
|
||||
(drawing_id, entry)
|
||||
for drawing_id, entry in manifest["drawings"].items()
|
||||
if _LONG_ID.fullmatch(drawing_id) and entry.get("confirmed")
|
||||
]
|
||||
if not confirmed:
|
||||
return
|
||||
# ponytail: 첫 장만 봄 — 옛 장은 한꺼번에 풀리므로 새 장과 섞이지 않음
|
||||
saved = _design_root(project_root) / "drawings" / f"{confirmed[0][0]}.json"
|
||||
try:
|
||||
if f'"{LONG_PLAN_LAYER_ID}"' in saved.read_text(encoding="utf-8"):
|
||||
return
|
||||
except OSError:
|
||||
pass
|
||||
for _drawing_id, entry in confirmed:
|
||||
entry["confirmed"] = False
|
||||
_write_manifest(project_root, manifest)
|
||||
|
||||
@@ -36,7 +36,7 @@ router = APIRouter(prefix="/api/projects", tags=["B07 Design Detail"])
|
||||
|
||||
# 분모 범위(정수) — 기본 · 자동 후보(토적 500~6,000 · 전개도 50~5,000)를 모두 담음.
|
||||
SCALE_RANGES: dict[str, dict[str, tuple[int, int]]] = {
|
||||
"longitudinal": {"x": (100, 10000), "y": (10, 2000)},
|
||||
"longitudinal": {"x": (100, 10000), "y": (10, 2000), "plan": (500, 50000)},
|
||||
"cross": {"x": (10, 1000), "y": (10, 1000)},
|
||||
"mass_haul": {"x": (100, 20000), "y": (1000, 1000000)},
|
||||
"development": {"x": (10, 10000), "y": (10, 10000)},
|
||||
@@ -65,7 +65,7 @@ def checked_scales(scales: Any) -> dict[str, dict[str, int | None]]:
|
||||
if value is not None and (
|
||||
isinstance(value, bool) or not isinstance(value, int) or not low <= value <= high
|
||||
):
|
||||
name = "X(가로)" if axis == "x" else "Y(세로)"
|
||||
name = {"x": "X(가로)", "y": "Y(세로)"}.get(axis, "평면도")
|
||||
raise ValueError(
|
||||
f"{_KIND_LABEL[kind]} {name} 축척은 1/{low:,}~1/{high:,} 사이 정수로 적으세요."
|
||||
)
|
||||
|
||||
@@ -31,8 +31,9 @@ import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
clearWorkflowStateCache,
|
||||
fetchWorkflowState,
|
||||
goToWorkflowStage,
|
||||
moveStage,
|
||||
WORKFLOW_STEP_ROUTES,
|
||||
type StageStep,
|
||||
type WorkflowState,
|
||||
} from "../A00_Common/b_workflow_nav";
|
||||
import {
|
||||
@@ -484,28 +485,16 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
placeTabs();
|
||||
|
||||
/** 페이지 이동(281) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
|
||||
let moving = false;
|
||||
const moveToStage = async (stepIndex: number): Promise<void> => {
|
||||
if (!projectId || moving) return;
|
||||
moving = true;
|
||||
try {
|
||||
if (!(await saveEditedDrawings(store))) return;
|
||||
if (stepIndex > 4 && drawings.length > 0) {
|
||||
try {
|
||||
await completeDesignStage(projectId);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
error instanceof Error ? error.message : "상세 설계 단계를 완료하지 못했습니다.",
|
||||
"error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
} finally {
|
||||
moving = false;
|
||||
}
|
||||
/** 페이지 이동(281 · 520-3) — 고친 도면 저장 → B08 · B09 면 단계 4 완료 → 이동 · 실패면 머묾. */
|
||||
const moveToStage = (stepIndex: number): void => {
|
||||
if (!projectId) return;
|
||||
const steps: StageStep[] = [{ name: "도면 저장", run: () => saveEditedDrawings(store) }];
|
||||
if (stepIndex > 4 && drawings.length > 0)
|
||||
steps.push({
|
||||
name: "상세 설계 단계 완료",
|
||||
run: async () => void (await completeDesignStage(projectId)),
|
||||
});
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps);
|
||||
};
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
@@ -519,7 +508,7 @@ export async function renderB07DesignDetail(root: HTMLElement): Promise<void> {
|
||||
currentStage: 5,
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
// ☰ · 진행 단계 판 · [<] [>] 한 길 — 저장 후 이동(옛 「모든 횡단 확정」 잠금 · 자동 B08 이동 없음)
|
||||
onStepClick: (stepIndex) => void moveToStage(stepIndex),
|
||||
onStepClick: (stepIndex) => moveToStage(stepIndex),
|
||||
});
|
||||
layout.root.classList.add("b07-design-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
* 오른쪽 = 고른 장 미리보기(보기 전용 CAD · 캔버스만 `bare`) · 확정 배지 · [CAD 에서 열기]
|
||||
* 고치기는 지금 서버 길 그대로(종류 파일) — 고칠 때마다 모달 안을 다시 받고,
|
||||
* 닫을 때 `onClosed(changed)` 로 좌측 목록 · 도면을 다시 읽음.
|
||||
* 처음 열 때 = 페이지 목록 · 미리받은 장(`Prefetch.openDrawing`) 그대로 · 같은 목록으로 다시 열면 요청 없음(522-3).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { createCadHost } from "@ui/cad_host/cad_host";
|
||||
import {
|
||||
fetchDesignDrawing,
|
||||
fetchDesignDrawingList,
|
||||
fetchPlanKinds,
|
||||
reorderPlanKinds,
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
type DesignDrawingItem,
|
||||
type PlanKindsState,
|
||||
} from "./B07_DesignDetail_Api_Fetch";
|
||||
import { openDrawing, syncDrawings } from "./B07_DesignDetail_Prefetch";
|
||||
import {
|
||||
groupPlanSheets,
|
||||
kindForm,
|
||||
@@ -36,8 +37,15 @@ export interface PlanManageControls {
|
||||
onClosed: (changed: boolean, openId?: string) => void;
|
||||
/** 머리 단추 — ⚙ 로 열 때 [원본에서 다시 그리기](280-5). */
|
||||
actions?: HTMLElement[];
|
||||
/** 페이지가 받은 도면 목록 — 있으면 처음엔 목록을 다시 안 받음. */
|
||||
drawings?: DesignDrawingItem[];
|
||||
}
|
||||
|
||||
/** 마지막으로 세운 종류 · 목록 — 같은 페이지 목록(`source`)으로 다시 열면 그대로 씀. */
|
||||
let seen:
|
||||
| { source: DesignDrawingItem[] | undefined; state: PlanKindsState; list: DesignDrawingItem[] }
|
||||
| undefined;
|
||||
|
||||
function el<K extends keyof HTMLElementTagNameMap>(
|
||||
tag: K,
|
||||
className: string,
|
||||
@@ -66,7 +74,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
|
||||
let pickedKind = "";
|
||||
let form: KindForm | undefined;
|
||||
let sheets: DesignDrawingItem[] = [];
|
||||
const preview = createCadHost<CadDrawing>({ title: "평면도 미리보기" });
|
||||
const preview = createCadHost<CadDrawing>({ title: "평면도 미리보기", bare: true });
|
||||
const side = el("div", "b07-plan-manage__side");
|
||||
const head = el("div", "b07-plan-manage__head");
|
||||
const view = el("div", "b07-plan-manage__view");
|
||||
@@ -77,7 +85,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
|
||||
projectId,
|
||||
onChanged: () => {
|
||||
changed = true;
|
||||
void refresh();
|
||||
void refresh(true);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -171,7 +179,7 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
|
||||
head.replaceChildren(el("strong", "", label));
|
||||
preview.beginLoading();
|
||||
try {
|
||||
const response = await fetchDesignDrawing(projectId, drawing.id);
|
||||
const response = await openDrawing(drawing);
|
||||
if (closed || picked !== drawing.id) return;
|
||||
const badge = el("span", "b07-plan-manage__badge", response.confirmed ? "확정" : "확정 전");
|
||||
badge.dataset.confirmed = String(response.confirmed);
|
||||
@@ -198,15 +206,23 @@ export function openPlanManager(controls: PlanManageControls): { close: () => vo
|
||||
}
|
||||
};
|
||||
|
||||
/** 종류 · 장 목록을 서버에서 다시 받아 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄. */
|
||||
const refresh = async (): Promise<void> => {
|
||||
/** 종류 · 장 목록으로 왼쪽을 세우고 보던 장(없으면 첫 장)을 다시 봄 ·
|
||||
* edited = 고친 뒤 — 서버에서 새로 받고 지문 바뀐 장은 미리받기에서 버림. */
|
||||
const refresh = async (edited = false): Promise<void> => {
|
||||
try {
|
||||
const [state, list] = await Promise.all([
|
||||
fetchPlanKinds(projectId),
|
||||
fetchDesignDrawingList(projectId),
|
||||
]);
|
||||
const source = controls.drawings;
|
||||
const fresh = edited || !source;
|
||||
const known = !fresh && seen?.source === source ? seen : undefined;
|
||||
const [state, list] = known
|
||||
? [known.state, known.list]
|
||||
: await Promise.all([
|
||||
fetchPlanKinds(projectId),
|
||||
fresh ? fetchDesignDrawingList(projectId).then((got) => got.drawings) : source,
|
||||
]);
|
||||
if (closed) return;
|
||||
sheets = list.drawings.filter((item) => item.kind === "plan");
|
||||
if (fresh) syncDrawings(projectId, list);
|
||||
seen = { source, state, list };
|
||||
sheets = list.filter((item) => item.kind === "plan");
|
||||
const all = groupPlanSheets(sheets).flatMap((group) => group.sheets);
|
||||
const keep = all.find((sheet) => sheet.drawing.id === picked) ?? all[0];
|
||||
if (keep) pickedKind = keep.drawing.plan_kind ?? "";
|
||||
|
||||
@@ -68,7 +68,7 @@ export function openDrawingSettings(kind: Kind, deps: DrawingSettingsDeps): void
|
||||
redraw.classList.add("b07-settings__redraw");
|
||||
|
||||
if (kind === "plan" && deps.plan) {
|
||||
const manager = openPlanManager({ ...deps.plan, actions: [redraw] });
|
||||
const manager = openPlanManager({ ...deps.plan, drawings: deps.drawings, actions: [redraw] });
|
||||
closeModal = manager.close;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* B07_DesignDetail_UI_Settings_Scale.ts
|
||||
* ⚙ 모달 X · Y 축척 칸(485-2 · 요구 280-5) — 종단 · 횡단 · 토적 · 전개도만.
|
||||
* 종단 = 셋째 칸 「평면도」(521-2 · 우측 상단 평면 위치도) — 칸은 서버 기본값 열쇠 그대로.
|
||||
*
|
||||
* 칸 = 분모 정수 · 범위 밖 = 칸 붉게 + [축척 저장] 꺼짐 · 기본이 자동인 칸(토적 X · 전개도) = 「자동」
|
||||
* [축척 저장] = 그 종류 저장본이 있으면 n 장 물음(취소 = 그대로) → PUT → 화면 다시 세움(도면 다시 그림)
|
||||
@@ -32,10 +33,8 @@ const LABEL: Record<DrawingScaleKind, string> = {
|
||||
mass_haul: "토적도",
|
||||
development: "전개도",
|
||||
};
|
||||
const AXES = [
|
||||
["x", "X(가로) 1/"],
|
||||
["y", "Y(세로) 1/"],
|
||||
] as const;
|
||||
type Axis = keyof DrawingScaleAxes;
|
||||
const AXIS_LABEL: Record<Axis, string> = { x: "X(가로) 1/", y: "Y(세로) 1/", plan: "평면도 1/" };
|
||||
|
||||
const isScaleKind = (kind: string): kind is DrawingScaleKind => kind in LABEL;
|
||||
|
||||
@@ -86,9 +85,11 @@ function buildScaleFields(
|
||||
data: DrawingScales,
|
||||
save: (next: DrawingScaleAxes) => Promise<void>,
|
||||
): void {
|
||||
const fields = AXES.map(([axis, label]) => {
|
||||
const fallback = data.defaults[kind][axis];
|
||||
const range = data.ranges[kind][axis];
|
||||
const axes = Object.keys(data.defaults[kind]) as Axis[];
|
||||
const fields = axes.map((axis) => {
|
||||
const label = AXIS_LABEL[axis];
|
||||
const fallback = data.defaults[kind][axis] ?? null;
|
||||
const range = data.ranges[kind][axis] ?? [1, 1];
|
||||
const [min, max] = range;
|
||||
const field = createInputField({ label, type: "number", compact: true, min, max });
|
||||
field.root.classList.add("b07-scale__field");
|
||||
@@ -99,7 +100,7 @@ function buildScaleFields(
|
||||
if (auto) auto.input.checked = value === null;
|
||||
field.input.disabled = value === null && !!auto;
|
||||
};
|
||||
set(data.scales[kind][axis]);
|
||||
set(data.scales[kind][axis] ?? null);
|
||||
const read = () => readScaleAxis(field.input.value, !!auto?.input.checked, range);
|
||||
return { axis, field, auto, fallback, set, read };
|
||||
});
|
||||
@@ -107,13 +108,14 @@ function buildScaleFields(
|
||||
const saveButton = createButton({
|
||||
label: "축척 저장",
|
||||
onClick: () => {
|
||||
const [x, y] = fields.map((f) => f.read()) as [number | null, number | null];
|
||||
const read = fields.map((f) => [f, f.read() as number | null] as const);
|
||||
const now = data.scales[kind];
|
||||
if (x === now.x && y === now.y) {
|
||||
if (read.every(([f, value]) => value === (now[f.axis] ?? null))) {
|
||||
showToast("바뀐 축척이 없습니다.", "info");
|
||||
return;
|
||||
}
|
||||
void save({ x: scaleToSend(x, fields[0].fallback), y: scaleToSend(y, fields[1].fallback) });
|
||||
const next = Object.fromEntries(read.map(([f, v]) => [f.axis, scaleToSend(v, f.fallback)]));
|
||||
void save(next as DrawingScaleAxes);
|
||||
},
|
||||
});
|
||||
// 칸 밑 오류 글은 숨기고(틀린 칸만 길어짐) 줄 아래 한 줄로 알림
|
||||
@@ -127,7 +129,8 @@ function buildScaleFields(
|
||||
const result = f.read();
|
||||
const message = typeof result === "string" ? result : undefined;
|
||||
f.field.setError(message);
|
||||
if (message) errors.set(message, [...(errors.get(message) ?? []), f.axis.toUpperCase()]);
|
||||
const name = f.axis === "plan" ? "평면도" : f.axis.toUpperCase();
|
||||
if (message) errors.set(message, [...(errors.get(message) ?? []), name]);
|
||||
}
|
||||
alertLine.textContent = [...errors].map(([m, axes]) => `${axes.join(" · ")}: ${m}`).join(" / ");
|
||||
alertLine.hidden = errors.size === 0;
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
isViewOnly,
|
||||
setActiveLayerId,
|
||||
setActiveToolActor,
|
||||
setBare,
|
||||
setCanvas,
|
||||
setEntities,
|
||||
setHighlightedEntityIds,
|
||||
@@ -46,6 +47,8 @@ import { ActorEvent, type DrawEvent } from './tools/tool.types';
|
||||
|
||||
// 호스트 앱의 화이트/블랙 모드를 먼저 붙인 뒤 렌더한다.
|
||||
syncThemeFromHost();
|
||||
// 부모가 `?bare` 로 띄우면 첫 그림부터 캔버스만 — 도면을 넘길 때 다시 정함(522-2)
|
||||
if (new URLSearchParams(window.location.search).has('bare')) setBare(true);
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLDivElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
import "@ui/ui_template_general_layout.css";
|
||||
import "./B08_Quantity_UI_Page.css";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav";
|
||||
import { renderStructureSummary } from "./B08_Quantity_UI_Summary";
|
||||
import { renderMaterialSheet } from "./B08_Quantity_UI_Material";
|
||||
import { renderHydro, request } from "./B08_Quantity_UI_Hydro";
|
||||
@@ -166,17 +166,15 @@ export function renderB08Quantity(root: HTMLElement): void {
|
||||
routes: WORKFLOW_STEP_ROUTES,
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (!projectId) return;
|
||||
void (async () => {
|
||||
if (!(await saveShown())) return;
|
||||
// B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾
|
||||
if (stepIndex > 5)
|
||||
try {
|
||||
await request(`/projects/${projectId}/quantity/complete`, { method: "POST" });
|
||||
} catch (error) {
|
||||
return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error");
|
||||
}
|
||||
goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
})();
|
||||
const steps: StageStep[] = [{ name: "수량 저장", run: saveShown }];
|
||||
// B09 쪽 = 단계 5 완료 뒤(요구 300 B1) · 실패면 머묾
|
||||
if (stepIndex > 5)
|
||||
steps.push({
|
||||
name: "수량 산출 단계 완료",
|
||||
run: async () =>
|
||||
void (await request(`/projects/${projectId}/quantity/complete`, { method: "POST" })),
|
||||
});
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], steps);
|
||||
},
|
||||
});
|
||||
root.append(layout.root);
|
||||
|
||||
@@ -27,7 +27,7 @@ import { attachHoverInfo } from "@ui/ui_template_hover_info";
|
||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { clearState, readState, writeState } from "../A00_Common/b_page_state";
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { moveStage, WORKFLOW_STEP_ROUTES } from "../A00_Common/b_workflow_nav";
|
||||
import { computeBase, type Row } from "./B09_Estimation_Calc_Base";
|
||||
import { computeEstimation } from "./B09_Estimation_Calc_Bill";
|
||||
import { computeLabor, daegaPrice } from "./B09_Estimation_Calc_Labor";
|
||||
@@ -357,9 +357,9 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
// 481-2 단계 이동 = 고친 값 저장 성공 뒤 · 실패면 머묾 · 불러오기 전 · 고침 없음 = 요청 없이
|
||||
onStepClick: (stepIndex: number) => {
|
||||
if (!projectId) return;
|
||||
void saveChanged().then(
|
||||
(ok) => ok && goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
||||
);
|
||||
void moveStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex], [
|
||||
{ name: "원가 저장", run: saveChanged },
|
||||
]);
|
||||
},
|
||||
});
|
||||
// [>] = 다음 단계 없음 → 공용 틀 대신 여기서 — 저장 → 단계 6 완료 → 초안 다음 길 · 초안 없으면 흐림 그대로
|
||||
@@ -369,15 +369,14 @@ export function renderB09Estimation(root: HTMLElement): void {
|
||||
left.addEventListener("ui-step-nav", (event) => {
|
||||
if ((event as CustomEvent<number>).detail !== 1 || !goNext || !projectId) return;
|
||||
event.stopPropagation();
|
||||
void (async () => {
|
||||
if (!(await saveChanged())) return;
|
||||
try {
|
||||
await request(`/projects/${projectId}/estimation/complete`, { method: "POST" });
|
||||
} catch (error) {
|
||||
return showToast(`단계를 마치지 못했습니다 — ${String(error)}`, "error");
|
||||
}
|
||||
goNext();
|
||||
})();
|
||||
void moveStage(projectId, goNext, [
|
||||
{ name: "원가 저장", run: saveChanged },
|
||||
{
|
||||
name: "원가 계산 단계 완료",
|
||||
run: async () =>
|
||||
void (await request(`/projects/${projectId}/estimation/complete`, { method: "POST" })),
|
||||
},
|
||||
]);
|
||||
});
|
||||
root.append(layout.root);
|
||||
show(current);
|
||||
|
||||
@@ -187,8 +187,11 @@ const sheets = () =>
|
||||
);
|
||||
const copy = () => JSON.parse(JSON.stringify(state));
|
||||
const API = {
|
||||
fetchPlanKinds: async () => copy(),
|
||||
fetchDesignDrawingList: async () => ({ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }),
|
||||
fetchPlanKinds: async () => (calls.push(["kinds"]), copy()),
|
||||
fetchDesignDrawingList: async () => (
|
||||
calls.push(["list"]),
|
||||
{ drawings: [{ id: "cover", kind: "cover" }, ...sheets()] }
|
||||
),
|
||||
fetchDesignDrawing: async (_, id) => (
|
||||
calls.push(["fetchDrawing", id]),
|
||||
{ id, confirmed: confirmed.has(id), drawing: { entities: [{ id }], layers: [] } }
|
||||
@@ -232,6 +235,12 @@ const { loadTs } = makeLoader({
|
||||
if (request === "@ui/ui_template_collapsible")
|
||||
return loadTs(path.join(ROOT, "ui_template", "ui_template_collapsible.ts"));
|
||||
if (request === "./B07_DesignDetail_Api_Fetch") return API;
|
||||
// 522-3 미리받기 — 장 받기는 `openDrawing` · 고친 뒤 목록 맞춤은 `syncDrawings`
|
||||
if (request === "./B07_DesignDetail_Prefetch")
|
||||
return {
|
||||
openDrawing: (item) => API.fetchDesignDrawing("p1", item.id),
|
||||
syncDrawings: (_, list) => calls.push(["sync", list.length]),
|
||||
};
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
throw new Error(`stub 없음: ${request}`);
|
||||
},
|
||||
@@ -245,9 +254,15 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
|
||||
const out = {};
|
||||
const closes = [];
|
||||
const selected = [];
|
||||
const plan = { projectId: "p1", onClosed: (changed, openId) => closes.push([changed, openId]) };
|
||||
// 페이지가 받은 목록을 넘김(`openDrawingSettings` 와 같은 길 · 522-3)
|
||||
const pageList = [{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()];
|
||||
const plan = {
|
||||
projectId: "p1",
|
||||
drawings: pageList,
|
||||
onClosed: (changed, openId) => closes.push([changed, openId]),
|
||||
};
|
||||
const panel = buildDrawingSidePanel(
|
||||
[{ id: "cover", kind: "cover", label: "표지", confirmed: false }, ...sheets()],
|
||||
pageList,
|
||||
(drawing) => selected.push(drawing.id),
|
||||
undefined,
|
||||
false,
|
||||
@@ -288,6 +303,8 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
|
||||
hasMarks: all(modal.body).some((n) => n.textContent === "대피소 · 사토장 자리"),
|
||||
preview: cad.loads.at(-1).drawing.entities[0].id,
|
||||
extra: cad.loads.at(-1).extra,
|
||||
bare: cad.opts.bare,
|
||||
calls: calls.map((c) => c[0]),
|
||||
};
|
||||
|
||||
// 종류 추가 — 「새 종류」 · 이름 · 조각 하나
|
||||
@@ -297,7 +314,12 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
|
||||
action().click();
|
||||
await settle();
|
||||
await settle();
|
||||
out.add = { kinds: kindRows(), sheets: sheetIds(), call: calls.find((c) => c[0] === "add") };
|
||||
out.add = {
|
||||
kinds: kindRows(),
|
||||
sheets: sheetIds(),
|
||||
call: calls.find((c) => c[0] === "add"),
|
||||
after: calls.slice(calls.findIndex((c) => c[0] === "add")).map((c) => c[0]),
|
||||
};
|
||||
|
||||
// 업데이트 — 새 종류를 골라 이름 · 축척 바꿈
|
||||
kindPick().change("k_new");
|
||||
@@ -393,10 +415,17 @@ const { openPlanManager } = loadTs(path.join(B07, "B07_DesignDetail_UI_PlanManag
|
||||
out.close = { closes: [...closes], destroyed: cad.destroyed, open: modal.open };
|
||||
|
||||
// 다시 열어 고치지 않고 [CAD 에서 열기] → changed = false · 그 장 id
|
||||
const callsBefore = calls.length;
|
||||
gear().click();
|
||||
await settle();
|
||||
await settle();
|
||||
const modal2 = modals[1];
|
||||
out.reopen = {
|
||||
calls: calls.slice(callsBefore).map((c) => c[0]),
|
||||
kinds: modal2.body
|
||||
.querySelectorAll(".b07-plan-manage__sheets .b07-plan-manage__row")
|
||||
.map((row) => row.dataset.planKind),
|
||||
};
|
||||
modal2.body
|
||||
.querySelectorAll(".b07-plan-manage__sheet")
|
||||
.find((b) => b.dataset.drawingId === "plan_k_route")
|
||||
|
||||
@@ -97,19 +97,19 @@ const calls = [];
|
||||
let failConfirm = false;
|
||||
const SCALES = {
|
||||
scales: {
|
||||
longitudinal: { x: 1000, y: 200 },
|
||||
longitudinal: { x: 1000, y: 200, plan: null },
|
||||
cross: { x: 100, y: 100 },
|
||||
mass_haul: { x: null, y: 50000 },
|
||||
development: { x: null, y: null },
|
||||
},
|
||||
defaults: {
|
||||
longitudinal: { x: 1000, y: 200 },
|
||||
longitudinal: { x: 1000, y: 200, plan: null },
|
||||
cross: { x: 100, y: 100 },
|
||||
mass_haul: { x: null, y: 50000 },
|
||||
development: { x: null, y: null },
|
||||
},
|
||||
ranges: {
|
||||
longitudinal: { x: [100, 10000], y: [10, 2000] },
|
||||
longitudinal: { x: [100, 10000], y: [10, 2000], plan: [500, 50000] },
|
||||
cross: { x: [10, 1000], y: [10, 1000] },
|
||||
mass_haul: { x: [100, 20000], y: [1000, 1000000] },
|
||||
development: { x: [10, 10000], y: [10, 10000] },
|
||||
@@ -413,6 +413,18 @@ const drawings = [
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
scale.same = { calls: calls.length, toasts: toasts.slice(toastAt).map((t) => t[1]) };
|
||||
// 평면 위치도 칸(521-2) — 셋째 칸 · 자동 끔 → 범위 밖 붉음 → 1/3,000 저장
|
||||
s = await scaleOf("longitudinal");
|
||||
scale.planStart = s.state();
|
||||
s.toggle(s.autos[0]);
|
||||
s.type(s.inputs[2], "400");
|
||||
scale.planLow = s.state();
|
||||
s.type(s.inputs[2], "3000");
|
||||
calls.length = 0;
|
||||
s.btn("축척 저장").click();
|
||||
await tick();
|
||||
await tick();
|
||||
scale.planSent = calls.find((c) => c[0] === "scales")?.[1];
|
||||
|
||||
s = await scaleOf("mass_haul");
|
||||
scale.massStart = s.state();
|
||||
|
||||
@@ -113,7 +113,19 @@ const common = {
|
||||
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
|
||||
"../A00_Common/b_workflow_nav": {
|
||||
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
|
||||
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
|
||||
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
|
||||
moveStage: async (_p, target, steps) => {
|
||||
for (const step of steps)
|
||||
try {
|
||||
if ((await step.run()) === false) return false;
|
||||
} catch {
|
||||
calls.push("toast:error");
|
||||
return false;
|
||||
}
|
||||
if (typeof target === "function") target();
|
||||
else calls.push(`go:${target}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name) => store.get(name) ?? null,
|
||||
|
||||
@@ -101,7 +101,19 @@ const common = {
|
||||
"../A00_Common/b_page_scaffold": { workflowSteps: () => [] },
|
||||
"../A00_Common/b_workflow_nav": {
|
||||
WORKFLOW_STEP_ROUTES: ["b03", "b04", "b05", "b06", "b07", "b08", "b09"],
|
||||
goToWorkflowStage: (_p, route) => calls.push(`go:${route}`),
|
||||
// 520-3 공용 페이지 넘김 — 단계 차례 · false · 던짐 = 머묾(잠금 · 진행 글은 test_520_3)
|
||||
moveStage: async (_p, target, steps) => {
|
||||
for (const step of steps)
|
||||
try {
|
||||
if ((await step.run()) === false) return false;
|
||||
} catch {
|
||||
calls.push("toast:error");
|
||||
return false;
|
||||
}
|
||||
if (typeof target === "function") target();
|
||||
else calls.push(`go:${target}`);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
"../A00_Common/b_page_state": {
|
||||
readState: (name) => store.get(name) ?? null,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 520-3 페이지 넘김 공용 `moveStage` — 진짜 `b_workflow_nav.ts` 를 트랜스파일해 가짜 잠금 · 토스트 · 이동 위에서 돌리고
|
||||
* 잠금 · 진행 글 · 머묾 · 이동을 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 calls = [];
|
||||
let lock = 0;
|
||||
const stubs = {
|
||||
"@config/config_frontend": { API_BASE_URL: "/api", CURRENT_PROJECT_ID_KEY: "pid", ROUTES: {} },
|
||||
"@ui/ui_template_elements": {
|
||||
showLoadingOverlay: () => (lock += 1),
|
||||
hideLoadingOverlay: () => (lock = Math.max(0, lock - 1)),
|
||||
setLoadingOverlayProgress: (ratio, label) => calls.push(`p:${ratio.toFixed(2)}:${label}`),
|
||||
showToast: (text, kind) => calls.push(`toast:${kind}:${text}`),
|
||||
},
|
||||
"../B04_PreProcess/B04_PreProcess_Api_Fetch": {},
|
||||
"./b_asset_cache": {},
|
||||
"./b_project_switch": { enterProject: async () => calls.push("enter") },
|
||||
"./router": { navigateTo: (route) => calls.push(`go:${route}`) },
|
||||
};
|
||||
const js = ts.transpileModule(
|
||||
fs.readFileSync(path.join(ROOT, "A00_Common/b_workflow_nav.ts"), "utf8"),
|
||||
{
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
},
|
||||
).outputText;
|
||||
const mod = { exports: {} };
|
||||
global.window = { setInterval, clearInterval };
|
||||
global.localStorage = { setItem() {} };
|
||||
new Function("exports", "module", "require", js)(mod.exports, mod, (id) => stubs[id] ?? {});
|
||||
const { moveStage } = mod.exports;
|
||||
|
||||
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const run = async (target, steps) => {
|
||||
calls.length = 0;
|
||||
const ok = await moveStage("P1", target, steps);
|
||||
return { ok, lock, calls: calls.splice(0) };
|
||||
};
|
||||
|
||||
(async () => {
|
||||
const out = {};
|
||||
let lockSeen = -1;
|
||||
out.ok = await run("b08", [
|
||||
{ name: "저장", run: async () => void (lockSeen = lock) },
|
||||
{ name: "완료", run: async () => true },
|
||||
]);
|
||||
out.ok.lockSeen = lockSeen;
|
||||
out.stop = await run("b08", [
|
||||
{ name: "저장", run: async () => false },
|
||||
{ name: "완료", run: async () => calls.push("완료 돎") },
|
||||
]);
|
||||
out.fail = await run("b08", [
|
||||
{
|
||||
name: "저장",
|
||||
run: async () => {
|
||||
throw new Error("Failed to fetch");
|
||||
},
|
||||
},
|
||||
]);
|
||||
let asked = 0;
|
||||
out.poll = await run(
|
||||
() => calls.push("go:fn"),
|
||||
[
|
||||
{
|
||||
name: "서버 저장",
|
||||
run: () => wait(1150),
|
||||
progress: async () => ((asked += 1), { ratio: 0.5, label: "관 차례" }),
|
||||
},
|
||||
],
|
||||
);
|
||||
out.poll.asked = asked;
|
||||
// 넘기는 중 또 누름 = 무시
|
||||
const first = moveStage("P1", "b07", [{ name: "느림", run: () => wait(50) }]);
|
||||
out.busy = await moveStage("P1", "b09", []);
|
||||
await first;
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
})();
|
||||
@@ -9,6 +9,8 @@
|
||||
- 장 단추 → 드롭다운 따라감 · 드롭다운 → 그 종류 첫 장 · 줄 끌어 놓기 = 종류 순서(놓는 즉시 · 140-2).
|
||||
- 미리보기 장 바꾸기 · 캔버스만(bare) · 확정 배지만(확정 풀기 없음 140-1) · 닫으면 changed=True · [CAD 에서 열기] = 그 장 id.
|
||||
|
||||
- 522 처음 = 페이지 목록 · 미리받기 장 · 고친 뒤만 목록 새로 + 미리받기 맞춤 · 같은 목록 다시 열기 = 요청 없음 · iframe `?bare`.
|
||||
|
||||
Node 헬퍼 `helper_130_b07_plan_manage.cjs` 가 화면 그릇 흉내로 돌림. 화면은 사용자 확인.
|
||||
"""
|
||||
|
||||
@@ -59,6 +61,21 @@ def test_첫_장_미리보기는_보기_전용(out):
|
||||
assert extra["readOnly"] is True and extra["viewOnly"] is True
|
||||
assert extra["bare"] is True # 140-1 캔버스만(제목줄 · 리본 · 상태막대 없음)
|
||||
assert extra["recoveryScope"] == "b07-plan-preview" # 본 CAD 자동백업 칸과 안 겹침
|
||||
assert out["open"]["bare"] is True # 522-2 iframe 을 `?bare` 로 — 도면 오기 전에도 리본 없음
|
||||
|
||||
|
||||
def test_처음_열기는_페이지_목록(out):
|
||||
# 522-3 목록은 페이지가 받은 것 · 장은 미리받기(`openDrawing`) — 목록 다시 안 받음
|
||||
assert out["open"]["calls"] == ["kinds", "fetchDrawing"]
|
||||
|
||||
|
||||
def test_고친_뒤엔_목록_새로_맞춤(out):
|
||||
assert out["add"]["after"][:4] == ["add", "kinds", "list", "sync"]
|
||||
|
||||
|
||||
def test_같은_목록으로_다시_열면_요청_없음(out):
|
||||
assert out["reopen"]["calls"] == ["fetchDrawing"] # 진짜 길은 미리받기 캐시
|
||||
assert out["reopen"]["kinds"] == ["k_route", "k_terrain"] # 고친 뒤 받은 순서 그대로
|
||||
|
||||
|
||||
def test_종류_추가(out):
|
||||
|
||||
@@ -244,7 +244,14 @@ def test_전개도_가로_세로(project: Path) -> None:
|
||||
|
||||
def test_범위_검사() -> None:
|
||||
assert checked_scales({"cross": {"x": 100, "y": None}}) == {"cross": {"x": 100, "y": None}}
|
||||
# 종단 평면 위치도(521-2) = 1/500~1/50,000 · null = 자동
|
||||
assert checked_scales({"longitudinal": {"plan": 3000}}) == {
|
||||
"longitudinal": {"x": None, "y": None, "plan": 3000}
|
||||
}
|
||||
with pytest.raises(ValueError, match="평면도 축척은 1/500~1/50,000"):
|
||||
checked_scales({"longitudinal": {"plan": 400}})
|
||||
for bad in (
|
||||
{"longitudinal": {"plan": 50001}},
|
||||
{"plan": {"x": 500}},
|
||||
{"standard": {"x": 50}},
|
||||
{"cross": {"x": 5}},
|
||||
@@ -287,7 +294,7 @@ def test_바꾼_종류만_저장본_풂(project: Path) -> None:
|
||||
drawings = _read_manifest(project)["drawings"]
|
||||
assert not drawings["longitudinal_1"]["confirmed"]
|
||||
assert drawings["cross_00020m"]["confirmed"] and drawings["cover"]["confirmed"]
|
||||
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200}
|
||||
assert read_drawing_scales(project)["longitudinal"] == {"x": 500, "y": 200, "plan": None}
|
||||
# 보낸 종류만 바뀜 — 앞서 쓴 종단은 남음 · 횡단 = 측점 설계 잠정 몫
|
||||
invalidated, stale = write_drawing_scales(
|
||||
project, project / "none.json", {"cross": {"x": 100, "y": 50}}
|
||||
@@ -314,5 +321,5 @@ def test_GET_PUT_범위_밖_400(project: Path, monkeypatch: pytest.MonkeyPatch)
|
||||
assert not drawing_scales_path(project).exists() # 거절 = 안 씀
|
||||
put = client.put(url, json={"scales": {"longitudinal": {"x": 500, "y": 200}}})
|
||||
assert put.status_code == 200
|
||||
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200}
|
||||
assert put.json()["scales"]["longitudinal"] == {"x": 500, "y": 200, "plan": None}
|
||||
assert put.json()["invalidated"] == {}
|
||||
|
||||
@@ -142,12 +142,12 @@ def test_페이지_확정_단추_잠금_자동이동_없음():
|
||||
assert gone not in page, gone
|
||||
assert "confirmed: false, // 잠금 없음(요구 281)" in page
|
||||
assert "frameRow.append(frameEditor.button);" in page # 298-2 — [도각 편집] = 윗줄
|
||||
assert "onStepClick: (stepIndex) => void moveToStage(stepIndex)," in page
|
||||
assert "onStepClick: (stepIndex) => moveToStage(stepIndex)," in page
|
||||
move = page[
|
||||
page.index("const moveToStage = async") : page.index("const layout = createWorkflowLayout")
|
||||
page.index("const moveToStage = (") : page.index("const layout = createWorkflowLayout")
|
||||
]
|
||||
assert move.index("saveEditedDrawings(store)") < move.index("completeDesignStage(projectId)")
|
||||
assert move.index("completeDesignStage(projectId)") < move.index("goToWorkflowStage(")
|
||||
assert move.index("completeDesignStage(projectId)") < move.index("moveStage(projectId")
|
||||
assert "if (stepIndex > 4 && drawings.length > 0)" in move
|
||||
assert "currentStage: 5," in page
|
||||
assert len(page.splitlines()) <= 700
|
||||
@@ -229,22 +229,27 @@ def test_485_2_축척_칸_판정(out):
|
||||
|
||||
def test_485_2_종단_축척_저장(out):
|
||||
scale = out["scale"]
|
||||
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/"]
|
||||
assert scale["longStart"]["values"] == ["1000", "200"]
|
||||
assert scale["longStart"]["autos"] == [] # 종단 = 자동 없음
|
||||
assert scale["longLabels"] == ["X(가로) 1/", "Y(세로) 1/", "평면도 1/"]
|
||||
assert scale["longStart"]["values"] == ["1000", "200", ""]
|
||||
assert scale["longStart"]["autos"] == [True] # 종단 = 평면도 칸만 자동(521-2)
|
||||
for bad in ("longLow", "longFrac"): # 범위 밖 · 소수 = 붉음 + 저장 꺼짐
|
||||
assert scale[bad]["errors"] == [RANGE, None]
|
||||
assert scale[bad]["errors"] == [RANGE, None, None]
|
||||
assert scale[bad]["save"] is False
|
||||
assert scale["longOk"]["errors"] == [None, None] and scale["longOk"]["save"] is True
|
||||
assert scale["longOk"]["errors"] == [None, None, None] and scale["longOk"]["save"] is True
|
||||
# 저장본 2장 — 물음 [취소] = 요청 없음 · 모달 그대로
|
||||
assert scale["cancel"] == {"calls": ["ask"], "open": True, "closed": 0}
|
||||
save = scale["save"]
|
||||
assert "저장본 2장" in save["ask"]
|
||||
assert save["sent"] == {"longitudinal": {"x": 500, "y": None}} # Y = 기본 → null
|
||||
assert save["sent"] == {"longitudinal": {"x": 500, "y": None, "plan": None}} # 기본 → null
|
||||
assert save["open"] is False
|
||||
assert save["closed"] == [[True, "longitudinal"]] # 화면 다시 세움 · 같은 도면
|
||||
assert scale["reset"]["values"] == ["1000", "200"] # [기본값] = 칸만
|
||||
assert scale["reset"]["values"] == ["1000", "200", ""] # [기본값] = 칸만
|
||||
assert scale["same"] == {"calls": 0, "toasts": ["바뀐 축척이 없습니다."]}
|
||||
# 평면 위치도 칸(521-2) — 처음 자동(칸 꺼짐) · 1/500 밑 = 붉음 · 1/3,000 저장
|
||||
assert scale["planStart"]["disabled"] == [False, False, True]
|
||||
assert scale["planLow"]["errors"] == [None, None, "1/500~1/50,000 사이 정수"]
|
||||
assert scale["planLow"]["save"] is False
|
||||
assert scale["planSent"] == {"longitudinal": {"x": None, "y": None, "plan": 3000}}
|
||||
|
||||
|
||||
def test_485_2_토적_전개도_자동(out):
|
||||
|
||||
@@ -59,6 +59,6 @@ def test_빈_초안은_고침_아님_취향은_둠(out):
|
||||
|
||||
def test_공용_이동_한_곳에서_부름():
|
||||
nav = (ROOT / "A00_Common/b_workflow_nav.ts").read_text(encoding="utf-8")
|
||||
assert "void enterProject(" in nav and "./b_project_switch" in nav
|
||||
assert "return enterProject(" in nav and "./b_project_switch" in nav
|
||||
ui = (ROOT / "ui_template/ui_template_elements.ts").read_text(encoding="utf-8")
|
||||
assert 'cancelLabel = "취소"' in ui and "label: cancelLabel" in ui
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""520-3 페이지 넘김 공용 `moveStage` — 잠금 → 「n/N 이름 · 지난 초」 → 실패면 잠금 풀고 머묾 + 까닭 → 성공이면 이동.
|
||||
|
||||
`helper_520_3_move_stage.cjs` 가 진짜 `A00_Common/b_workflow_nav.ts` 를 가짜 잠금 · 토스트 · 이동 위에서 돌림.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_520_3_move_stage.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_성공_잠금_단계_글_이동_뒤_풀림(r):
|
||||
o = r["ok"]
|
||||
assert o["ok"] is True and o["lock"] == 0 and o["lockSeen"] == 1
|
||||
assert o["calls"][0] == "p:0.00:1/2 저장 · 0초"
|
||||
assert o["calls"][1] == "p:0.50:2/2 완료 · 0초"
|
||||
assert o["calls"][2] == "p:1.00:페이지 여는 중 · 0초"
|
||||
assert o["calls"][-2:] == ["enter", "go:b08"]
|
||||
|
||||
|
||||
def test_단계가_거짓이면_머묾_뒤_단계_안_돎(r):
|
||||
o = r["stop"]
|
||||
assert o["ok"] is False and o["lock"] == 0
|
||||
assert not any(c.startswith("go:") or c == "완료 돎" for c in o["calls"])
|
||||
|
||||
|
||||
def test_던지면_까닭_토스트_풀림_머묾(r):
|
||||
o = r["fail"]
|
||||
assert o["ok"] is False and o["lock"] == 0
|
||||
assert "toast:error:저장에서 멈췄습니다 — Failed to fetch" in o["calls"]
|
||||
assert not any(c.startswith("go:") for c in o["calls"])
|
||||
|
||||
|
||||
def test_서버_진행_묻기_글_비율_함수_이동(r):
|
||||
o = r["poll"]
|
||||
assert o["asked"] >= 1 and o["lock"] == 0
|
||||
assert any(c.startswith("p:0.50:1/1 서버 저장 — 관 차례 · 1초") for c in o["calls"])
|
||||
assert o["calls"][-1] == "go:fn"
|
||||
|
||||
|
||||
def test_넘기는_중_또_누름_무시(r):
|
||||
assert r["busy"] is False
|
||||
|
||||
|
||||
def test_페이지_셋이_공용_길():
|
||||
for rel in (
|
||||
"B07_DesignDetail/B07_DesignDetail_UI_Page.ts",
|
||||
"B08_Quantity/B08_Quantity_UI_Page.ts",
|
||||
"B09_Estimation/B09_Estimation_UI_Shell.ts",
|
||||
):
|
||||
page = (ROOT / rel).read_text(encoding="utf-8")
|
||||
assert "void moveStage(projectId" in page and "goToWorkflowStage(" not in page, rel
|
||||
@@ -1,4 +1,10 @@
|
||||
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3)."""
|
||||
"""B07 종단도 — 장마다 그림 좌하단 = 작도영역 좌하단 · 고정 축척 · 넘치면 장을 더 나눔 (59-3).
|
||||
|
||||
우측 상단 평면 위치도(521-2) — 칸 자리 · 그래프와 안 겹침 · 장마다 강조 구간 · 척도.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -7,7 +13,21 @@ from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Long import (
|
||||
build_longitudinal_drawing,
|
||||
longitudinal_chunks,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import entities_bbox, usable_area
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_LongPlan import (
|
||||
LONG_PLAN_LAYER_ID,
|
||||
plan_box_overlaps,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Engine_Template import (
|
||||
drawing_scales_path,
|
||||
entities_bbox,
|
||||
usable_area,
|
||||
use_project_templates,
|
||||
)
|
||||
from B07_DesignDetail.B07_DesignDetail_Router_Support_Io import (
|
||||
_read_manifest,
|
||||
_write_manifest,
|
||||
release_old_long_sheets,
|
||||
)
|
||||
|
||||
|
||||
def _longitudinal(count: int, interval: float, slope: float) -> dict:
|
||||
@@ -57,3 +77,123 @@ def test_wide_interval_splits_more() -> None:
|
||||
# 40 m 간격 30측점 = 1,160 mm > 작도영역 폭 → 30개보다 적게 담는다
|
||||
chunks = longitudinal_chunks(_longitudinal(40, 40.0, 0.0))
|
||||
assert len(chunks) > 2
|
||||
|
||||
|
||||
# ── 우측 상단 평면 위치도(521-2 · 요구 340-2) ─────────────────────────────────
|
||||
def _with_xy(longitudinal: dict) -> dict:
|
||||
for station in longitudinal["stations"]:
|
||||
chainage = station["chainage_m"]
|
||||
station["center_x"] = 200000.0 + chainage
|
||||
station["center_y"] = 500000.0 + 60.0 * math.sin(chainage / 150.0)
|
||||
return longitudinal
|
||||
|
||||
|
||||
def _plan_sheets(longitudinal: dict) -> list[dict]:
|
||||
"""장마다 그래프 bbox · 칸 엔티티 · 굵은(이 장) 선 꼭짓점 · 글."""
|
||||
sheets = []
|
||||
for chunk in longitudinal_chunks(longitudinal):
|
||||
entities = build_longitudinal_drawing(longitudinal, chunk["id"], chunk)["entities"]
|
||||
graph = [e for e in entities if e["layerId"] not in (FRAME_LAYER_ID, LONG_PLAN_LAYER_ID)]
|
||||
plan = [e for e in entities if e["layerId"] == LONG_PLAN_LAYER_ID]
|
||||
bold = [
|
||||
(child["shapeData"][key]["x"], child["shapeData"][key]["y"])
|
||||
for entity in plan
|
||||
for child in entity.get("children") or []
|
||||
if child["lineWidth"] == 3
|
||||
for key in ("startPoint", "endPoint")
|
||||
]
|
||||
texts = [e["shapeData"]["label"] for e in plan if e["type"] == "Text"]
|
||||
sheets.append(
|
||||
{
|
||||
"graph": graph,
|
||||
"bbox": entities_bbox(graph),
|
||||
"plan": plan,
|
||||
"bold": bold,
|
||||
"texts": texts,
|
||||
}
|
||||
)
|
||||
return sheets
|
||||
|
||||
|
||||
def test_평면_칸_작도영역_우상단_안_겹침_장마다_강조() -> None:
|
||||
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
|
||||
sheets = _plan_sheets(longitudinal)
|
||||
usable_w, usable_h = usable_area()
|
||||
lows = []
|
||||
for number, sheet in enumerate(sheets, start=1):
|
||||
x0, y0 = sheet["bbox"][0], sheet["bbox"][1]
|
||||
box = entities_bbox(sheet["plan"])
|
||||
# 칸 = 작도 영역 우상단 5 mm 안쪽 150×100 mm
|
||||
assert box == pytest.approx(
|
||||
(x0 + usable_w - 155.0, y0 + usable_h - 105.0, x0 + usable_w - 5.0, y0 + usable_h - 5.0)
|
||||
)
|
||||
assert not plan_box_overlaps(longitudinal, sheet["graph"], sheet["bbox"])
|
||||
assert f"{number}/{len(sheets)} 장" in sheet["texts"]
|
||||
assert any(text.startswith("평면도 S=1/") for text in sheet["texts"])
|
||||
assert sheet["bold"] # 이 장 구간 굵게
|
||||
lows.append(min(x for x, _y in sheet["bold"]) - box[0])
|
||||
# 자동 = 모든 장이 같은 축척 · 같은 자리 — 굵은 구간만 장마다 오른쪽으로 옮겨 감
|
||||
assert lows == sorted(lows) and len(set(lows)) == len(lows)
|
||||
|
||||
|
||||
def test_칸을_덮으면_측점_줄여_장_더_나눔() -> None:
|
||||
# 25 m 간격 30 측점 = 725 mm(폭 안) · 오르막 그래프가 우상단 칸을 덮음
|
||||
plain = longitudinal_chunks(_longitudinal(60, 25.0, 0.1))
|
||||
with_box = _with_xy(_longitudinal(60, 25.0, 0.1))
|
||||
assert longitudinal_chunks(with_box)[0]["end_m"] < plain[0]["end_m"]
|
||||
for sheet in _plan_sheets(with_box):
|
||||
assert not plan_box_overlaps(with_box, sheet["graph"], sheet["bbox"])
|
||||
|
||||
|
||||
def test_좌표_없으면_칸_없음() -> None:
|
||||
assert all(not sheet["plan"] for sheet in _plan_sheets(_longitudinal(40, 20.0, 0.0)))
|
||||
|
||||
|
||||
def test_척도_정하면_이_장_확대_밖_흐린_파선(tmp_path) -> None:
|
||||
longitudinal = _with_xy(_longitudinal(66, 20.0, 0.05))
|
||||
use_project_templates(tmp_path)
|
||||
try:
|
||||
path = drawing_scales_path(tmp_path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"longitudinal": {"plan": 3000}}), encoding="utf-8")
|
||||
sheets = _plan_sheets(longitudinal)
|
||||
finally:
|
||||
use_project_templates(None)
|
||||
for sheet in sheets:
|
||||
assert "평면도 S=1/3,000" in sheet["texts"]
|
||||
assert any(entity.get("lineDash") for entity in sheet["plan"]) # 밖 노선 파선
|
||||
box = entities_bbox(sheet["plan"])
|
||||
xs = [x for x, _y in sheet["bold"]]
|
||||
# 이 장 중심 = 칸 노선 자리 가운데
|
||||
assert (min(xs) + max(xs)) / 2.0 == pytest.approx((box[0] + box[2]) / 2.0, abs=1.0)
|
||||
|
||||
|
||||
def test_칸_앞_확정_종단만_한_번_풂(tmp_path) -> None:
|
||||
drawings = tmp_path / "B07_DesignDetail" / "drawings"
|
||||
drawings.mkdir(parents=True)
|
||||
old = {"kind": "longitudinal", "confirmed": True, "file": "x"}
|
||||
_write_manifest(
|
||||
tmp_path,
|
||||
{
|
||||
"drawings": {
|
||||
"longitudinal_1": dict(old),
|
||||
"longitudinal_2": dict(old),
|
||||
"cross_00020m": {"kind": "cross", "confirmed": True, "file": "x"},
|
||||
}
|
||||
},
|
||||
)
|
||||
for name in ("longitudinal_1", "longitudinal_2", "cross_00020m"):
|
||||
(drawings / f"{name}.json").write_text('{"layers": []}', encoding="utf-8")
|
||||
release_old_long_sheets(tmp_path)
|
||||
after = _read_manifest(tmp_path)["drawings"]
|
||||
assert not after["longitudinal_1"]["confirmed"] and not after["longitudinal_2"]["confirmed"]
|
||||
assert after["cross_00020m"]["confirmed"]
|
||||
assert not (drawings / "longitudinal_1.json").exists() # 옛 저장본 걷힘
|
||||
# 새 확정본(칸 층 있음)은 그대로
|
||||
after["longitudinal_1"] = dict(old)
|
||||
_write_manifest(tmp_path, {"drawings": after})
|
||||
(drawings / "longitudinal_1.json").write_text(
|
||||
json.dumps({"layers": [{"id": LONG_PLAN_LAYER_ID}]}), encoding="utf-8"
|
||||
)
|
||||
release_old_long_sheets(tmp_path)
|
||||
assert _read_manifest(tmp_path)["drawings"]["longitudinal_1"]["confirmed"]
|
||||
|
||||
@@ -64,6 +64,8 @@ export interface CadSaveResult<D, Q> {
|
||||
export interface CadHostOptions {
|
||||
/** iframe 제목(접근성). */
|
||||
title: string;
|
||||
/** 처음부터 캔버스만(`?bare`) — 도면이 오기 전에도 리본 · 패널을 안 그림(522-2). */
|
||||
bare?: boolean;
|
||||
/** CAD 편집 통지 — dirty 는 미저장 편집이 있는가. */
|
||||
onChanged?: (dirty: boolean) => void;
|
||||
/** CAD 리본의 DXF·DWG 내보내기 단추. */
|
||||
@@ -142,7 +144,7 @@ export function createCadHost<D extends CadHostDrawing, M = unknown, Q = unknown
|
||||
element.className = "cad-host";
|
||||
const frame = document.createElement("iframe");
|
||||
frame.className = "cad-host__frame";
|
||||
frame.src = CAD_APP_URL;
|
||||
frame.src = options.bare ? `${CAD_APP_URL}?bare` : CAD_APP_URL;
|
||||
frame.title = options.title;
|
||||
// CAD 안(리본 · 패널)도 브라우저 기본 메뉴를 막음 — 부모 문서의 듣기는 iframe 에 안 닿음(계획서 39-9a)
|
||||
frame.addEventListener("load", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "./ui_template_elements_base";
|
||||
import { createProgressCircle, type ProgressCircleHandle } from "./ui_template_progress";
|
||||
|
||||
// 차트·기본 스타일 조각은 파일이 700줄을 넘어 떼어냈다(2026-09-04).
|
||||
// 여기서 그대로 다시 내보내 호출부의 import 경로는 불변이다.
|
||||
@@ -225,6 +226,7 @@ export function createTag(label: string, variant: TagVariant = "neutral"): HTMLS
|
||||
|
||||
let overlayEl: HTMLDivElement | null = null;
|
||||
let overlayCount = 0;
|
||||
let overlayProgress: ProgressCircleHandle | null = null;
|
||||
|
||||
function ensureOverlay(): HTMLDivElement {
|
||||
if (overlayEl) return overlayEl;
|
||||
@@ -248,10 +250,25 @@ export function showLoadingOverlay(): void {
|
||||
export function hideLoadingOverlay(): void {
|
||||
overlayCount = Math.max(0, overlayCount - 1);
|
||||
if (overlayCount === 0 && overlayEl) {
|
||||
overlayEl.classList.remove("is-active");
|
||||
overlayEl.classList.remove("is-active", "ui-overlay--progress");
|
||||
overlayProgress?.remove();
|
||||
overlayProgress = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 띄운 잠금판에 진행 원 · 글(520-3) — 스피너 자리 · 모든 참조가 풀리면 함께 걷힘.
|
||||
* ratio null = 진행률 미상(회전만). 잠금판이 안 떠 있으면 아무것도 안 함. */
|
||||
export function setLoadingOverlayProgress(ratio: number | null, label?: string): void {
|
||||
if (overlayCount === 0) return;
|
||||
const overlay = ensureOverlay();
|
||||
if (!overlayProgress) {
|
||||
overlayProgress = createProgressCircle();
|
||||
overlay.append(overlayProgress.root);
|
||||
overlay.classList.add("ui-overlay--progress");
|
||||
}
|
||||
overlayProgress.set(ratio, label);
|
||||
}
|
||||
|
||||
/* -----------------------------------------------------------------------------
|
||||
* 6. 토스트 알림 (Toast) — 성공/에러 배너
|
||||
* -------------------------------------------------------------------------- */
|
||||
|
||||
@@ -204,6 +204,9 @@ const BASE_CSS = `
|
||||
z-index: var(--z-overlay);
|
||||
}
|
||||
.ui-overlay.is-active { display: flex; }
|
||||
/* 진행 잠금판(520-3) — 스피너 대신 진행 원 · 단계 글 */
|
||||
.ui-overlay--progress > .ui-spinner { display: none; }
|
||||
.ui-overlay .ui-progress-circle__label { max-width: 40ch; }
|
||||
.ui-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
|
||||
Reference in New Issue
Block a user