Merge remote-tracking branch 'origin/dev' into sub_laptop_7
This commit is contained in:
@@ -154,12 +154,16 @@ const out = (v: number | null, key = false, digits = 0): HTMLElement =>
|
||||
const lineRow = (line: SheetLine, label = line.이름): HTMLElement =>
|
||||
createCalcFormulaRow(label, createCalcFormula(sheetFormula(line.글)), out(line.금액, false, 1));
|
||||
|
||||
/** 변수 칸 하나 — 고르기 있으면 고르기 · 아니면 글 칸(Enter · 떠남 때 넘김) */
|
||||
function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
/** 변수 칸 하나 — 고르기 있으면 고르기(값 없으면 빈 첫 칸) · 아니면 글 칸(Enter · 떠남 때 넘김) ·
|
||||
* 722-1 공용 단가산출 상세 박스 옵션도 씀 */
|
||||
export function varRow(spec: SheetInput, value: string, onInput: (v: string) => void): HTMLElement {
|
||||
let control: HTMLElement;
|
||||
if (spec.고르기?.length) {
|
||||
control = createSelectField({
|
||||
options: spec.고르기.map((o) => ({ value: String(o), text: String(o) })),
|
||||
options: [...(value ? [] : [""]), ...spec.고르기.map(String)].map((o) => ({
|
||||
value: o,
|
||||
text: o,
|
||||
})),
|
||||
value,
|
||||
compact: true,
|
||||
ariaLabel: spec.이름,
|
||||
|
||||
@@ -24,6 +24,7 @@ from config.config_system import (
|
||||
SEND_ANALYSIS_COMPLETION_EMAIL,
|
||||
SHEET_SURFACE_DEFAULT_METHOD,
|
||||
SURFACE_AUTO_METHODS,
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD,
|
||||
SURFACE_CONTOUR_INTERVAL_M,
|
||||
)
|
||||
|
||||
@@ -67,7 +68,8 @@ async def trigger_wf1_analysis_and_email(
|
||||
# 자동 전처리는 기본 조합만 만든다 — 필터는 엔진이 입력 LAS를 보고 정하고
|
||||
# (분류 있으면 classification, 없으면 csf), 표현은 SURFACE_AUTO_METHODS.
|
||||
# 다른 조합은 관리자가 B04 드롭다운에서 요청할 때 만든다 (2026-09-01 사용자 확정).
|
||||
methods = list(SURFACE_AUTO_METHODS)
|
||||
# 확정 표현(implicit)도 함께 지음 — 엔진이 기본 필터에만 지음(요구 558).
|
||||
methods = list(dict.fromkeys([*SURFACE_AUTO_METHODS, SURFACE_CONFIRM_DEFAULT_METHOD]))
|
||||
params = {
|
||||
"input_file_id": str(input_file_id),
|
||||
"source_filters": None,
|
||||
@@ -176,10 +178,10 @@ async def trigger_wf1_analysis_and_email(
|
||||
else:
|
||||
# 자동 확정은 엔진이 정한 기본 필터를 그대로 따른다 — 이제 그 필터
|
||||
# 하나만 만들어 두므로 config 기본값과 어긋나면 확정할 모델이 없다.
|
||||
# 표현 · 평활은 config 확정 기본값(implicit · 평활 끔 — 요구 558).
|
||||
selection = surface_confirmation_defaults()
|
||||
if resolved_filters:
|
||||
selection["source_filter"] = resolved_filters[0]
|
||||
selection["method"] = methods[0]
|
||||
try:
|
||||
model_id = await find_surface_model_for_selection(
|
||||
connection, project_id, selection
|
||||
|
||||
@@ -279,7 +279,7 @@ def run_surface_analysis(
|
||||
_report(40, "ground_filter", "지면 필터 적용 중")
|
||||
# 자동 전처리는 목록을 넘기지 않는다 — 기본 필터는 입력 LAS를 보고 여기서 정한다.
|
||||
if not source_filters:
|
||||
source_filters = resolve_auto_source_filters(data)
|
||||
source_filters = resolve_auto_source_filters(data, processed_dir)
|
||||
logger.info("B04 자동 전처리 기본 필터: %s", ", ".join(source_filters))
|
||||
masks: dict[str, np.ndarray] = {}
|
||||
for filter_key in source_filters:
|
||||
|
||||
@@ -5,11 +5,18 @@
|
||||
쓸 수 있는지는 has_classified_ground()로 먼저 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from config.config_system import SURFACE_CLASSIFIED_GROUND_MIN_RATIO
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from config.config_system import (
|
||||
SURFACE_CLASSIFIED_COVERAGE_CELL_M,
|
||||
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
|
||||
SURFACE_CLASSIFIED_GROUND_MIN_RATIO,
|
||||
)
|
||||
|
||||
# ASPRS 표준 지면 클래스 코드
|
||||
GROUND_CLASS_CODE = 2
|
||||
@@ -38,3 +45,51 @@ def has_classified_ground(
|
||||
return False
|
||||
ground = int(np.count_nonzero(classification == GROUND_CLASS_CODE))
|
||||
return ground / total >= minimum_ratio
|
||||
|
||||
|
||||
def classified_coverage(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
cell_meters: float = SURFACE_CLASSIFIED_COVERAGE_CELL_M,
|
||||
) -> float:
|
||||
"""점 있는 칸 중 class 2 가 있는 칸의 비율 — 일부만 분류된 LAS 를 가려냄."""
|
||||
if "classification" not in structured_data:
|
||||
return 0.0
|
||||
classification = np.asarray(structured_data["classification"])
|
||||
xyz = structured_data["xyz"]
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
cell = max(float(cell_meters), 0.1)
|
||||
cols = int((bounds[0, 1] - bounds[0, 0]) // cell) + 1
|
||||
rows = int((bounds[1, 1] - bounds[1, 0]) // cell) + 1
|
||||
occupied = np.zeros((rows, cols), dtype=bool)
|
||||
ground = np.zeros((rows, cols), dtype=bool)
|
||||
for start in range(0, len(classification), 5_000_000):
|
||||
points = np.asarray(xyz[start : start + 5_000_000, :2], dtype=np.float64)
|
||||
gx = np.clip(((points[:, 0] - bounds[0, 0]) // cell).astype(np.int32), 0, cols - 1)
|
||||
gy = np.clip(((points[:, 1] - bounds[1, 0]) // cell).astype(np.int32), 0, rows - 1)
|
||||
occupied[gy, gx] = True
|
||||
is_ground = classification[start : start + len(points)] == GROUND_CLASS_CODE
|
||||
ground[gy[is_ground], gx[is_ground]] = True
|
||||
total = int(np.count_nonzero(occupied))
|
||||
return float(np.count_nonzero(ground)) / total if total else 0.0
|
||||
|
||||
|
||||
def has_classified_coverage(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
processed_dir: Path | None = None,
|
||||
minimum_ratio: float = SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO,
|
||||
) -> bool:
|
||||
"""분류 덮임 문턱 판정 — `processed_dir` 면 structured.npz 지문으로 저장본 재사용."""
|
||||
if processed_dir is None:
|
||||
return classified_coverage(structured_data) >= minimum_ratio
|
||||
stat = (processed_dir / "structured.npz").stat()
|
||||
stamp = [int(stat.st_size), int(stat.st_mtime_ns)]
|
||||
path = processed_dir / "classification_coverage.json"
|
||||
try:
|
||||
stored = json.loads(path.read_text(encoding="utf-8"))
|
||||
coverage = float(stored["coverage"]) if stored.get("structured") == stamp else None
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
coverage = None
|
||||
if coverage is None:
|
||||
coverage = classified_coverage(structured_data)
|
||||
atomic_write_json(path, {"structured": stamp, "coverage": coverage})
|
||||
return coverage >= minimum_ratio
|
||||
|
||||
@@ -5,12 +5,14 @@
|
||||
source_filters를 따른다.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
filter_classification,
|
||||
has_classified_coverage,
|
||||
has_classified_ground,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_CSF import filter_csf
|
||||
@@ -45,14 +47,18 @@ def usable_conditional_filters(
|
||||
|
||||
def resolve_auto_source_filters(
|
||||
structured_data: dict[str, Any] | np.lib.npyio.NpzFile,
|
||||
processed_dir: Path | None = None,
|
||||
) -> list[str]:
|
||||
"""자동 전처리가 만들 필터를 입력 LAS를 보고 정한다. 첫 항목이 기본 확정값이다.
|
||||
|
||||
업체가 이미 지면을 분류해 놨으면 그것을 기본으로 쓴다 — 계산이 없고 품질도 가장
|
||||
안정적이다. **csf는 분류가 있어도 함께 만든다** — csf 처리 결과를 눈으로 검증할 수
|
||||
있어야 하기 때문이다 (2026-09-01 사용자 확정). 분류가 없으면 csf 하나만 남는다.
|
||||
「분류 있음」 = 비율 문턱 + 덮임(일부만 분류된 LAS 거름) — 자동 고름에만(요구 558).
|
||||
"""
|
||||
usable = usable_conditional_filters(structured_data)
|
||||
if usable and not has_classified_coverage(structured_data, processed_dir):
|
||||
usable = []
|
||||
return [*usable[:1], SURFACE_AUTO_FALLBACK_FILTER]
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ TerrainContext에서 파생 격자·샘플을 받아 각 표현의 모델(npz)
|
||||
(GLB/PLY)를 저장하고 메타데이터를 반환한다.
|
||||
"""
|
||||
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
@@ -25,6 +26,7 @@ from B04_PreProcess.B04_PreProcess_Engine_ModelContext import (
|
||||
write_binary_ply,
|
||||
write_glb,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import implicit_grid_npz
|
||||
|
||||
|
||||
def build_tin(
|
||||
@@ -168,11 +170,19 @@ def build_nurbs(
|
||||
)
|
||||
|
||||
|
||||
def implicit_point_count(ground_count: int, config: dict[str, Any]) -> int:
|
||||
"""implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부."""
|
||||
minimum = int(config["implicit_min_points"])
|
||||
if ground_count <= minimum:
|
||||
return int(ground_count)
|
||||
ratio = round(ground_count * float(config["implicit_point_ratio"]))
|
||||
return int(min(max(ratio, minimum), int(config["implicit_max_points"])))
|
||||
|
||||
|
||||
def build_implicit(
|
||||
context: TerrainContext, output_dir: Path, stem: str, progress: ProgressCallback
|
||||
) -> dict[str, Any]:
|
||||
maximum = max(100, int(context.config["implicit_max_points_per_tile"]))
|
||||
points = context.sample(maximum)
|
||||
points = context.grid_sample(implicit_point_count(context.source_count, context.config))
|
||||
unique_xy, unique_indices = np.unique(points[:, :2], axis=0, return_index=True)
|
||||
points = points[unique_indices]
|
||||
if len(points) < 4:
|
||||
@@ -209,6 +219,8 @@ def build_implicit(
|
||||
smoothing=np.array([float(context.config["implicit_smoothing"])], np.float32),
|
||||
)
|
||||
write_glb(preview_path, vertices, faces, context.bounds)
|
||||
with suppress(OSError, ValueError): # 641-1 굳힌 격자 — 못 지으면 처음 읽을 때 지음
|
||||
implicit_grid_npz(output_dir, stem.removeprefix("implicit_"))
|
||||
progress(100)
|
||||
return with_footprint(
|
||||
context,
|
||||
|
||||
@@ -29,6 +29,8 @@ ProgressCallback = Callable[[int], None]
|
||||
|
||||
# 대용량 포인트 배치 처리 크기
|
||||
_BATCH_SIZE = 500_000
|
||||
# implicit 격자 칸 뽑기 씨앗 — 고정이라 다시 지어도 같은 중심점
|
||||
IMPLICIT_SAMPLE_SEED = 558
|
||||
|
||||
# NURBS 평활 계수 — 제어점당 허용 잔차제곱(m²).
|
||||
# 0.01(RMS 0.1m)은 요구가 너무 빡빡해 FITPACK이 제어점 수(122x113)보다 많은
|
||||
@@ -262,6 +264,30 @@ class TerrainContext:
|
||||
self._samples[maximum] = points
|
||||
return points
|
||||
|
||||
def grid_sample(self, count: int, seed: int = IMPLICIT_SAMPLE_SEED) -> np.ndarray:
|
||||
"""implicit 전용 — 격자 칸(√(외곽 넓이 / 점 수))마다 하나 · 모자라면 무작위로 채움.
|
||||
|
||||
파일 차례 뽑기는 점 많은 트인 곳에 몰려 숲 밑 성긴 곳이 빔(요구 558). 씨앗 고정.
|
||||
"""
|
||||
indices = self.indices()
|
||||
if len(indices) <= count:
|
||||
return np.asarray(self.xyz[indices], dtype=np.float32)
|
||||
_, _, resolution, footprint = self.footprint()
|
||||
cell = max(math.sqrt(float(footprint.sum()) * resolution * resolution / count), 0.01)
|
||||
xy = np.asarray(self.xyz[indices][:, :2], dtype=np.float64)
|
||||
rng = np.random.default_rng(seed)
|
||||
order = rng.permutation(len(indices))
|
||||
gx = ((xy[order, 0] - self.bounds[0, 0]) // cell).astype(np.int64)
|
||||
gy = ((xy[order, 1] - self.bounds[1, 0]) // cell).astype(np.int64)
|
||||
_, first = np.unique(gy * (int(gx.max()) + 1) + gx, return_index=True)
|
||||
chosen = order[first]
|
||||
if len(chosen) > count:
|
||||
chosen = rng.choice(chosen, count, replace=False)
|
||||
elif len(chosen) < count:
|
||||
rest = np.setdiff1d(np.arange(len(indices)), chosen, assume_unique=True)
|
||||
chosen = np.concatenate([chosen, rng.choice(rest, count - len(chosen), replace=False)])
|
||||
return np.asarray(self.xyz[indices[np.sort(chosen)]], dtype=np.float32)
|
||||
|
||||
def footprint(self) -> tuple[float, float, float, np.ndarray]:
|
||||
if self._footprint is not None:
|
||||
return self._footprint
|
||||
|
||||
@@ -151,7 +151,13 @@ _REPRESENTATIONS = {
|
||||
def _cache_is_valid(
|
||||
output_dir: Path, stem: str, method: str, entry: dict[str, Any], config: dict[str, Any]
|
||||
) -> bool:
|
||||
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다."""
|
||||
"""디스크의 결과 파일과 스무딩 메타데이터가 유효한지 검사한다.
|
||||
|
||||
manifest 항목이 완료여야 함 — 설정 지문이 바뀌면 manifest 가 비므로 파일만 남은
|
||||
옛 결과(평활 없는 implicit · nurbs · meshfree)를 다시 짓는다(요구 558).
|
||||
"""
|
||||
if entry.get("status") != "completed":
|
||||
return False
|
||||
ext = "ply" if method == "meshfree" else "glb"
|
||||
files_exist = (output_dir / f"{stem}_preview.{ext}").exists() and (
|
||||
output_dir / f"{stem}.npz"
|
||||
@@ -190,6 +196,8 @@ def _build_all_terrain_models(
|
||||
methods_list.remove("dtm")
|
||||
methods_list.insert(0, "dtm")
|
||||
methods = tuple(methods_list)
|
||||
# 무거운 implicit 은 기본 필터(첫 필터)에만 — 분류 LAS 의 csf 까지 두 번 짓지 않음(요구 558).
|
||||
later_methods = tuple(method for method in methods if method != "implicit")
|
||||
signature = config_signature(config)
|
||||
bounds = np.asarray(structured_data["bounds"], dtype=np.float64)
|
||||
xyz = structured_data["xyz"]
|
||||
@@ -211,7 +219,7 @@ def _build_all_terrain_models(
|
||||
}
|
||||
started_at = time.monotonic()
|
||||
timeout = max(0, int(config.get("sync_timeout_seconds", 0)))
|
||||
total_units = max(1, len(filters) * len(methods))
|
||||
total_units = max(1, len(methods) + (len(filters) - 1) * len(later_methods))
|
||||
done_units = 0
|
||||
failures = 0
|
||||
|
||||
@@ -229,27 +237,11 @@ def _build_all_terrain_models(
|
||||
)
|
||||
filter_entry["source_point_count"] = context.source_count
|
||||
|
||||
for method in methods:
|
||||
for method in later_methods if filter_index else methods:
|
||||
stem = f"{method}_{filter_key}"
|
||||
entry = filter_entry["methods"].get(method, {})
|
||||
|
||||
if not force and _cache_is_valid(output_dir, stem, method, entry, config):
|
||||
if entry.get("status") != "completed":
|
||||
entry.update(
|
||||
{
|
||||
"status": "completed",
|
||||
"representation": _REPRESENTATIONS[method],
|
||||
"model_file": f"{stem}.npz",
|
||||
"preview_file": f"{stem}_preview."
|
||||
+ ("ply" if method == "meshfree" else "glb"),
|
||||
"preview_media_type": "application/octet-stream"
|
||||
if method == "meshfree"
|
||||
else "model/gltf-binary",
|
||||
"error": None,
|
||||
}
|
||||
)
|
||||
filter_entry["methods"][method] = entry
|
||||
_write_json_file(manifest_path, manifest)
|
||||
interval = float(config.get("contour_interval_meters", 1.0))
|
||||
contour_path = output_dir / f"contour_{filter_key}_{method}_{interval}m.json"
|
||||
needs_contours = not contour_path.exists()
|
||||
|
||||
@@ -54,7 +54,7 @@ import { currentProjectId, guardProjectLeave } from "../A00_Common/b_project_swi
|
||||
const SOURCE_FILTERS = ["classification", "grid_min_z", "csf", "pmf"] as const;
|
||||
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
|
||||
const DEFAULT_FILTER = "csf";
|
||||
const DEFAULT_METHOD = "dtm";
|
||||
const DEFAULT_METHOD = "implicit"; // 확정 기본 표현(요구 558) — 확정본이 있으면 그것이 먼저
|
||||
const ROUTE_STAGE = ROUTES.B05_PROFILE;
|
||||
// 도엽 서피스 보간 방식 버튼 순서 — 백엔드 SHEET_SURFACE_METHODS와 같은 차례로 둔다.
|
||||
const SHEET_METHOD_ORDER = ["tin_sheet", "tin", "biharmonic", "anudem", "multires", "laplace"];
|
||||
@@ -494,7 +494,7 @@ export async function renderB04Surface(root: HTMLElement): Promise<void> {
|
||||
]);
|
||||
models = modelResponse.models;
|
||||
// 확정본과 같은 조합에서 시작해야 B05와 같은 파일을 보고, 보관함도 한 벌만 쓴다.
|
||||
// 확정 이력이 없을 때만 개발 기본값(csf·dtm)으로 둔다.
|
||||
// 확정 이력이 없을 때만 개발 기본값(csf·implicit)으로 둔다.
|
||||
if (confirmed.model_id) {
|
||||
if (confirmed.source_filter) filterGroup.select.value = confirmed.source_filter;
|
||||
if (confirmed.method) methodGroup.select.value = confirmed.method;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
(화면 묶음 stations · ground 단계와 같은 함수). 파이썬은 머리(schema_version · source ·
|
||||
coordinate_reference · cad_exchange · options)만 붙임.
|
||||
|
||||
DTM 지표면만(격자 이진 = `dtm_grid_file`) — DTM 아닌 지표면(tin · nurbs · implicit · meshfree) ·
|
||||
DTM · implicit(굳힌 격자 641-1) 지표면만(격자 이진 = `dtm_grid_file`) — 그 밖(tin · nurbs · meshfree) ·
|
||||
Node 못 돌림 = None → 부르는 쪽이 파이썬 `generate_sections`(짝 · 로그 남김).
|
||||
측점 열쇠(`key`) = 노선 저장 이월 열쇠(`B05_Profile_Route_Save._carry_cross`).
|
||||
"""
|
||||
|
||||
@@ -40,7 +40,11 @@ from common_util.common_util_surface_confirmation import (
|
||||
design_terrain,
|
||||
get_surface_confirmation_params,
|
||||
)
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler, dtm_grid_file
|
||||
from common_util.common_util_surface_sampler import (
|
||||
build_surface_sampler,
|
||||
dtm_grid_file,
|
||||
implicit_grid_npz,
|
||||
)
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -161,15 +165,17 @@ async def choosable_terrains(
|
||||
async def _design_model(
|
||||
connection: aiomysql.Connection, project_id: UUID, params: dict
|
||||
) -> tuple[int, str, str] | None:
|
||||
"""설계 지형 DTM 모델 (id, 표현, 파일 경로) — 서버 표본기처럼 stage 1 params 의 `dtm_{filter}.npz`
|
||||
(확정 행 먼저). 확정 행이 params 와 어긋나도 서버와 같은 바닥을 받게(578-1) · 없으면 None."""
|
||||
name = f"dtm_{params.get('source_filter') or ''}.npz"
|
||||
"""설계 지형 격자 모델 (id, 표현, 파일 경로) — 서버 표본기처럼 stage 1 params 의
|
||||
`{dtm|implicit}_{filter}.npz`(확정 행 먼저 · implicit = 굳힌 격자 641-1). 확정 행이 params 와
|
||||
어긋나도 서버와 같은 바닥을 받게(578-1) · 없으면 None."""
|
||||
method = "implicit" if params.get("method") == "implicit" else "dtm"
|
||||
name = f"{method}_{params.get('source_filter') or ''}.npz"
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"SELECT id, model_type, model_file_path FROM surface_models"
|
||||
" WHERE project_id = %s AND model_type = 'dtm' AND status IN ('CONFIRMED', 'COMPLETE')"
|
||||
" WHERE project_id = %s AND model_type = %s AND status IN ('CONFIRMED', 'COMPLETE')"
|
||||
" ORDER BY status = 'CONFIRMED' DESC, created_at DESC",
|
||||
(str(project_id),),
|
||||
(str(project_id), method),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
row = next((r for r in rows if Path(str(r[2] or "")).name == name), None)
|
||||
@@ -181,15 +187,16 @@ def _design_grid(
|
||||
) -> dict | None:
|
||||
"""설계 지형 격자 받을 자리(PLAN 344-1) — 서버 표본기(`build_surface_sampler`)와 같은 파일만.
|
||||
|
||||
DTM 아닌 표현(TIN 등)은 None → 화면은 서버 표본 길 그대로. `signature` = manifest 구성 ·
|
||||
평활 서명 — 화면이 주소에 붙여 지형이 바뀐 뒤 옛 격자를 먼저 내지 않음.
|
||||
DTM · implicit(굳힌 격자) 밖 표현(TIN 등)은 None → 화면은 서버 표본 길 그대로. `signature` =
|
||||
manifest 구성 · 평활 서명 — 화면이 주소에 붙여 지형이 바뀐 뒤 옛 격자를 먼저 내지 않음.
|
||||
"""
|
||||
source_filter = str(params.get("source_filter") or "")
|
||||
if not confirmed or params.get("method") != "dtm" or confirmed[1] != "dtm":
|
||||
method = params.get("method")
|
||||
if not confirmed or method not in ("dtm", "implicit") or confirmed[1] != method:
|
||||
return None
|
||||
if Path(confirmed[2]).name != f"dtm_{source_filter}.npz":
|
||||
if Path(confirmed[2]).name != f"{method}_{source_filter}.npz":
|
||||
return None
|
||||
smooth = bool(params.get("smooth"))
|
||||
smooth = bool(params.get("smooth")) and method == "dtm"
|
||||
try:
|
||||
manifest = json.loads((models_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
@@ -206,7 +213,8 @@ def _design_grid(
|
||||
|
||||
|
||||
async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool) -> Path:
|
||||
"""그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) — 없으면 LookupError(까닭).
|
||||
"""그 프로젝트 DTM 모델의 npz(평활 고르기 = 서버 표본기와 같음) · implicit 모델 = 굳힌 격자
|
||||
(641-1 · 표본기와 같은 파일) — 없으면 LookupError(까닭).
|
||||
|
||||
경로 = 프로젝트 storage 안 DB 상대 경로만(밖으로 나가면 LookupError · 계산 창구 `/calc` 도 씀).
|
||||
"""
|
||||
@@ -218,12 +226,19 @@ async def dtm_grid_npz(connection, project_id: UUID, model_id: int, smooth: bool
|
||||
(model_id, str(project_id)),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not stored or not row or row[0] != "dtm" or not row[1]:
|
||||
if not stored or not row or row[0] not in ("dtm", "implicit") or not row[1]:
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
root = Path(resolve_stored_project_path(stored))
|
||||
model_path = (root / str(row[1])).resolve()
|
||||
if not model_path.is_relative_to(root.resolve()):
|
||||
raise LookupError("DTM 격자가 없는 지표면 모델입니다.")
|
||||
if row[0] == "implicit":
|
||||
try:
|
||||
return await asyncio.to_thread(
|
||||
implicit_grid_npz, model_path.parent, model_path.stem.removeprefix("implicit_")
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise LookupError("implicit 격자를 굳히지 못했습니다.") from exc
|
||||
smooth_path = model_path.with_name(f"{model_path.stem}_smooth.npz")
|
||||
npz_path = smooth_path if smooth and smooth_path.is_file() else model_path
|
||||
if not npz_path.is_file():
|
||||
|
||||
@@ -255,12 +255,13 @@ def _enforce_stored_designs(
|
||||
|
||||
|
||||
def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path | None:
|
||||
"""설계 지형이 DTM 이면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와
|
||||
같은 npz) · DTM 아닌 지표면(tin · nurbs · …) · 못 엶 = None(파이썬 길)."""
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file
|
||||
"""설계 지형이 DTM · implicit 면 격자 이진(`dtm_grid_file` · 표본기 `build_surface_sampler` 와
|
||||
같은 npz · implicit = 굳힌 격자 641-1) · 그 밖 지표면(tin · nurbs · …) · 못 엶 = None
|
||||
(파이썬 길)."""
|
||||
from common_util.common_util_surface_sampler import dtm_grid_file, implicit_grid_npz
|
||||
|
||||
params = params or {}
|
||||
if params.get("method") != "dtm" or not params.get("source_filter"):
|
||||
if params.get("method") not in ("dtm", "implicit") or not params.get("source_filter"):
|
||||
return None
|
||||
models = project_root / "B04_PreProcess" / "models"
|
||||
smooth = models / f"dtm_{params['source_filter']}_smooth.npz"
|
||||
@@ -270,6 +271,8 @@ def design_grid_file(project_root: Path, params: dict[str, Any] | None) -> Path
|
||||
else smooth.with_name(f"dtm_{params['source_filter']}.npz")
|
||||
)
|
||||
try:
|
||||
if params["method"] == "implicit":
|
||||
npz = implicit_grid_npz(models, str(params["source_filter"]))
|
||||
return dtm_grid_file(npz) if npz.is_file() else None
|
||||
except (OSError, ValueError, KeyError) as exc:
|
||||
logger.warning("서버 재계산: DTM 격자 이진을 못 만듦 — 파이썬 길 · %s", exc)
|
||||
|
||||
@@ -476,7 +476,7 @@ def _hillshade_for_box(
|
||||
|
||||
params = getattr(context, "surface_params", None) or {}
|
||||
source_filter = str(params.get("source_filter") or "csf")
|
||||
smooth = bool(params.get("smooth", True))
|
||||
smooth = bool(params.get("smooth", True)) or params.get("method") == "implicit" # 641-2
|
||||
models_dir = Path(context.project_root) / "B04_PreProcess" / "models"
|
||||
candidates = [models_dir / f"dtm_{source_filter}_smooth.npz"] if smooth else []
|
||||
candidates.append(models_dir / f"dtm_{source_filter}.npz")
|
||||
|
||||
@@ -105,7 +105,8 @@ def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Pa
|
||||
return source, None
|
||||
npz = Path(models) / f"dtm_{picked.get('source_filter') or ''}.npz"
|
||||
smooth = npz.with_name(f"{npz.stem}_smooth.npz")
|
||||
npz = smooth if picked.get("smooth") and smooth.is_file() else npz
|
||||
implicit = picked.get("method") == "implicit" # 641-2 — implicit 확정이어도 평활 DTM
|
||||
npz = smooth if (picked.get("smooth") or implicit) and smooth.is_file() else npz
|
||||
return got, npz if npz.is_file() else None
|
||||
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { buildCalc } from "./M01_MasterData_UI_Logic_Calc";
|
||||
import { buildList, logicId, type ListItem, type ListMark } from "./M01_MasterData_UI_Logic_List";
|
||||
import type { SideHandle } from "./M01_MasterData_UI_Side";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { buildLabDetail, isOwnLogic } from "./M01_MasterData_UI_LogicLab_Detail";
|
||||
import { buildLabDetail, isOwnLogic, trial } from "./M01_MasterData_UI_LogicLab_Detail";
|
||||
import { setLogicOpener } from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { openCopyScreen } from "./M01_MasterData_UI_LogicLab_Copy";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
@@ -91,10 +91,10 @@ interface Opened extends Draft {
|
||||
/** 줄의 흐름(있으면 흐름도로 셈) */
|
||||
const flowOf = (row: LogicRow | null): CfLogic | null => (row?.흐름 as CfLogic | undefined) ?? null;
|
||||
const OWN_FILE = "로직_자체.json";
|
||||
/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) */
|
||||
/** 시험 계산에 넘길 줄 — 흐름 로직은 입력 칸을 흐름의 「설계 때 정할 것」으로(옛 식 칸은 서버가 지움) · 그 밖은 시험 고침(722-2) 덮은 사본 */
|
||||
const calcRow = (row: LogicRow | null): LogicRow | null => {
|
||||
const f = flowOf(row);
|
||||
return f && row ? { ...row, 입력: designFields(f) } : row;
|
||||
return f && row ? { ...row, 입력: designFields(f) } : trial.apply(row);
|
||||
};
|
||||
|
||||
const cacheKey = "m01_logic_drafts_lab";
|
||||
@@ -218,7 +218,7 @@ export async function mountM01LogicLab(
|
||||
file: current.file,
|
||||
row: calcRow(current.row) as LogicRow,
|
||||
prices: current.prices,
|
||||
dirty: () => current.id in drafts,
|
||||
dirty: () => current.id in drafts || trial.has(current.row),
|
||||
values: current.values,
|
||||
onText: (text) => {
|
||||
current.text = text;
|
||||
@@ -233,8 +233,7 @@ export async function mountM01LogicLab(
|
||||
};
|
||||
|
||||
const drawEditor = (): void => {
|
||||
// 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
|
||||
const listing = !opened;
|
||||
const listing = !opened; // 고른 로직이 없으면 목록 · 있으면 호표 표 + 시험 계산
|
||||
list.root.hidden = !listing || !loaded;
|
||||
waiting.hidden = loaded;
|
||||
panes.root.hidden = !opened;
|
||||
@@ -257,6 +256,7 @@ export async function mountM01LogicLab(
|
||||
ownHost: current.ownHost,
|
||||
onChange: touch,
|
||||
onInput: (name, v) => syncLeft(current, { [name]: v }, true),
|
||||
onTrial: drawCalc,
|
||||
calcHost: calc,
|
||||
flowHost: flowOf(current.row) ? current.flowHost : undefined,
|
||||
admin,
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Cover.ts
|
||||
* 단가산출 상세 줄 「그 자리 고침」(PLAN 722-2 · 내역 16 · 17-2) — 줄 모달 안 수량 · 단가 칸.
|
||||
* 고침 꼴 = M02 단가산출 덮개 칸과 같음(`qty` · `labor` · `material` · `expense` — `M02_Sangeun_Doc._covered` 규칙 중 품 · 단가)
|
||||
* M01 = 시험 값만 — 고친 값은 화면 메모리(로직 줄 객체 열쇠)에만 · 셈 = 줄 사본에 덮어 서버로(`/calc` · `/text` row)
|
||||
* · 정본 · 요소(노임 · 자재) · 캐시(초안) 안 바뀜 · 로직을 다시 열면 사라짐
|
||||
* M02(724) = 같은 고침 칸 · 같은 꼴을 고침 고리(`HoEdit.set`)로 받아 덮개로 보관
|
||||
* 서버 셈 = 줄 `단가`(손 단가) · 로직 줄 `값묶음`(노 · 재 · 경) · `수량` 글 — `master_pick._priced` · `bundled` 가 읽음
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createInputField, el } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
/** 줄 하나 고침 — 품(수량) · 비목 단가(로직 줄은 셋 · 그 밖은 그 줄 비목 하나) */
|
||||
export interface LineCover {
|
||||
qty?: number;
|
||||
labor?: number;
|
||||
material?: number;
|
||||
expense?: number;
|
||||
}
|
||||
export const COVER_PARTS = { labor: "노무비", material: "재료비", expense: "경비" } as const;
|
||||
export type CoverPart = keyof typeof COVER_PARTS;
|
||||
|
||||
/** 지수 꼴 없는 수 글(엔진 식 칸) — 1e-7 → 0.0000001 */
|
||||
const plainNum = (v: number): string =>
|
||||
v.toLocaleString("en-US", { useGrouping: false, maximumFractionDigits: 20 });
|
||||
|
||||
/** 호표 줄(차례 열쇠)에 고침을 덮은 줄 사본 — 원본은 안 바뀜 · 덧줄은 고침 대상 밖 */
|
||||
export function applyCovers<R extends { 호표?: object[] }>(
|
||||
row: R,
|
||||
covers: Record<number, LineCover>,
|
||||
): R {
|
||||
const ho = (row.호표 ?? []).map((raw, i) => {
|
||||
const c = covers[i];
|
||||
if (!c) return raw;
|
||||
const item = raw as Record<string, unknown>;
|
||||
const out: Record<string, unknown> = { ...item };
|
||||
if (c.qty !== undefined) out.수량 = plainNum(c.qty);
|
||||
const parts = (Object.keys(COVER_PARTS) as CoverPart[]).filter((k) => c[k] !== undefined);
|
||||
if (parts.length && item.종류 === "로직") {
|
||||
const given = parts.map((k) => [COVER_PARTS[k], c[k]]);
|
||||
out.값묶음 = { ...((item.값묶음 as object) ?? {}), ...Object.fromEntries(given) };
|
||||
} else if (parts.length) out.단가 = c[parts[0]];
|
||||
return out;
|
||||
});
|
||||
return { ...row, 호표: ho };
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 시험 고침 — 로직 줄 객체마다(다시 열면 새 객체 = 사라짐) */
|
||||
const trials = new WeakMap<object, Record<number, LineCover>>();
|
||||
export const trial = {
|
||||
of(row: object): Record<number, LineCover> {
|
||||
let got = trials.get(row);
|
||||
if (!got) trials.set(row, (got = {}));
|
||||
return got;
|
||||
},
|
||||
has: (row: object | null): boolean => !!row && Object.keys(trials.get(row) ?? {}).length > 0,
|
||||
/** 시험 계산에 넘길 줄 — 고침 없으면 원본 그대로(같은 객체) */
|
||||
apply<R extends object | null>(row: R): R {
|
||||
return row && trial.has(row) ? (applyCovers(row, trials.get(row)!) as R) : row;
|
||||
},
|
||||
};
|
||||
|
||||
const TEXT = {
|
||||
Head: ["그 자리 고침 (시험 값)", "Edit here (trial value)"],
|
||||
Note: [
|
||||
"정본 · 마스터 값(노임 · 자재)은 안 바뀜 · 로직을 다시 열면 사라짐",
|
||||
"Source and master prices stay unchanged · cleared when reopened",
|
||||
],
|
||||
NoteM02: [
|
||||
"고친 값은 이 단가산출의 변경 항목으로 저장 — M01 은 안 바뀜",
|
||||
"Saved as this item's override — M01 stays unchanged",
|
||||
],
|
||||
Qty: ["수량(품)", "Quantity"],
|
||||
Price: ["단가", "Unit price"],
|
||||
Apply: ["적용", "Apply"],
|
||||
Undo: ["되돌림", "Undo"],
|
||||
Bad: ["0 이상 수만", "Numbers ≥ 0 only"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
export const tc = (k: keyof typeof TEXT): string =>
|
||||
TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
|
||||
export interface CoverFormOptions {
|
||||
/** 지금 값(덮기 전 셈 값) — 칸 처음 값 */
|
||||
qty: number | null;
|
||||
/** 단가 칸 — 로직 줄 = 셋(1 단위 비목 값) · 그 밖 = 그 줄 비목 하나 */
|
||||
prices: Partial<Record<CoverPart, number | null>>;
|
||||
/** 지금 든 고침(있으면 칸 처음 값 · [되돌림]) */
|
||||
cover?: LineCover;
|
||||
/** 「m02」 = 덮개 보관 안내 */
|
||||
scope?: "m01" | "m02";
|
||||
onApply: (cover: LineCover | null) => void;
|
||||
}
|
||||
|
||||
const num = (raw: string): number | null => {
|
||||
const v = Number(raw.replaceAll(",", "").trim());
|
||||
return raw.trim() !== "" && Number.isFinite(v) && v >= 0 ? v : null;
|
||||
};
|
||||
|
||||
/** 고침 칸 — 수량 · 단가(들) · [적용] [되돌림] · 바뀐 칸만 고침으로 넘김 */
|
||||
export function coverForm(o: CoverFormOptions): HTMLElement {
|
||||
const parts = Object.keys(o.prices) as CoverPart[];
|
||||
const field = (label: string, raw: number | null, mine?: number) => {
|
||||
const now = raw === null ? null : Number(raw.toPrecision(10)); // 칸 글 = 유효 10 자리 · 손대지 않으면 고침 아님
|
||||
const box = createInputField({
|
||||
label,
|
||||
value: String(mine ?? now ?? ""),
|
||||
compact: true,
|
||||
type: "text",
|
||||
});
|
||||
box.input.inputMode = "decimal";
|
||||
return { box, now };
|
||||
};
|
||||
const qty = field(tc("Qty"), o.qty, o.cover?.qty);
|
||||
const prices = parts.map((k) => ({
|
||||
k,
|
||||
...field(parts.length > 1 ? COVER_PARTS[k] : tc("Price"), o.prices[k] ?? null, o.cover?.[k]),
|
||||
}));
|
||||
const error = createText("", "error");
|
||||
error.hidden = true;
|
||||
const apply = (): void => {
|
||||
const next: LineCover = {};
|
||||
let bad = false;
|
||||
const take = (f: { box: { input: HTMLInputElement }; now: number | null }, was?: number) => {
|
||||
const v = num(f.box.input.value);
|
||||
if (v === null)
|
||||
return f.box.input.value.trim() === "" ? undefined : ((bad = true), undefined);
|
||||
return v !== f.now || was !== undefined ? v : undefined; // 셈 값 그대로면 고침 아님
|
||||
};
|
||||
const q = take(qty, o.cover?.qty);
|
||||
if (q !== undefined) next.qty = q;
|
||||
for (const p of prices) {
|
||||
const v = take(p, o.cover?.[p.k]);
|
||||
if (v !== undefined) next[p.k] = v;
|
||||
}
|
||||
error.textContent = tc("Bad");
|
||||
error.hidden = !bad;
|
||||
if (!bad) o.onApply(Object.keys(next).length ? next : null);
|
||||
};
|
||||
const actions = el("div", {
|
||||
className: "m01lab-cover__actions",
|
||||
children: [
|
||||
createButton({ label: tc("Apply"), variant: "filled", size: 32, onClick: apply }),
|
||||
...(o.cover
|
||||
? [
|
||||
createButton({
|
||||
label: tc("Undo"),
|
||||
variant: "ghost",
|
||||
size: 32,
|
||||
onClick: () => o.onApply(null),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
return el("section", {
|
||||
className: "m01lab-cover",
|
||||
attrs: { "data-cover-form": "" },
|
||||
children: [
|
||||
el("strong", { text: tc("Head") }),
|
||||
createText(tc(o.scope === "m02" ? "NoteM02" : "Note"), "hint"),
|
||||
el("div", {
|
||||
className: "m01lab-cover__fields",
|
||||
children: [qty.box.root, ...prices.map((p) => p.box.root)],
|
||||
}),
|
||||
error,
|
||||
actions,
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -2,76 +2,29 @@
|
||||
* M01_MasterData_UI_LogicLab_Detail.ts
|
||||
* 「단가산출 로직」 상세 화면 — 컨테이너 넷을 위에서 아래로
|
||||
* ① 기본정보(정본은 읽기 전용 · 비고만 고침) ② 시험 계산(입력값 + 결과값 — `Logic_Calc` 그대로)
|
||||
* ③ 단가산출 상세(인력·자재·경비·그 밖 묶음 + 소계) ④ 텍스트 수식(자리 — `_LogicLab_Formula.ts`)
|
||||
* 호표 줄을 누르면 그 줄이 쓰는 자료 모달(`_LogicLab_Modal.ts`)
|
||||
* ③ 단가산출 상세(공용 부품 `_LogicLab_Ho.ts` — 하위 박스 · 계산식 · 박스 옵션 · 그 자리 고침) ④ 텍스트 수식(`_LogicLab_Formula.ts`)
|
||||
* 그 자리 고침(722-2) = 시험 값만(`_LogicLab_Cover.ts` trial — 줄 사본으로 셈 · 정본 · 요소 · 캐시 안 바뀜)
|
||||
* ========================================================================== */
|
||||
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import type {
|
||||
CalcLine,
|
||||
ElementBrief,
|
||||
HoLine,
|
||||
LogicRow,
|
||||
NamedFormula,
|
||||
TextAnswer,
|
||||
TextLine,
|
||||
TextPiece,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { pctView } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor, formatNumber, qtyKind } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import type { LogicRow } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { buildEditor } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { openPicker } from "./M01_MasterData_UI_Logic_Pick";
|
||||
import { safeReason, tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { buildFormula } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import { buildEst } from "./M01_MasterData_UI_LogicLab_Est";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
openNoteModal,
|
||||
openRefModal,
|
||||
openLogic,
|
||||
openTableModal,
|
||||
type PickCond,
|
||||
} from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl, type LabTextKey } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import { buildHo, type HoContext, type HoEdit } from "./M01_MasterData_UI_LogicLab_Ho";
|
||||
import { trial } from "./M01_MasterData_UI_LogicLab_Cover";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
import "./M01_MasterData_UI_LogicLab_Style.css";
|
||||
import "./M01_MasterData_UI_LogicLab_Pill.css";
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { block, nowInputs } from "./M01_MasterData_UI_LogicLab_View";
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 수량 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
function toFragments(pieces: TextPiece[], ctx: DetailContext): Fragment[] {
|
||||
return pieces.map((piece): Fragment => {
|
||||
const dim = piece.깊이 ? { dim: true } : {};
|
||||
if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직")
|
||||
return { text: piece.글, ...dim };
|
||||
const ref = piece.참조;
|
||||
const open = (): void => {
|
||||
if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick"));
|
||||
if (piece.kind === "표")
|
||||
openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 });
|
||||
else if (piece.kind === "요소")
|
||||
openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices });
|
||||
else openLogic(ref.키);
|
||||
};
|
||||
return { pill: { label: piece.글, onClick: open }, ...dim };
|
||||
});
|
||||
}
|
||||
export { trial };
|
||||
|
||||
export interface DetailContext {
|
||||
row: LogicRow;
|
||||
export interface DetailContext extends HoContext {
|
||||
file: string;
|
||||
reasons: string[];
|
||||
prices: Record<string, ElementBrief | null>;
|
||||
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
|
||||
lines: CalcLine[] | null;
|
||||
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
|
||||
text: TextAnswer | null;
|
||||
/** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */
|
||||
lastText?: TextAnswer;
|
||||
values: Record<string, string>;
|
||||
/** 자체 로직 편집기 자리 — 다시 그려도 같은 칸을 씀(고치던 값이 안 날아감) */
|
||||
ownHost: HTMLElement;
|
||||
onChange: () => void;
|
||||
@@ -82,58 +35,12 @@ export interface DetailContext {
|
||||
flowHost?: HTMLElement;
|
||||
/** 시스템 관리자 — 기본 로직 안내 글이 다름(흐름도에서 고침 / 보기만) */
|
||||
admin?: boolean;
|
||||
/** 714-3 텍스트 수식 입력 단추 — 시험 계산 입력 고침 + 다시 셈 */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 722-2 시험 고침 뒤 — 시험 계산 칸을 고친 줄로 다시 그림(있으면 그 자리 고침 켜짐 · 셈은 여기서 누름) */
|
||||
onTrial?: () => void;
|
||||
/** 717-2b M02 단가산출 — 단가산출 상세 · 인력·재료·경비 덩이를 이 칸으로(SK 셈 답 · 고침 표) */
|
||||
cover?: { ho: HTMLElement; est: HTMLElement };
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
const GROUPS: Group[] = ["labor", "material", "cost", "other"];
|
||||
const OF_KIND: Record<string, Group> = { 인력: "labor", 재료: "material", 기계: "cost" };
|
||||
const OF_COST: Record<string, Group> = { 노무비: "labor", 재료비: "material", 경비: "cost" };
|
||||
|
||||
/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */
|
||||
const groupOfHo = (item: HoLine): Group =>
|
||||
OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other";
|
||||
|
||||
interface Entry {
|
||||
/** 호표 줄 차례(덧줄은 호표 수 + 덧줄 차례) — 서버 조각을 찾는 열쇠 */
|
||||
idx: number;
|
||||
group: Group;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
qty: string;
|
||||
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
|
||||
qtyValue: string;
|
||||
qtyTag: string;
|
||||
qtyFrag: Fragment[];
|
||||
/** 한 줄이 비목 몫으로 갈렸을 때 이 묶음의 몫 이름(예 「노무비」) */
|
||||
share?: string;
|
||||
price: string;
|
||||
amount: number | null;
|
||||
/** 덧줄이면 true */
|
||||
extra: boolean;
|
||||
open: (self: Entry) => void;
|
||||
/** 서버 조각으로 그린 단가 자리 · 수량 풀이 — 줄 모달이 씀(붙기 전엔 없음) */
|
||||
unitFrag?: Fragment[];
|
||||
piecesFrag?: Fragment[];
|
||||
/** 서버 요소조각의 첫 요소 — 자리 표시 든 줄이 입력으로 풀린 키 · 글 */
|
||||
unitRef?: string;
|
||||
unitText?: string;
|
||||
/** 자재 단위가 줄 단위와 성격이 다름 — 「줄 m · 자재 Ton」 */
|
||||
warn?: string;
|
||||
}
|
||||
|
||||
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
|
||||
const condOf = (item: HoLine): PickCond | null =>
|
||||
typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null;
|
||||
const condLabel = (cond: PickCond): string =>
|
||||
[cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · ");
|
||||
|
||||
const groupName = (group: Group): string => tl(`G_${group}` as LabTextKey);
|
||||
|
||||
const section = (title: string, body: HTMLElement[], hint = ""): HTMLElement =>
|
||||
el("section", {
|
||||
className: "m01-logic__section m01lab__box",
|
||||
@@ -238,220 +145,6 @@ function infoBox(ctx: DetailContext): HTMLElement[] {
|
||||
return [fold, ...reasons];
|
||||
}
|
||||
|
||||
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
|
||||
const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—");
|
||||
|
||||
/** 계산된 줄은 서버가 준 비목별 몫으로 묶음에 나눠 넣음 — 로직을 부르는 줄도 노무비·재료비·경비로 갈림 */
|
||||
const spread = (e: Entry, line?: CalcLine): Entry[] => {
|
||||
const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0);
|
||||
if (!parts.length) return [e];
|
||||
return parts.map(([k, v]) => ({
|
||||
...e,
|
||||
group: OF_COST[k] ?? "other",
|
||||
amount: v,
|
||||
...(parts.length > 1
|
||||
? {
|
||||
share: k,
|
||||
// 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게
|
||||
...(line && line.금액 && line.단가 != null
|
||||
? { price: formatPrice((line.단가 * v) / line.금액) }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
};
|
||||
|
||||
function entries(ctx: DetailContext): Entry[] {
|
||||
const middles: NamedFormula[] = ctx.row.중간 ?? [];
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const out: Entry[] = ho.flatMap((item, i) => {
|
||||
const line = ctx.lines?.[i];
|
||||
const cond = condOf(item);
|
||||
const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소];
|
||||
const price = line ? line.단가 : brief?.값;
|
||||
const entry: Entry = {
|
||||
idx: i,
|
||||
group: groupOfHo(item),
|
||||
name: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
spec: item.규격 ?? brief?.규격 ?? "",
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyTag: qtyKind(item.수량) === tx("Ho_QtyTable") ? tl("Tag_Table") : qtyKind(item.수량),
|
||||
qtyFrag: [{ text: item.수량 }],
|
||||
price: price === undefined || price === null ? "" : formatPrice(price),
|
||||
amount: line ? line.금액 : null,
|
||||
warn: line?.단위경고,
|
||||
extra: false,
|
||||
open: (self) =>
|
||||
openMaterialModal({
|
||||
unitFrag: self.unitFrag,
|
||||
unitRef: line?.품목?.키 ?? self.unitRef,
|
||||
unitText: self.unitText,
|
||||
qtyFrag: self.piecesFrag,
|
||||
title: item.이름 ?? (cond ? condLabel(cond) : item.요소),
|
||||
expr: item.수량,
|
||||
...(cond ? { cond, unit: item.단위 } : { element: item.요소 }),
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
|
||||
kind: item.종류,
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item.종류 === "재료"
|
||||
? { onPick: (it) => pickMaterial(ctx.calcHost, ctx.values, i, it) }
|
||||
: {}),
|
||||
}),
|
||||
};
|
||||
return spread(entry, line);
|
||||
});
|
||||
(ctx.row.덧줄 ?? []).forEach((extra, i) => {
|
||||
const line = ctx.lines?.[ho.length + i];
|
||||
const entry: Entry = {
|
||||
idx: ho.length + i,
|
||||
group: OF_COST[extra.비목 ?? ""] ?? "other",
|
||||
name: extra.이름,
|
||||
spec: "",
|
||||
unit: line ? pctView(line).unit : "",
|
||||
qty: extra.식,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyTag: tl("Extra"),
|
||||
qtyFrag: [{ text: extra.식 }],
|
||||
price: "",
|
||||
amount: line ? line.금액 : null,
|
||||
extra: true,
|
||||
open: (self) =>
|
||||
openMaterialModal({
|
||||
unitFrag: self.unitFrag,
|
||||
qtyFrag: self.piecesFrag,
|
||||
title: extra.이름,
|
||||
expr: extra.식,
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
}),
|
||||
};
|
||||
out.push(...spread(entry, line));
|
||||
});
|
||||
attachPieces(out, ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 서버 `/text` 줄의 조각을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 묶음마다 호표 차례대로 줄을 주므로
|
||||
* 이름을 차례로 따라가며 줄 차례에 맞춤 · 금액 0 로직 부름 줄처럼 묶음이 달라도 찾음 ·
|
||||
* 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */
|
||||
function attachPieces(all: Entry[], ctx: DetailContext): void {
|
||||
const source = ctx.text?.ok ? ctx.text : ctx.lastText;
|
||||
if (!source?.ok) {
|
||||
if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸
|
||||
return;
|
||||
}
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const names = [
|
||||
...ho.map((item) => item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소)),
|
||||
...(ctx.row.덧줄 ?? []).map((extra) => extra.이름),
|
||||
];
|
||||
const found = new Map<string, TextLine>();
|
||||
for (const g of source.groups) {
|
||||
let from = 0;
|
||||
for (const line of g.줄) {
|
||||
const at = names.findIndex((n, i) => i >= from && n === line.이름);
|
||||
if (at < 0) continue;
|
||||
found.set(`${g.비목}|${at}`, line);
|
||||
from = at + 1;
|
||||
}
|
||||
}
|
||||
const costs = Object.keys(OF_COST);
|
||||
for (const e of all) {
|
||||
const own = costs.find((c) => OF_COST[c] === e.group);
|
||||
const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean);
|
||||
const pieces = line?.조각;
|
||||
e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : [];
|
||||
if (line?.요소조각?.length) {
|
||||
e.unitFrag = toFragments(line.요소조각, ctx);
|
||||
const first = line.요소조각.find((piece) => piece.kind === "요소");
|
||||
e.unitRef = first?.참조?.키;
|
||||
e.unitText = first?.글;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cellOf = (text: string, className = ""): HTMLElement => el("td", { className, text });
|
||||
|
||||
function lineRow(e: Entry): HTMLElement {
|
||||
const tr = el("tr", {
|
||||
className: `m01lab__line${e.extra ? " m01lab__line--extra" : ""}`,
|
||||
attrs: { tabindex: "0", "data-line": e.name },
|
||||
children: [
|
||||
el("td", {
|
||||
children: [
|
||||
el("div", { text: e.name }),
|
||||
...(e.share ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
|
||||
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
|
||||
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
|
||||
],
|
||||
}),
|
||||
cellOf(e.qtyValue, "m01-logic__money"),
|
||||
cellOf(e.unit),
|
||||
el("td", {
|
||||
className: "m01lab__qty-cell",
|
||||
children: [createBadge(e.qtyTag, "accent"), fragmentRow(e.qtyFrag)],
|
||||
}),
|
||||
cellOf(e.price, "m01-logic__money"),
|
||||
cellOf(e.amount === null ? "" : formatMoney(e.amount), "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
tr.addEventListener("click", () => e.open(e));
|
||||
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e));
|
||||
return tr;
|
||||
}
|
||||
|
||||
function groupTable(group: Group, mine: Entry[], calculated: boolean): HTMLElement[] {
|
||||
const sub = mine.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
const empty = el("tr", {
|
||||
children: [
|
||||
el("td", {
|
||||
attrs: { colspan: "6" },
|
||||
className: "ui-text ui-text--muted",
|
||||
text: tl("Empty_Group"),
|
||||
}),
|
||||
],
|
||||
});
|
||||
const subtotal = el("tr", {
|
||||
className: "m01lab__subtotal",
|
||||
attrs: { "data-group": group, "data-subtotal": calculated ? String(sub) : "" },
|
||||
children: [
|
||||
el("td", { attrs: { colspan: "5" }, text: `${groupName(group)} ${tl("Subtotal")}` }),
|
||||
cellOf(calculated ? formatMoney(sub) : "—", "m01-logic__money"),
|
||||
],
|
||||
});
|
||||
const headers = [
|
||||
tl("Col_Name"),
|
||||
tl("Col_Qty"),
|
||||
tl("Col_Unit"),
|
||||
tl("Col_Logic"),
|
||||
tl("Col_Price"),
|
||||
tl("Col_Amount"),
|
||||
];
|
||||
return [
|
||||
el("h4", { className: "m01lab__group", text: groupName(group) }),
|
||||
el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
children: [
|
||||
el("table", {
|
||||
className: "ui-table ui-table--data m01-logic__grid m01lab__ho",
|
||||
children: [
|
||||
el("thead", {
|
||||
children: [el("tr", { children: headers.map((h) => el("th", { text: h })) })],
|
||||
}),
|
||||
el("tbody", { children: [...(mine.length ? mine.map(lineRow) : [empty]), subtotal] }),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** 자체 로직(키 GX…) — 정본은 아님 */
|
||||
export const isOwnLogic = (row: LogicRow): boolean => row.키.startsWith("GX");
|
||||
|
||||
@@ -477,6 +170,23 @@ function ownEditor(ctx: DetailContext): HTMLElement {
|
||||
});
|
||||
}
|
||||
|
||||
/** M01 그 자리 고침 고리 — 시험 값(줄 객체 열쇠) · 고치면 시험 계산을 다시 그리고 셈 */
|
||||
function trialEdit(ctx: DetailContext): HoEdit | undefined {
|
||||
const redraw = ctx.onTrial;
|
||||
if (!redraw) return undefined;
|
||||
const covers = trial.of(ctx.row);
|
||||
return {
|
||||
covers,
|
||||
scope: "m01",
|
||||
set: (idx, cover) => {
|
||||
if (cover) covers[idx] = cover;
|
||||
else delete covers[idx];
|
||||
redraw();
|
||||
ctx.calcHost.querySelector<HTMLButtonElement>("[data-calc-run]")?.click();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hoBox(ctx: DetailContext): HTMLElement {
|
||||
const money = !("결과" in ctx.row) && (ctx.row.결과단위 ?? "").startsWith("원");
|
||||
if (!money) {
|
||||
@@ -488,26 +198,17 @@ function hoBox(ctx: DetailContext): HTMLElement {
|
||||
"ho",
|
||||
);
|
||||
}
|
||||
const all = entries(ctx);
|
||||
const calculated = ctx.lines !== null;
|
||||
const tables = GROUPS.flatMap((group) => {
|
||||
const mine = all.filter((e) => e.group === group);
|
||||
return group === "other" && !mine.length ? [] : groupTable(group, mine, calculated); // 그 밖은 있을 때만
|
||||
});
|
||||
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
const totalLine = el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
|
||||
});
|
||||
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 묶음 표는 접어 둠(펼치면 소계가 보임)
|
||||
const body = buildHo({ ...ctx, edit: trialEdit(ctx) });
|
||||
const totalLine = body.pop()!; // 계 줄 — 자체 로직은 접힌 표 밖에 둠
|
||||
// 자체 로직 — 고치는 자리는 편집기 하나 · 읽기용 박스는 접어 둠(펼치면 소계가 보임)
|
||||
const fold = el("details", {
|
||||
className: "m01lab__fold",
|
||||
attrs: ctx.ownHost.dataset.fold ? { open: "" } : {},
|
||||
children: [el("summary", { text: tl("Ho_Fold") }), ...tables],
|
||||
children: [el("summary", { text: tl("Ho_Fold") }), ...body],
|
||||
});
|
||||
fold.addEventListener("toggle", () => (ctx.ownHost.dataset.fold = fold.open ? "1" : ""));
|
||||
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...tables, totalLine];
|
||||
const blocks = isOwnLogic(ctx.row) ? [ownEditor(ctx), fold, totalLine] : [...body, totalLine];
|
||||
const calculated = ctx.lines !== null;
|
||||
const hint = calculated ? tl("Click_Hint") : `${tl("Click_Hint")} · ${tl("Calc_First")}`;
|
||||
return block(section(tl("Ho_Title"), [nowInputs(ctx.values), ...blocks], hint), "ho");
|
||||
}
|
||||
|
||||
@@ -45,8 +45,12 @@ export function usedInputs(
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` */
|
||||
function inputChip(spec: LogicInput, shown: string, onInput: (v: string) => void): HTMLElement {
|
||||
/** 입력 단추 하나 — 누르면 그 자리에 고르기(고르기 있는 입력) · 글 칸 · 바꾸면 `onInput` · 722-1 계산식 칸도 씀 */
|
||||
export function inputChip(
|
||||
spec: LogicInput,
|
||||
shown: string,
|
||||
onInput: (v: string) => void,
|
||||
): HTMLElement {
|
||||
const wrap = el("span", { className: "m01lab-fx__var", attrs: { "data-fx-var": spec.이름 } });
|
||||
const chip = createButton({
|
||||
label: `${spec.이름} ${shown}${spec.단위 ? ` ${spec.단위}` : ""}`,
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
/* =============================================================================
|
||||
* M01_MasterData_UI_LogicLab_Ho.ts
|
||||
* 공용 「단가산출 상세」(PLAN 722-1 · 요구서 내역16 2장 6 · 7) — M01 단가산출 로직 상세가 씀 · (724) M02 단가산출 · 일위대가 모달도.
|
||||
* 하위 박스 = 서버 `묶음`(인력 · 재료 · 경비(장비) · 하위 — `/text` 줄 덧칸 714-1) · 계산 전 = 호표 종류로
|
||||
* · 장비 · 하위 로직 줄 = 박스 안 한 줄(노 · 재 · 경 몫 합) + 누르면 펼침(부른 로직 시간당 줄 · 몫 식 줄)
|
||||
* 박스 제목 바로 아래 = 그 박스 줄들이 쓰는 입력 고르기(`calc_sheet` 변수 칸 꼴) · 어느 박스도 안 쓰는 입력 = 맨 위 「공통 옵션」
|
||||
* 「계산식」 열 = 값 든 식(「268,908 × 0.12」 · 서버 `글`) + 표 · 요소 · 로직 알약 + 그 줄 입력 칩 · 변수 이름 식(`이름글`)은 마우스 올리면
|
||||
* 맨 아래 = 비목 소계 셋(노무비 · 재료비 · 경비 — 장비 노무는 여기서 인력과 더해짐) + 계
|
||||
* 줄 누름 = 그 줄 자료 모달(`_LogicLab_Modal.ts`) · 고침 고리(`edit`) 있으면 모달 안 「그 자리 고침」(`_LogicLab_Cover.ts`)
|
||||
* 값 = 서버 셈 그대로(박스 소계 · 비목 소계만 줄 금액 합) · 화면에서 다시 셈 안 함
|
||||
* ========================================================================== */
|
||||
|
||||
import { createBadge } from "@ui/ui_template_badge";
|
||||
import { createCalcFormulaRow } from "@ui/ui_template_calc_row";
|
||||
import { createCard } from "@ui/ui_template_card";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { emptyRow, tableRow, tableShell, type Column } from "@ui/ui_template_table";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { sheetFormula, varRow, type SheetUnfold } from "../A00_Common/calc_sheet";
|
||||
import type {
|
||||
CalcLine,
|
||||
ElementBrief,
|
||||
HoLine,
|
||||
LogicRow,
|
||||
NamedFormula,
|
||||
TextAnswer,
|
||||
TextLine,
|
||||
TextPiece,
|
||||
} from "./M01_MasterData_UI_Logic_Api";
|
||||
import { pctView } from "./M01_MasterData_UI_Logic_Api";
|
||||
import { formatNumber } from "./M01_MasterData_UI_Logic_Edit";
|
||||
import { formatMoney, formatPrice } from "./M01_MasterData_UI_Logic_Money";
|
||||
import { tx } from "./M01_MasterData_UI_Logic_Text";
|
||||
import { pickMaterial } from "./M01_MasterData_UI_Logic_CalcPick";
|
||||
import { coverForm, type CoverPart, type LineCover } from "./M01_MasterData_UI_LogicLab_Cover";
|
||||
import { inputChip } from "./M01_MasterData_UI_LogicLab_Formula";
|
||||
import {
|
||||
condText,
|
||||
openMaterialModal,
|
||||
openNoteModal,
|
||||
openRefModal,
|
||||
openLogic,
|
||||
openTableModal,
|
||||
type PickCond,
|
||||
} from "./M01_MasterData_UI_LogicLab_Modal";
|
||||
import { fragmentRow, type Fragment } from "./M01_MasterData_UI_LogicLab_Pill";
|
||||
import { tl } from "./M01_MasterData_UI_LogicLab_Text";
|
||||
|
||||
/* ── 순수 셈 ── */
|
||||
/** 박스로 묶을 줄 — 호표 차례 · 박스 이름 · 한 줄로 합칠지(장비 · 하위) · 금액 */
|
||||
export interface HoRowLike {
|
||||
idx: number;
|
||||
bundle: string;
|
||||
merge: boolean;
|
||||
amount: number | null;
|
||||
}
|
||||
export interface HoMerged<T> {
|
||||
idx: number;
|
||||
몫: T[];
|
||||
금액: number | null;
|
||||
}
|
||||
export interface HoBox<T> {
|
||||
묶음: string;
|
||||
줄: (T | HoMerged<T>)[];
|
||||
소계: number;
|
||||
}
|
||||
const BOX_ORDER = ["인력", "재료", "경비", "하위"];
|
||||
|
||||
/** 줄 → 박스(인력 · 재료 · 경비 · 하위 차례) — 합칠 줄은 같은 박스 · 같은 호표 차례끼리 한 줄(몫 차례 그대로) ·
|
||||
* `always` 박스는 줄이 없어도 빈 박스로 */
|
||||
export function boxesOf<T extends HoRowLike>(list: T[], always: string[] = []): HoBox<T>[] {
|
||||
const boxes = new Map<string, HoBox<T>>();
|
||||
const take = (name: string): HoBox<T> => {
|
||||
let b = boxes.get(name);
|
||||
if (!b) boxes.set(name, (b = { 묶음: name, 줄: [], 소계: 0 }));
|
||||
return b;
|
||||
};
|
||||
always.forEach(take);
|
||||
const merged = new Map<string, HoMerged<T>>();
|
||||
for (const e of list) {
|
||||
const b = take(e.bundle);
|
||||
b.소계 += e.amount ?? 0;
|
||||
if (!e.merge) {
|
||||
b.줄.push(e);
|
||||
continue;
|
||||
}
|
||||
const id = `${e.bundle}\n${e.idx}`;
|
||||
let m = merged.get(id);
|
||||
if (!m) {
|
||||
merged.set(id, (m = { idx: e.idx, 몫: [], 금액: null }));
|
||||
b.줄.push(m);
|
||||
}
|
||||
m.몫.push(e);
|
||||
m.금액 = m.금액 === null && e.amount === null ? null : (m.금액 ?? 0) + (e.amount ?? 0);
|
||||
}
|
||||
const rank = (x: string): number => (BOX_ORDER.includes(x) ? BOX_ORDER.indexOf(x) : 9);
|
||||
return [...boxes.values()].sort((a, b) => rank(a.묶음) - rank(b.묶음));
|
||||
}
|
||||
/* ── 순수 셈 끝 ── */
|
||||
|
||||
const TEXT = {
|
||||
인력: ["인력", "Labor"],
|
||||
재료: ["재료", "Material"],
|
||||
경비: ["경비(장비)", "Expense (equipment)"],
|
||||
하위: ["하위 로직", "Sub logic"],
|
||||
Common: ["공통 옵션", "Common options"],
|
||||
Basis: ["기준", "Basis"],
|
||||
Formula: ["계산식", "Formula"],
|
||||
Short: ["노 {a} · 재 {b} · 경 {c}", "L {a} · M {b} · E {c}"],
|
||||
Fold: ["누르면 펼침", "Click to expand"],
|
||||
Per: ["{n} — {v} 원/{u}", "{n} — {v} KRW/{u}"],
|
||||
Edited: ["시험 고침", "Trial edit"],
|
||||
} as const satisfies Record<string, readonly [string, string]>;
|
||||
const th = (k: keyof typeof TEXT): string => TEXT[k][currentLanguageIndex as 0 | 1] ?? TEXT[k][0];
|
||||
const boxName = (b: string): string => (b in TEXT ? th(b as keyof typeof TEXT) : b);
|
||||
|
||||
/** 고침 고리 — M01 = 시험 값(줄 사본) · M02 = 덮개 보관 · 열쇠 = 호표 줄 차례 */
|
||||
export interface HoEdit {
|
||||
covers: Record<number, LineCover>;
|
||||
set: (idx: number, cover: LineCover | null) => void;
|
||||
scope?: "m01" | "m02";
|
||||
}
|
||||
|
||||
export interface HoContext {
|
||||
row: LogicRow;
|
||||
prices: Record<string, ElementBrief | null>;
|
||||
/** 마지막 시험 계산의 줄 — 호표 차례 뒤에 덧줄 */
|
||||
lines: CalcLine[] | null;
|
||||
/** 마지막 시험 계산의 읽는 식 줄(서버가 줌) */
|
||||
text: TextAnswer | null;
|
||||
/** 마지막으로 잘 받은 읽는 식 — `text` 가 멈춤이면 조각을 이걸로 */
|
||||
lastText?: TextAnswer;
|
||||
values: Record<string, string>;
|
||||
/** 박스 옵션 · 입력 칩 고침 — 시험 계산 입력 고침 + 다시 셈(없으면 고르기 · 칩 없음) */
|
||||
onInput?: (name: string, value: string) => void;
|
||||
/** 시험 계산 칸 — 재료 후보 고르기가 씀(없으면 후보만 보임) */
|
||||
calcHost?: HTMLElement;
|
||||
/** 그 자리 고침 고리(없으면 모달은 보기만) */
|
||||
edit?: HoEdit;
|
||||
}
|
||||
|
||||
type Group = "labor" | "material" | "cost" | "other";
|
||||
const OF_KIND: Record<string, Group> = { 인력: "labor", 재료: "material", 기계: "cost" };
|
||||
const OF_COST: Record<string, Group> = { 노무비: "labor", 재료비: "material", 경비: "cost" };
|
||||
const BOX_OF: Record<Group, string> = {
|
||||
labor: "인력",
|
||||
material: "재료",
|
||||
cost: "경비",
|
||||
other: "하위",
|
||||
};
|
||||
const COST_OF: Record<string, string> = { labor: "노무비", material: "재료비", cost: "경비" };
|
||||
const PART_OF: Record<Group, CoverPart> = {
|
||||
labor: "labor",
|
||||
material: "material",
|
||||
cost: "expense",
|
||||
other: "expense",
|
||||
};
|
||||
|
||||
/** 줄 종류가 셋 중 어디에도 안 들어가면 「그 밖」 — 다른 로직을 부르는 줄은 비목으로 */
|
||||
const groupOfHo = (item: HoLine): Group =>
|
||||
OF_KIND[item.종류] ?? (item.종류 === "로직" ? OF_COST[item.비목 ?? ""] : undefined) ?? "other";
|
||||
|
||||
interface Entry extends HoRowLike {
|
||||
group: Group;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
qty: string;
|
||||
/** 고른 수량 값(계산 답 `CalcLine.수량` · % 덧줄은 율) — 계산 전 「—」 */
|
||||
qtyValue: string;
|
||||
qtyFrag: Fragment[];
|
||||
/** 한 줄이 비목 몫으로 갈렸을 때 이 몫 이름(예 「노무비」) */
|
||||
share?: string;
|
||||
price: string;
|
||||
/** 갈리기 전 줄 단가(장비 한 줄 머리) */
|
||||
linePrice: string;
|
||||
extra: boolean;
|
||||
/** 계산 답 줄 · 서버 읽는 식 줄(붙기 전엔 없음) */
|
||||
line?: CalcLine;
|
||||
textLine?: TextLine;
|
||||
/** 고침 칸 — 로직 줄 = 노 · 재 · 경 · 그 밖 = 그 줄 비목 */
|
||||
logic: boolean;
|
||||
part: CoverPart;
|
||||
open: (self: Entry) => void;
|
||||
unitFrag?: Fragment[];
|
||||
piecesFrag?: Fragment[];
|
||||
unitRef?: string;
|
||||
unitText?: string;
|
||||
warn?: string;
|
||||
}
|
||||
|
||||
/** 서버 조각(`/text` 줄의 `조각`) → 칸 조각 — 요소·표·로직은 알약(글 = 조각 글) · 나머지는 글자 */
|
||||
function toFragments(pieces: TextPiece[], ctx: HoContext): Fragment[] {
|
||||
return pieces.map((piece): Fragment => {
|
||||
const dim = piece.깊이 ? { dim: true } : {};
|
||||
if (piece.kind !== "요소" && piece.kind !== "표" && piece.kind !== "로직")
|
||||
return { text: piece.글, ...dim };
|
||||
const ref = piece.참조;
|
||||
const open = (): void => {
|
||||
if (!ref) return openNoteModal(piece.글, tl("Pill_NoPick"));
|
||||
if (piece.kind === "표")
|
||||
openTableModal({ title: piece.글, table: ref.키, row: ref.줄 ?? ref.행, col: ref.열 });
|
||||
else if (piece.kind === "요소")
|
||||
openRefModal({ title: piece.글, ref: ref.키, prices: ctx.prices });
|
||||
else openLogic(ref.키);
|
||||
};
|
||||
return { pill: { label: piece.글, onClick: open }, ...dim };
|
||||
});
|
||||
}
|
||||
|
||||
/** 재료 고르기 줄 — `요소` 가 글 대신 {구분·상세구분·규격·대표} 객체 */
|
||||
const condOf = (item: HoLine): PickCond | null =>
|
||||
typeof item.요소 === "object" && item.요소 !== null ? (item.요소 as unknown as PickCond) : null;
|
||||
const condLabel = (cond: PickCond): string =>
|
||||
[cond.구분, cond.상세구분, cond.규격].filter(Boolean).join(" · ");
|
||||
const nameOf = (item: HoLine): string =>
|
||||
item.이름 ?? (condOf(item) ? condLabel(condOf(item)!) : item.요소);
|
||||
|
||||
/** 수량 칸 = 계산 답의 고른 수량(% 덧줄은 율) · 계산 전 「—」 */
|
||||
const qtyOf = (line?: CalcLine): string => (line ? formatNumber(pctView(line).qty) : "—");
|
||||
|
||||
/** 계산된 줄은 서버가 준 비목별 몫으로 나눔 — 로직을 부르는 줄 · 장비 줄도 노무비·재료비·경비로 갈림 */
|
||||
const spread = (e: Entry, line?: CalcLine): Entry[] => {
|
||||
const parts = Object.entries(line?.비목 ?? {}).filter(([, v]) => v !== 0);
|
||||
if (!parts.length) return [e];
|
||||
return parts.map(([k, v]) => ({
|
||||
...e,
|
||||
group: OF_COST[k] ?? "other",
|
||||
bundle: BOX_OF[OF_COST[k] ?? "other"],
|
||||
amount: v,
|
||||
...(parts.length > 1
|
||||
? {
|
||||
share: k,
|
||||
// 몫 줄 단가 = 단가 × (몫 금액 ÷ 줄 금액) — 곱하기 수량이 몫 금액과 맞게
|
||||
...(line && line.금액 && line.단가 != null
|
||||
? { price: formatPrice((line.단가 * v) / line.금액) }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
};
|
||||
|
||||
/** 줄 모달 — 자료 보기 + (고리 있으면) 그 자리 고침 */
|
||||
function modalOf(e: Entry, ctx: HoContext, extra: { cond?: PickCond; item?: HoLine }): void {
|
||||
const middles: NamedFormula[] = ctx.row.중간 ?? [];
|
||||
const { cond, item } = extra;
|
||||
const line = e.line;
|
||||
const edit = ctx.edit;
|
||||
const perUnit = (k: string): number | null =>
|
||||
line && line.수량 ? (line.비목?.[k] ?? 0) / line.수량 : null;
|
||||
openMaterialModal({
|
||||
unitFrag: e.unitFrag,
|
||||
unitRef: line?.품목?.키 ?? e.unitRef,
|
||||
unitText: e.unitText,
|
||||
qtyFrag: e.piecesFrag,
|
||||
title: e.name,
|
||||
expr: e.qty,
|
||||
...(item ? (cond ? { cond, unit: item.단위 } : { element: item.요소 }) : {}),
|
||||
prices: ctx.prices,
|
||||
middles,
|
||||
values: ctx.values,
|
||||
// 재료 줄 = 시험 계산 자재 칸과 같은 모달 — 고르면 시험 입력으로 다시 셈(정본 안 바뀜)
|
||||
kind: item?.종류,
|
||||
usedSlot: line?.품목?.값칸,
|
||||
...(item?.종류 === "재료" && ctx.calcHost
|
||||
? { onPick: (it) => pickMaterial(ctx.calcHost!, ctx.values, e.idx, it) }
|
||||
: {}),
|
||||
...(edit && item && !e.extra
|
||||
? {
|
||||
edit: (close: () => void) =>
|
||||
coverForm({
|
||||
qty: line ? line.수량 : null,
|
||||
prices: e.logic
|
||||
? {
|
||||
labor: perUnit("노무비"),
|
||||
material: perUnit("재료비"),
|
||||
expense: perUnit("경비"),
|
||||
}
|
||||
: { [e.part]: line ? line.단가 : null },
|
||||
cover: edit.covers[e.idx],
|
||||
scope: edit.scope,
|
||||
onApply: (c) => (close(), edit.set(e.idx, c)),
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
function entries(ctx: HoContext): Entry[] {
|
||||
const ho = ctx.row.호표 ?? [];
|
||||
const out: Entry[] = ho.flatMap((item, i) => {
|
||||
const line = ctx.lines?.[i];
|
||||
const cond = condOf(item);
|
||||
const brief = cond ? ctx.prices[condText(cond)] : ctx.prices[item.요소];
|
||||
const price = line ? line.단가 : brief?.값;
|
||||
const group = groupOfHo(item);
|
||||
const shown = price === undefined || price === null ? "" : formatPrice(price);
|
||||
const entry: Entry = {
|
||||
idx: i,
|
||||
group,
|
||||
bundle: BOX_OF[group],
|
||||
merge: false,
|
||||
name: nameOf(item),
|
||||
spec: item.규격 ?? brief?.규격 ?? "",
|
||||
unit: item.단위 ?? "",
|
||||
qty: item.수량,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyFrag: [{ text: item.수량 }],
|
||||
price: shown,
|
||||
linePrice: shown,
|
||||
amount: line ? line.금액 : null,
|
||||
warn: line?.단위경고,
|
||||
extra: false,
|
||||
line,
|
||||
logic: item.종류 === "로직",
|
||||
part: OF_COST[item.비목 ?? ""] ? PART_OF[OF_COST[item.비목 ?? ""]] : PART_OF[group],
|
||||
open: (self) => modalOf(self, ctx, { cond: cond ?? undefined, item }),
|
||||
};
|
||||
return spread(entry, line);
|
||||
});
|
||||
(ctx.row.덧줄 ?? []).forEach((extra, i) => {
|
||||
const line = ctx.lines?.[ho.length + i];
|
||||
const group = OF_COST[extra.비목 ?? ""] ?? "other";
|
||||
const entry: Entry = {
|
||||
idx: ho.length + i,
|
||||
group,
|
||||
bundle: BOX_OF[group],
|
||||
merge: false,
|
||||
name: extra.이름,
|
||||
spec: "",
|
||||
unit: line ? pctView(line).unit : "",
|
||||
qty: extra.식,
|
||||
qtyValue: qtyOf(line),
|
||||
qtyFrag: [{ text: extra.식 }],
|
||||
price: "",
|
||||
linePrice: "",
|
||||
amount: line ? line.금액 : null,
|
||||
extra: true,
|
||||
line,
|
||||
logic: false,
|
||||
part: PART_OF[group],
|
||||
open: (self) => modalOf(self, ctx, {}),
|
||||
};
|
||||
out.push(...spread(entry, line));
|
||||
});
|
||||
attachPieces(out, ctx);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 서버 `/text` 줄을 호표 줄 차례(덧줄은 덧줄 차례)로 이음 — 서버는 비목마다 호표 차례대로 줄을 주므로
|
||||
* 이름을 차례로 따라가며 줄 차례에 맞춤 · 붙은 줄의 `묶음` · `장비` 로 박스를 정함 ·
|
||||
* 끝내 못 찾으면 날식 대신 빈칸(계산 전 · 서버 실패면 식 글자 그대로) */
|
||||
function attachPieces(all: Entry[], ctx: HoContext): void {
|
||||
const source = ctx.text?.ok ? ctx.text : ctx.lastText;
|
||||
if (!source?.ok) {
|
||||
if (ctx.text) for (const e of all) e.qtyFrag = e.piecesFrag = []; // 멈춤 · 받은 조각 없음 — 날식 대신 빈칸
|
||||
return;
|
||||
}
|
||||
const names = [...(ctx.row.호표 ?? []).map(nameOf), ...(ctx.row.덧줄 ?? []).map((x) => x.이름)];
|
||||
const found = new Map<string, TextLine>();
|
||||
for (const g of source.groups) {
|
||||
let from = 0;
|
||||
for (const line of g.줄) {
|
||||
const at = names.findIndex((n, i) => i >= from && n === line.이름);
|
||||
if (at < 0) continue;
|
||||
found.set(`${g.비목}|${at}`, line);
|
||||
from = at + 1;
|
||||
}
|
||||
}
|
||||
const costs = Object.keys(OF_COST);
|
||||
const fresh = ctx.lines !== null; // 묶음은 지금 셈 답에서만(옛 답의 묶음으로 박스를 안 옮김)
|
||||
for (const e of all) {
|
||||
const own = costs.find((c) => OF_COST[c] === e.group);
|
||||
const line = [own, ...costs].map((c) => found.get(`${c}|${e.idx}`)).find(Boolean);
|
||||
const pieces = line?.조각;
|
||||
e.qtyFrag = e.piecesFrag = pieces?.length ? toFragments(pieces, ctx) : [];
|
||||
if (line?.요소조각?.length) {
|
||||
e.unitFrag = toFragments(line.요소조각, ctx);
|
||||
const first = line.요소조각.find((piece) => piece.kind === "요소");
|
||||
e.unitRef = first?.참조?.키;
|
||||
e.unitText = first?.글;
|
||||
}
|
||||
if (!line || !fresh) continue;
|
||||
e.textLine = line;
|
||||
if (line.묶음) e.bundle = line.묶음;
|
||||
e.merge = !!line.장비 || line.묶음 === "하위";
|
||||
}
|
||||
}
|
||||
|
||||
const COLS: Column[] = [
|
||||
{ label: tl("Col_Name"), width: "20%" },
|
||||
{ label: tl("Col_Qty"), width: "9%", align: "end" },
|
||||
{ label: tl("Col_Unit"), width: "7%" },
|
||||
{ label: th("Formula"), width: "38%" },
|
||||
{ label: tl("Col_Price"), width: "13%", align: "end" },
|
||||
{ label: tl("Col_Amount"), width: "13%", align: "end" },
|
||||
];
|
||||
|
||||
const money = (v: number | null): string => (v === null ? "" : formatMoney(v));
|
||||
|
||||
/** 이 줄이 쓰는 입력 칩 — 서버 `쓰는입력` ∩ 로직 입력 */
|
||||
function chips(e: Entry, ctx: HoContext): HTMLElement[] {
|
||||
const onInput = ctx.onInput;
|
||||
const uses = e.textLine?.쓰는입력 ?? [];
|
||||
const specs = (ctx.row.입력 ?? []).filter((s) => uses.includes(s.이름));
|
||||
if (!onInput || !specs.length) return [];
|
||||
return [
|
||||
el("div", {
|
||||
className: "m01lab-fx__vars",
|
||||
children: specs.map((s) => inputChip(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v))),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
/** 계산식 칸 — 값 든 식(계산 뒤) · 계산 전 = 수량 식 · 알약 · 입력 칩 · 이름 식은 풍선 */
|
||||
function formulaCell(e: Entry, ctx: HoContext, text = ""): HTMLElement {
|
||||
const tline = e.textLine;
|
||||
const main = text || (tline ? sheetFormula(tline.글) : e.qty);
|
||||
const pills = e.qtyFrag.some((f) => "pill" in f) ? [fragmentRow(e.qtyFrag)] : [];
|
||||
const cell = el("div", {
|
||||
className: "m01lab__qty-cell",
|
||||
children: [
|
||||
el("div", { className: "m01lab__qty-text", text: main }),
|
||||
...(text ? [] : [...pills, ...chips(e, ctx)]),
|
||||
],
|
||||
});
|
||||
cell.title = tline?.이름글 ?? e.qty;
|
||||
return cell;
|
||||
}
|
||||
|
||||
function nameCell(e: Entry, label: string, covered: boolean): HTMLElement {
|
||||
return el("div", {
|
||||
children: [
|
||||
el("div", { text: label }),
|
||||
...(e.share && label === e.name ? [createBadge(`${e.share} ${tl("Share")}`, "accent")] : []),
|
||||
...(e.spec ? [el("div", { className: "ui-text ui-text--muted", text: e.spec })] : []),
|
||||
...(e.warn ? [createBadge(`${tx("Calc_UnitWarn")} · ${e.warn}`, "warning")] : []),
|
||||
...(covered ? [createBadge(th("Edited"), "accent")] : []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
/** 보통 줄 · 몫 줄 — 누르면 그 줄 모달 */
|
||||
function lineRow(e: Entry, ctx: HoContext, label = e.name): HTMLTableRowElement {
|
||||
const covered = !!ctx.edit?.covers[e.idx];
|
||||
const tr = tableRow(COLS, [
|
||||
nameCell(e, label, covered && !e.share),
|
||||
e.qtyValue,
|
||||
e.unit,
|
||||
formulaCell(e, ctx),
|
||||
e.price,
|
||||
money(e.amount),
|
||||
]);
|
||||
tr.classList.add("m01lab__line");
|
||||
if (e.extra) tr.classList.add("m01lab__line--extra");
|
||||
if (covered) tr.classList.add("is-changed");
|
||||
tr.tabIndex = 0;
|
||||
tr.dataset.line = e.name;
|
||||
tr.addEventListener("click", () => e.open(e));
|
||||
tr.addEventListener("keydown", (ev) => ev.key === "Enter" && e.open(e));
|
||||
return tr;
|
||||
}
|
||||
|
||||
/** 펼친 장비 줄 — 로직 줄 객체마다 기억(다시 셈 · 다시 그려도 펼친 채) */
|
||||
const opened = new WeakMap<object, Set<number>>();
|
||||
const openOf = (row: object): Set<number> => {
|
||||
let got = opened.get(row);
|
||||
if (!got) opened.set(row, (got = new Set()));
|
||||
return got;
|
||||
};
|
||||
|
||||
/** 서버 줄 덧칸 — 장비 줄 펼침(714-1b · 부른 기계경비 로직 시간당 줄) */
|
||||
type Unfoldable = TextLine & { 펼침?: SheetUnfold | null };
|
||||
|
||||
/** 장비 · 하위 한 줄 — 머리(노 · 재 · 경 몫 + 합) · 누르면 펼침(부른 로직 시간당 줄 · 몫 줄) */
|
||||
function mergedRows(m: HoMerged<Entry>, ctx: HoContext): HTMLTableRowElement[] {
|
||||
const first = m.몫[0];
|
||||
const share = (cost: string): string =>
|
||||
formatMoney(
|
||||
m.몫
|
||||
.filter((x) => (x.share ?? COST_OF[x.group]) === cost)
|
||||
.reduce((s, x) => s + (x.amount ?? 0), 0),
|
||||
);
|
||||
const short = th("Short")
|
||||
.replace("{a}", share("노무비"))
|
||||
.replace("{b}", share("재료비"))
|
||||
.replace("{c}", share("경비"));
|
||||
const covered = !!ctx.edit?.covers[m.idx];
|
||||
const head = tableRow(COLS, [
|
||||
nameCell(first, `▸ ${first.name}`, covered),
|
||||
first.qtyValue,
|
||||
first.unit,
|
||||
formulaCell(first, ctx, short),
|
||||
first.linePrice,
|
||||
money(m.금액),
|
||||
]);
|
||||
head.classList.add("m01lab__line");
|
||||
if (covered) head.classList.add("is-changed");
|
||||
head.dataset.equip = first.name;
|
||||
head.title = th("Fold");
|
||||
head.tabIndex = 0;
|
||||
head.setAttribute("aria-expanded", "false");
|
||||
const unfold = m.몫.map((x) => (x.textLine as Unfoldable | undefined)?.펼침).find(Boolean);
|
||||
const subs: HTMLTableRowElement[] = [
|
||||
...(unfold
|
||||
? [
|
||||
tableRow(COLS, [
|
||||
{
|
||||
content: createText(
|
||||
th("Per")
|
||||
.replace("{n}", unfold.이름)
|
||||
.replace("{v}", formatPrice(unfold.계 ?? 0))
|
||||
.replace("{u}", unfold.단위 || "-"),
|
||||
"hint",
|
||||
),
|
||||
colSpan: COLS.length,
|
||||
},
|
||||
]),
|
||||
...unfold.줄.map((x) =>
|
||||
tableRow(COLS, [
|
||||
`${x.이름} (${x.비목})`,
|
||||
"",
|
||||
"",
|
||||
sheetFormula(x.글),
|
||||
"",
|
||||
money(x.금액),
|
||||
]),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
...m.몫.map((x) => lineRow(x, ctx, `${x.share ?? first.name}`)),
|
||||
];
|
||||
for (const s of subs) {
|
||||
s.classList.add("is-sub");
|
||||
s.hidden = true;
|
||||
s.dataset.part = first.name;
|
||||
}
|
||||
const label = head.querySelector("td > div > div") as HTMLElement;
|
||||
const keep = openOf(ctx.row);
|
||||
const show = (open: boolean): void => {
|
||||
head.setAttribute("aria-expanded", String(open));
|
||||
label.textContent = `${open ? "▾" : "▸"} ${first.name}`;
|
||||
subs.forEach((s) => (s.hidden = !open));
|
||||
if (open) keep.add(m.idx);
|
||||
else keep.delete(m.idx);
|
||||
};
|
||||
const toggle = (): void => show(head.getAttribute("aria-expanded") !== "true");
|
||||
if (keep.has(m.idx)) show(true);
|
||||
head.addEventListener("click", toggle);
|
||||
head.addEventListener("keydown", (ev) => {
|
||||
if (ev.key !== "Enter" && ev.key !== " ") return;
|
||||
ev.preventDefault();
|
||||
toggle();
|
||||
});
|
||||
return [head, ...subs];
|
||||
}
|
||||
|
||||
/** 옵션 고르기 줄들 — 이름 차례 · 고르기 있으면 고르기 · 아니면 글 칸 */
|
||||
function optionRows(names: string[], ctx: HoContext): HTMLElement[] {
|
||||
const onInput = ctx.onInput;
|
||||
if (!onInput) return [];
|
||||
return (ctx.row.입력 ?? [])
|
||||
.filter((s) => names.includes(s.이름))
|
||||
.map((s) => varRow(s, ctx.values[s.이름] ?? "", (v) => onInput(s.이름, v)));
|
||||
}
|
||||
|
||||
const usesOf = (b: HoBox<Entry>): string[] => [
|
||||
...new Set(
|
||||
b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]).flatMap((e) => e.textLine?.쓰는입력 ?? [])),
|
||||
),
|
||||
];
|
||||
|
||||
function boxCard(b: HoBox<Entry>, ctx: HoContext, calculated: boolean): HTMLElement {
|
||||
const name = boxName(b.묶음);
|
||||
const lines = b.줄.flatMap((r) => ("몫" in r ? r.몫 : [r]));
|
||||
const basis = [...new Set(lines.map((e) => e.textLine?.기준단위).filter(Boolean))];
|
||||
const notes = [...new Set(lines.map((e) => e.textLine?.환산근거).filter(Boolean))];
|
||||
const { root, tbody } = tableShell(COLS, { density: "data" });
|
||||
root.classList.add("m01lab__ho");
|
||||
tbody.append(
|
||||
...(b.줄.length
|
||||
? b.줄.flatMap((r) => ("몫" in r ? mergedRows(r, ctx) : [lineRow(r, ctx)]))
|
||||
: [emptyRow(COLS, tl("Empty_Group"))]),
|
||||
);
|
||||
const sub = tableRow(COLS, [
|
||||
{ content: `${name} ${tl("Subtotal")}`, colSpan: 5 },
|
||||
calculated ? formatMoney(b.소계) : "—",
|
||||
]);
|
||||
sub.classList.add("m01lab__subtotal");
|
||||
sub.dataset.box = b.묶음;
|
||||
sub.dataset.subtotal = calculated ? String(b.소계) : "";
|
||||
tbody.append(sub);
|
||||
const card = createCard({
|
||||
title: name,
|
||||
pad: 12,
|
||||
body: [
|
||||
...(basis.length ? [createText(`${th("Basis")} ${basis.join(" · ")}`, "muted")] : []),
|
||||
...(notes.length ? [createText(notes.join(" · "), "hint")] : []),
|
||||
...optionRows(usesOf(b), ctx),
|
||||
root,
|
||||
],
|
||||
});
|
||||
card.classList.add("m01lab__ho-box");
|
||||
card.dataset.hoBox = b.묶음;
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 어느 박스 줄도 안 쓰는 입력 — 맨 위 한 카드 */
|
||||
function commonCard(rows: HTMLElement[]): HTMLElement {
|
||||
const card = createCard({ title: th("Common"), pad: 12, body: rows });
|
||||
card.classList.add("m01lab__ho-box");
|
||||
card.dataset.hoBox = "common";
|
||||
return card;
|
||||
}
|
||||
|
||||
/** 공용 단가산출 상세 몸 — 공통 옵션 · 박스들 · 비목 소계 셋 · 계 */
|
||||
export function buildHo(ctx: HoContext): HTMLElement[] {
|
||||
const all = entries(ctx);
|
||||
const calculated = ctx.lines !== null;
|
||||
const boxes = boxesOf(all, ["인력", "재료", "경비"]);
|
||||
const used = new Set(boxes.flatMap(usesOf));
|
||||
const common = optionRows(
|
||||
(ctx.row.입력 ?? []).map((s) => s.이름).filter((n) => !used.has(n)),
|
||||
ctx,
|
||||
);
|
||||
const sums = (["labor", "material", "cost"] as Group[]).map((g) => {
|
||||
const v = all.filter((e) => e.group === g).reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
const out = el("span", {
|
||||
className: "ui-calc-row__out is-key",
|
||||
text: calculated ? formatMoney(v) : "—",
|
||||
});
|
||||
const row = createCalcFormulaRow(COST_OF[g], null, out);
|
||||
row.dataset.group = g;
|
||||
row.dataset.subtotal = calculated ? String(v) : "";
|
||||
return row;
|
||||
});
|
||||
const total = all.reduce((s, e) => s + (e.amount ?? 0), 0);
|
||||
return [
|
||||
...(common.length ? [commonCard(common)] : []),
|
||||
...boxes.map((b) => boxCard(b, ctx, calculated)),
|
||||
el("div", { className: "m01lab__sums", children: sums }),
|
||||
el("p", {
|
||||
className: "m01lab__total",
|
||||
attrs: { "data-sum": calculated ? String(total) : "" },
|
||||
text: `${tl("Total")} ${calculated ? formatMoney(total) : "—"}`,
|
||||
}),
|
||||
];
|
||||
}
|
||||
@@ -92,6 +92,8 @@ export interface ModalTarget {
|
||||
usedSlot?: string;
|
||||
/** 있으면 후보 줄을 눌러 고름(시험 입력 · 저장 안 함) — 고르면 모달 닫힘 */
|
||||
onPick?: (item: { ref: string; label: string }) => void;
|
||||
/** 722-2 그 자리 고침 칸(맨 위) — 받는 것 = 모달 닫기 */
|
||||
edit?: (close: () => void) => HTMLElement;
|
||||
}
|
||||
|
||||
/** 수량 칸 표 알약 하나가 여는 자료 — 서버 조각 `참조`(키 · 행 · 열)가 곧 찾은 자리 */
|
||||
@@ -503,6 +505,7 @@ export function openMaterialModal(target: ModalTarget): void {
|
||||
rate.append(muted(`${element} — ${tl("Pill_NoValue")}`));
|
||||
}
|
||||
body.replaceChildren(
|
||||
...(target.edit ? [target.edit(close)] : []),
|
||||
rate,
|
||||
...(cond || target.kind === "재료" ? [candidateView(target, priceRef, close)] : []),
|
||||
muted(tl("Modal_Sum")),
|
||||
|
||||
@@ -233,3 +233,42 @@
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 722-1 공용 단가산출 상세 — 하위 박스(카드) 안 · 옵션 줄 · 표 사이 간격 · 맨 아래 비목 소계 */
|
||||
.m01lab__ho-box > .ui-card__body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab__sums {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
/* 722-2 줄 모달 「그 자리 고침」 */
|
||||
.m01lab-cover {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.m01lab-cover__fields {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab-cover__actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.m01lab__ho-box .ui-card__title {
|
||||
font-size: var(--text-body);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
type TreeRef,
|
||||
type TreeView,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askCopyFolder, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { copyToLayer, type CopyTarget } from "./M02_MasterTemplete_TreePanel_CopyTo";
|
||||
import { askFolder, askNewItem, askText } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { attachTreeDrag } from "./M02_MasterTemplete_TreePanel_Drag";
|
||||
import {
|
||||
@@ -196,7 +196,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
{ label: L("M02_TreeCopy").replace("{n}", n), run: () => copy(list) },
|
||||
...(opt.copyTo ?? []).map((to) => ({
|
||||
label: to.label.replace("{n}", n),
|
||||
run: () => void copyTo(list, to),
|
||||
run: () => copyTo(list, to),
|
||||
})),
|
||||
];
|
||||
};
|
||||
@@ -243,6 +243,7 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
...(isTop
|
||||
? []
|
||||
: [
|
||||
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }, // 720-2 = 「+」 길
|
||||
{ label: L("M02_TreeRename"), run: () => void rename(target) },
|
||||
{ label: L("M02_TreeMove"), run: () => void move(target) },
|
||||
{ label: L("M02_Delete"), run: () => void remove(target) },
|
||||
@@ -554,25 +555,12 @@ export function createTreePanel(opt: TreePanelOptions): TreePanelHandle {
|
||||
showToast(L("M02_TreeCopied").replace("{n}", String(list.length)), "success");
|
||||
}
|
||||
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 이 나무는 그대로 */
|
||||
async function copyTo(list: Target[], to: CopyTarget): Promise<void> {
|
||||
if (!api.paste || !list.length) return;
|
||||
/** 「〇〇로 복사…」(39-6) — 몸은 `TreePanel_CopyTo` */
|
||||
function copyTo(list: Target[], to: CopyTarget): void {
|
||||
const paste_ = api.paste;
|
||||
if (!paste_ || !list.length) return;
|
||||
const clip = { kind: clipKind, from: { layer: opt.layer, owner }, refs: list.map(refOf) };
|
||||
try {
|
||||
const dest = await askCopyFolder(api.fetch, to, list.length);
|
||||
if (!dest) return;
|
||||
await api.paste(to.layer, to.owner, clip, dest);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
|
||||
showToast(
|
||||
L("M02_TreeCopiedTo")
|
||||
.replace("{n}", String(list.length))
|
||||
.replace("{layer}", where)
|
||||
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);
|
||||
}
|
||||
|
||||
async function paste(dest: string[]): Promise<void> {
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
* M02_MasterTemplete_TreePanel_CopyTo.ts
|
||||
* 「〇〇로 복사…」(39-6) — 붙일 층의 나무를 읽어 대상 폴더를 고름(맨 위 · 폴더) → 경로 · 취소 = null.
|
||||
* 아직 만든 것이 없는 남의 층(404)은 맨 위만 — 첫 쓰기 때 서버가 소속을 보고 뼈대를 세움.
|
||||
* `copyToLayer` = 고름 → 붙여넣기 → 알림(구조물 조합 나무 `TreePanel` 에서 뗌 · 720-2).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import type { Layer, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { Layer, TreeClip, TreeView } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import { askFolder } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildFolders, listFolders } from "./M02_MasterTemplete_TreePanel_Model";
|
||||
|
||||
@@ -26,3 +28,38 @@ export async function askCopyFolder(
|
||||
const title = to.label.replace("{n}", String(n));
|
||||
return askFolder(title, folders, () => false, [], L("M02_TreeCopyFolder"));
|
||||
}
|
||||
|
||||
/** 「〇〇로 복사…」 — 그 층 폴더를 고름(`askCopyFolder`) → 새 키 · 같은 이름 「(2)」 · 부른 나무는 그대로 */
|
||||
export async function copyToLayer(
|
||||
api: {
|
||||
fetch: (layer: Layer, owner: string | null) => Promise<TreeView>;
|
||||
paste: (
|
||||
layer: Layer,
|
||||
owner: string | null,
|
||||
clip: TreeClip,
|
||||
dest: string[],
|
||||
) => Promise<TreeView>;
|
||||
},
|
||||
clip: TreeClip,
|
||||
to: CopyTarget,
|
||||
): Promise<void> {
|
||||
const n = String(clip.refs.length);
|
||||
try {
|
||||
const dest = await askCopyFolder(api.fetch, to, clip.refs.length);
|
||||
if (!dest) return;
|
||||
await api.paste(to.layer, to.owner, clip, dest);
|
||||
const where = to.label.replace(/ ?\(.*$/, "").replace(/(으로|로) 복사…?$/, "");
|
||||
showToast(
|
||||
L("M02_TreeCopiedTo")
|
||||
.replace("{n}", n)
|
||||
.replace("{layer}", where)
|
||||
.replace("{folder}", dest.join(" / ") || L("M02_TreeTop")),
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(
|
||||
L("M02_ActionFailed").replace("{value}", error instanceof Error ? error.message : ""),
|
||||
"error",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -595,6 +595,10 @@ export function createMain(
|
||||
sheet.recalc();
|
||||
panel.refresh(sheet.getDoc()?.열 ?? []);
|
||||
}
|
||||
// 720-3 — 서고 문서(단가산출 · 일위대가) 이름을 고쳐 저장하면 머리 제목도 새 이름(「키 이름」)
|
||||
const named = (doc as { 이름?: unknown } | null)?.이름;
|
||||
if ((at.kind === "sangeun" || isPrecombo(at.kind)) && typeof named === "string" && sel === at)
|
||||
title.textContent = at.label = named ? `${at.name} ${named}` : at.name;
|
||||
showNotice([]);
|
||||
showToast(L("M02_Saved"), "success");
|
||||
onSaved?.(); // 좌측 목록의 판을 새 판으로
|
||||
|
||||
@@ -239,24 +239,37 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise<Mounte
|
||||
history.replaceState(null, "", `#/${ROUTES.M02_MASTER_TEMPLATE}?${query}`);
|
||||
void side.refreshPrecombo();
|
||||
};
|
||||
const preBar = (): HTMLElement =>
|
||||
// 층 단추 옆 「+」(720-2) = 서고 칸이 만든 맨 위 메뉴
|
||||
const preBar = (add: HTMLElement): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: pre.root, owner: pre.owner },
|
||||
company,
|
||||
go: (next) => setPre(next),
|
||||
// 켜진 단추 다시 누름 = 그 층 목록표(생성 · 복사 · 삭제)
|
||||
add,
|
||||
// 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표(720-1) · 켜진 단추 다시 누름 = 목록표
|
||||
go: (next) =>
|
||||
void main
|
||||
.confirmLeave()
|
||||
.then((ok) => ok && (setPre(next), void main.open(precomboList(pre)))),
|
||||
reselect: () =>
|
||||
void main.confirmLeave().then((ok) => ok && void main.open(precomboList(pre))),
|
||||
});
|
||||
// 단가산출 층(717-2a) — 일위대가 층과 따로 · 기본 개인(못 가면 마스터) · 주소에 안 실음
|
||||
let sk = preScope("personal");
|
||||
const skBar = (): HTMLElement =>
|
||||
const skBar = (add: HTMLElement): HTMLElement =>
|
||||
createLayerBar({
|
||||
user,
|
||||
active: { root: sk.root, owner: sk.owner },
|
||||
company,
|
||||
go: (next) => ((sk = preScope(next)), void side.refreshSangeun()),
|
||||
add,
|
||||
go: (next) =>
|
||||
void main
|
||||
.confirmLeave()
|
||||
.then(
|
||||
(ok) =>
|
||||
ok &&
|
||||
((sk = preScope(next)), void side.refreshSangeun(), void main.open(sangeunList(sk))),
|
||||
),
|
||||
reselect: () => void main.confirmLeave().then((ok) => ok && void main.open(sangeunList(sk))),
|
||||
});
|
||||
const side = buildSide({
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
* 여럿(구조물 조합 나무 꼴 · `TreePanel_Pick`) — Ctrl · Shift 클릭 고름 · 오른쪽 클릭 · Ctrl+C · Ctrl+V ·
|
||||
* Delete 가 고른 것 전부에 · 복사는 읽을 수 있는 층 어디서나 → 쓸 수 있는 층에 붙여넣기(새 키).
|
||||
* 문서 누름 = 편집 칸 · 켜진 층 단추 다시 누름 = 목록표(페이지 몫).
|
||||
* 최상위 폴더(720-2) — 층 단추 옆 「+」(맨 위 메뉴) · 폴더 · 문서 줄 [최상위 폴더 만들기] = 같은 `addFolder([])`.
|
||||
* 문서 줄 [이름 바꾸기](720-3) — 읽고 문서 `이름` 만 갈아 저장(서버 새 길 없음) · 열린 문서면 새 이름으로 다시 엶.
|
||||
* ========================================================================== */
|
||||
|
||||
import { isCtrlKey } from "../A00_Common/key_shortcut";
|
||||
import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements";
|
||||
import { createEmpty } from "@ui/ui_template_empty";
|
||||
import { t as L } from "@ui/ui_template_locale";
|
||||
import {
|
||||
@@ -28,6 +30,8 @@ import {
|
||||
listTemplates,
|
||||
newLibrary,
|
||||
pasteLibraryTree,
|
||||
readTemplate,
|
||||
saveTemplate,
|
||||
type Layer,
|
||||
type LibraryKind,
|
||||
type PrecomboTree,
|
||||
@@ -58,8 +62,8 @@ export interface LibraryScope {
|
||||
export interface LibraryOptions {
|
||||
kind: LibraryKind;
|
||||
scope: () => LibraryScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 단추 옆 「+」(720-2) */
|
||||
bar: (add: HTMLElement) => HTMLElement;
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
@@ -130,6 +134,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
className: "ui-tree m02-tree__body",
|
||||
attrs: { "data-library-tree": kind, tabindex: "0" },
|
||||
});
|
||||
// 층 단추 옆 「+」(720-2 · 구조물 조합 꼴) — 맨 위 메뉴 · 읽기만 층 = 숨김(그릴 때)
|
||||
const add = createButton({
|
||||
label: "+",
|
||||
variant: "ghost",
|
||||
onClick: (event) => openTreeMenu(event, menuAt([])),
|
||||
});
|
||||
add.classList.add("m02-tree__add");
|
||||
add.title = L("M02_TreeAddTop");
|
||||
add.setAttribute("aria-label", L("M02_TreeAddTop"));
|
||||
const leaves = new Map<string, HTMLElement>();
|
||||
const toggled = new Map<string, boolean>(); // 손으로 펴고 접은 폴더 — 다시 그려도 그대로
|
||||
const targets = new Map<string, Target>();
|
||||
@@ -209,6 +222,34 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
if (got && got.name !== was) await edit({ op: "rename", 경로: path, 이름: got.name });
|
||||
};
|
||||
|
||||
/** 문서 이름 바꾸기(720-3) — 열린 문서면 저장 안 한 고침 물음 → 읽고 `이름` 만 갈아 저장 → 나무 · 상세 새 이름 */
|
||||
const renameDoc = async (row: LibraryRow): Promise<void> => {
|
||||
const at = opt.scope();
|
||||
const was = row.제목 || row.이름;
|
||||
const got = await askText(L("M02_TreeRename"), [
|
||||
{ key: "name", label: L("M02_TreeItemName"), value: was },
|
||||
]);
|
||||
const name = got?.name.trim();
|
||||
if (!name || name === was) return;
|
||||
const cur = opt.getOpen();
|
||||
const shown =
|
||||
cur?.kind === kind &&
|
||||
cur.name === row.이름 &&
|
||||
cur.layer === at.layer &&
|
||||
(cur.owner ?? null) === at.owner;
|
||||
if (shown && !(await opt.confirmLeave())) return;
|
||||
try {
|
||||
const doc = await readTemplate(at.layer, kind, row.이름, null, at.owner);
|
||||
const body = { ...(doc.문서 as Record<string, unknown>), 이름: name };
|
||||
await saveTemplate(at.layer, kind, row.이름, null, doc.판 ?? "", body, at.owner);
|
||||
showToast(L("M02_TreeDone"), "success");
|
||||
await refresh(true);
|
||||
if (shown) await open({ ...row, 제목: name });
|
||||
} catch (error) {
|
||||
showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error");
|
||||
}
|
||||
};
|
||||
|
||||
/* --- 지우기 · 여럿 · 복사 붙여넣기(구조물 조합 나무 꼴) --- */
|
||||
const pickedTargets = (): Target[] =>
|
||||
[...picked].map((id) => targets.get(id)).filter((t): t is Target => !!t);
|
||||
@@ -255,6 +296,7 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
item(opt.text.newHere, () => make(path)),
|
||||
...(path.length
|
||||
? [
|
||||
item(L("M02_TreeAddTopFolder"), () => void addFolder([])),
|
||||
item(L("M02_TreeRename"), () => void rename(path)),
|
||||
item(L("M02_Delete"), () => void remove([target])),
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
@@ -288,12 +330,15 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
const item = { key: row.이름, 열: row.이름, 경로: [...folder, label], 이름: label };
|
||||
const target: Target = { kind: "item", item };
|
||||
targets.set(id, target);
|
||||
const off = (): string | undefined => (opt.scope().canWrite ? undefined : tc("ReadOnly"));
|
||||
const single = (): TreeMenuItem[] => [
|
||||
{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]), disabledReason: off() },
|
||||
{ label: L("M02_TreeRename"), run: () => void renameDoc(row), disabledReason: off() },
|
||||
{ label: L("M02_TreeCopy").replace("{n}", "1"), run: () => copy([target]) },
|
||||
{
|
||||
label: L("M02_Delete"),
|
||||
run: () => void remove([target]),
|
||||
disabledReason: opt.scope().canWrite ? undefined : tc("ReadOnly"),
|
||||
disabledReason: off(),
|
||||
},
|
||||
...(opt.more?.(row.이름, label) ?? []),
|
||||
];
|
||||
@@ -424,7 +469,8 @@ export function buildLibrarySection(opt: LibraryOptions): LibraryHandle {
|
||||
const refresh = async (keep = false): Promise<void> => {
|
||||
const mine = ++seq;
|
||||
const at = opt.scope();
|
||||
barHost.replaceChildren(opt.bar());
|
||||
add.hidden = !at.canWrite;
|
||||
barHost.replaceChildren(opt.bar(add));
|
||||
if (!keep) treeHost.replaceChildren(createEmpty({ text: tc("Loading"), small: true }));
|
||||
const [got, tree] = await Promise.all([
|
||||
// 못 받으면 빈 나무(목록표에서 까닭) · 폴더를 못 받으면 문서는 모두 뿌리
|
||||
|
||||
@@ -15,6 +15,7 @@ import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
import {
|
||||
buildLibrarySection,
|
||||
type LibraryHandle,
|
||||
type LibraryOptions,
|
||||
type LibraryScope,
|
||||
} from "./M02_MasterTemplete_UI_Side_Library";
|
||||
|
||||
@@ -53,8 +54,8 @@ export const precomboList = (scope: PrecomboScope): Selection => ({
|
||||
|
||||
export interface PrecomboOptions {
|
||||
scope: () => PrecomboScope;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) */
|
||||
bar: () => HTMLElement;
|
||||
/** 층 단추 줄(페이지가 만듦 · 그릴 때마다 새로 — 켜진 단추가 바뀜) · `add` = 「+」(720-2) */
|
||||
bar: LibraryOptions["bar"];
|
||||
confirmLeave: () => Promise<boolean>;
|
||||
onOpen: (sel: Selection | null) => void;
|
||||
getOpen: () => Selection | null;
|
||||
|
||||
@@ -177,6 +177,55 @@ def dtm_grid_file(npz_path: Path | str) -> Path:
|
||||
return target
|
||||
|
||||
|
||||
def implicit_rbf(path: Path | str) -> Callable[[np.ndarray], np.ndarray]:
|
||||
"""`implicit_{필터}.npz`(중심점 · 높이 · smoothing) → RBF 표고 함수((N, 2) → (N,))."""
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
with np.load(Path(path), allow_pickle=False) as data:
|
||||
centers = np.asarray(data["centers_xy"], dtype=np.float64)
|
||||
center_z = np.asarray(data["center_z"], dtype=np.float64)
|
||||
smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0
|
||||
return RBFInterpolator(
|
||||
centers,
|
||||
center_z,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
|
||||
|
||||
def implicit_grid_npz(models_dir: Path | str, source_filter: str) -> Path:
|
||||
"""implicit 를 1 m 격자로 굳힌 파생 저장본 `implicit_{필터}_grid.npz`(641-1 · 요구 558-3).
|
||||
|
||||
축 · 유효 칸 = 같은 필터 `dtm_{필터}.npz` 그대로 · 유효 칸만 RBF · 밖은 dtm 값(NaN 없음).
|
||||
없거나 implicit · dtm 보다 옛것이면 다시 셈 — 서버 표본 · 브라우저 · Node 측점 · B06 이 같은 값.
|
||||
"""
|
||||
check_surface_keys(source_filter, "implicit")
|
||||
models_dir = Path(models_dir)
|
||||
source = models_dir / f"implicit_{source_filter}.npz"
|
||||
dtm = models_dir / f"dtm_{source_filter}.npz"
|
||||
target = models_dir / f"implicit_{source_filter}_grid.npz"
|
||||
newest = max(source.stat().st_mtime_ns, dtm.stat().st_mtime_ns)
|
||||
if target.is_file() and target.stat().st_mtime_ns >= newest:
|
||||
return target
|
||||
with np.load(dtm, allow_pickle=False) as data:
|
||||
x, y = data["x"], data["y"]
|
||||
z = np.array(data["z"])
|
||||
valid = np.asarray(data["valid_mask"], dtype=bool)
|
||||
resolution = data["resolution"] if "resolution" in data else np.array([1.0], np.float32)
|
||||
rbf = implicit_rbf(source)
|
||||
rows, cols = np.nonzero(valid)
|
||||
query = np.column_stack([x[cols], y[rows]]).astype(np.float64)
|
||||
for start in range(0, len(query), 50_000):
|
||||
end = start + 50_000
|
||||
z[rows[start:end], cols[start:end]] = rbf(query[start:end])
|
||||
temporary = target.with_name(f"{target.stem}.{os.getpid()}.tmp")
|
||||
with open(temporary, "wb") as handle:
|
||||
np.savez(handle, x=x, y=y, z=z, valid_mask=valid, resolution=resolution)
|
||||
os.replace(temporary, target)
|
||||
return target
|
||||
|
||||
|
||||
@dataclass
|
||||
class CallableSurfaceSampler:
|
||||
"""테스트와 어댑터에 사용할 함수 기반 sampler."""
|
||||
@@ -255,21 +304,8 @@ def build_surface_sampler(
|
||||
return InterpolatedSurfaceSampler(lambda xy: spline.ev(xy[:, 1], xy[:, 0]), footprint)
|
||||
|
||||
if method == "implicit":
|
||||
from scipy.interpolate import RBFInterpolator
|
||||
|
||||
path = models_dir / f"implicit_{source_filter}.npz"
|
||||
with np.load(path, allow_pickle=False) as data:
|
||||
centers = np.asarray(data["centers_xy"], dtype=np.float64)
|
||||
center_z = np.asarray(data["center_z"], dtype=np.float64)
|
||||
smoothing = float(data["smoothing"][0]) if "smoothing" in data else 0.0
|
||||
interpolator = RBFInterpolator(
|
||||
centers,
|
||||
center_z,
|
||||
neighbors=min(64, len(centers)),
|
||||
smoothing=smoothing,
|
||||
kernel="thin_plate_spline",
|
||||
)
|
||||
return InterpolatedSurfaceSampler(lambda xy: interpolator(xy), footprint)
|
||||
# 굳힌 격자(641-1) — 브라우저 · Node 측점 · B06 이 같은 파일을 읽음(`source` 가 붙음)
|
||||
return DtmGridSampler.open_cached(implicit_grid_npz(models_dir, source_filter))
|
||||
|
||||
if method == "meshfree":
|
||||
from scipy.interpolate import LinearNDInterpolator
|
||||
|
||||
@@ -63,6 +63,11 @@ SURFACE_RANSAC_SEED = int(os.getenv("SURFACE_RANSAC_SEED", "42"))
|
||||
SURFACE_CLASSIFIED_GROUND_MIN_RATIO = float(
|
||||
os.getenv("SURFACE_CLASSIFIED_GROUND_MIN_RATIO", "0.002")
|
||||
)
|
||||
# 자동 기본 고름만 — 점 있는 칸(한 변 아래 m) 중 class 2 가 있는 칸이 이 비율 이상이어야 분류 있음.
|
||||
SURFACE_CLASSIFIED_COVERAGE_CELL_M = float(os.getenv("SURFACE_CLASSIFIED_COVERAGE_CELL_M", "10.0"))
|
||||
SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO = float(
|
||||
os.getenv("SURFACE_CLASSIFIED_COVERAGE_MIN_RATIO", "0.5")
|
||||
)
|
||||
# 지면점 비율이 이 값 미만이면 필터가 사실상 실패한 것으로 보고 WARNING을 남긴다.
|
||||
SURFACE_GROUND_RATIO_WARN = float(os.getenv("SURFACE_GROUND_RATIO_WARN", "0.01"))
|
||||
|
||||
@@ -95,7 +100,8 @@ SURFACE_MODEL_SYNC_TIMEOUT_SECONDS = int(os.getenv("SURFACE_MODEL_SYNC_TIMEOUT_S
|
||||
|
||||
# footprint(외곽) 산출
|
||||
SURFACE_FOOTPRINT_RESOLUTION_M = float(os.getenv("SURFACE_FOOTPRINT_RESOLUTION_M", "1.0"))
|
||||
SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "1.0"))
|
||||
# 외곽 틈 닫기 3 m — DJI 지면점이 성긴 숲 띠까지 스캔 범위로 살림(요구 558).
|
||||
SURFACE_FOOTPRINT_GAP_CLOSE_M = float(os.getenv("SURFACE_FOOTPRINT_GAP_CLOSE_M", "3.0"))
|
||||
SURFACE_BOUNDARY_INSET_M = float(os.getenv("SURFACE_BOUNDARY_INSET_M", "1.0"))
|
||||
SURFACE_KEEP_LARGEST_FOOTPRINT = (
|
||||
os.getenv("SURFACE_KEEP_LARGEST_FOOTPRINT", "True").lower() == "true"
|
||||
@@ -111,9 +117,10 @@ SURFACE_NURBS_PATCH_SIZE_M = float(os.getenv("SURFACE_NURBS_PATCH_SIZE_M", "50.0
|
||||
SURFACE_NURBS_CONTROL_POINTS_PER_AXIS = int(
|
||||
os.getenv("SURFACE_NURBS_CONTROL_POINTS_PER_AXIS", "16")
|
||||
)
|
||||
SURFACE_IMPLICIT_MAX_POINTS_PER_TILE = int(
|
||||
os.getenv("SURFACE_IMPLICIT_MAX_POINTS_PER_TILE", "10000")
|
||||
)
|
||||
# implicit 중심점 수 = clip(지면점 × 비율, 하한, 상한) · 지면점이 하한보다 적으면 전부(요구 558).
|
||||
SURFACE_IMPLICIT_POINT_RATIO = float(os.getenv("SURFACE_IMPLICIT_POINT_RATIO", "0.175"))
|
||||
SURFACE_IMPLICIT_MIN_POINTS = int(os.getenv("SURFACE_IMPLICIT_MIN_POINTS", "50000"))
|
||||
SURFACE_IMPLICIT_MAX_POINTS = int(os.getenv("SURFACE_IMPLICIT_MAX_POINTS", "2000000"))
|
||||
SURFACE_IMPLICIT_SMOOTHING = float(os.getenv("SURFACE_IMPLICIT_SMOOTHING", "0.1"))
|
||||
SURFACE_MESHFREE_MAX_MODEL_POINTS = int(os.getenv("SURFACE_MESHFREE_MAX_MODEL_POINTS", "500000"))
|
||||
SURFACE_MESHFREE_POINT_RADIUS_M = float(os.getenv("SURFACE_MESHFREE_POINT_RADIUS_M", "0.15"))
|
||||
@@ -166,9 +173,10 @@ SHEET_SURFACE_AUTO_METHODS = [
|
||||
|
||||
# 일반 사용자 WF1 자동 확정 기본값
|
||||
SURFACE_CONFIRM_DEFAULT_FILTER = os.getenv("SURFACE_CONFIRM_DEFAULT_FILTER", "csf")
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "dtm")
|
||||
# 확정 표현 = implicit · 평활 끔 — WF1 자동 짓기도 이 표현을 기본 필터에 지음(요구 558).
|
||||
SURFACE_CONFIRM_DEFAULT_METHOD = os.getenv("SURFACE_CONFIRM_DEFAULT_METHOD", "implicit")
|
||||
SURFACE_CONFIRM_DEFAULT_SMOOTH = (
|
||||
os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "True").lower() == "true"
|
||||
os.getenv("SURFACE_CONFIRM_DEFAULT_SMOOTH", "False").lower() == "true"
|
||||
)
|
||||
|
||||
|
||||
@@ -190,7 +198,9 @@ def build_surface_model_config() -> dict:
|
||||
"nurbs_degree": SURFACE_NURBS_DEGREE,
|
||||
"nurbs_patch_size_meters": SURFACE_NURBS_PATCH_SIZE_M,
|
||||
"nurbs_control_points_per_axis": SURFACE_NURBS_CONTROL_POINTS_PER_AXIS,
|
||||
"implicit_max_points_per_tile": SURFACE_IMPLICIT_MAX_POINTS_PER_TILE,
|
||||
"implicit_point_ratio": SURFACE_IMPLICIT_POINT_RATIO,
|
||||
"implicit_min_points": SURFACE_IMPLICIT_MIN_POINTS,
|
||||
"implicit_max_points": SURFACE_IMPLICIT_MAX_POINTS,
|
||||
"implicit_smoothing": SURFACE_IMPLICIT_SMOOTHING,
|
||||
"meshfree_max_model_points": SURFACE_MESHFREE_MAX_MODEL_POINTS,
|
||||
"meshfree_point_radius_meters": SURFACE_MESHFREE_POINT_RADIUS_M,
|
||||
|
||||
@@ -133,3 +133,19 @@ M01/M02에 대한 화면 검증은 잠시 미루고 라이다 데이터와 관
|
||||
## 요구 559 (11-01 · 사용자 · 요구서 558 물음에 답)
|
||||
|
||||
분류 없는 라이다용 내장 CSF 개선 → 처음부터 점진 TIN 내장
|
||||
|
||||
## 요구 560 (11-01 · 사용자 · 밤 맡김 · 라이다 · 내역서 공통)
|
||||
|
||||
오늘은 나는 쉬러갈 생각임. 너가 브레인으로서 업무 수행할 내용
|
||||
1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음.
|
||||
2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행
|
||||
3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유
|
||||
|
||||
## 요구 561 (11-01 · 사용자 · 밤 맡김 다시 · 4 번 더함)
|
||||
|
||||
오늘은 나는 쉬러갈 생각임. 너가 브레인으로서 업무 수행할 내용
|
||||
|
||||
1. 구현 완료시 내요구사항이 제대로 반영되었는지 직접다시 읽고 확인할것. 최근 누락이 늘어나고 있음.
|
||||
2. 1번 내용 완료시 너가 사용자가 된것처럼 브라우져를 컨트롤하여 검증 수행
|
||||
3. 최종완료시 내일 아침에 내가 볼수 있게 아티팩트로 결과보고서 공유
|
||||
4. 현재 존재하는 프로젝트들에 사용되던 라이다 데이터를 영구저장소에 복제하여 넣고 내가 프로젝트를 접근하면 바로 확인할 수 있게 반영
|
||||
|
||||
@@ -139,3 +139,7 @@ M02 단가산출/일위대가에서 선택에 대한 검색기능에서 여러
|
||||
|
||||
1. 일위대가 수량 → 일위대가의 하나의 단가산출행의 수기입력적보는 기준단위 당 수량이 맞음. 하지만 이값은 구조물 조합의 엑셀에서 알약으로 연결하거나 프로젝트 내부에서 계산된 값과 연결되어져야한다고 내가 말했잖아. 만약 일위대가의 만드는 당시에 계산값을 확인하기 위해 임시 입력을 휘발성으로 입력받으라고도 말했고 이해를 못하는것같네....
|
||||
2. M01 창 고침 → M01단가산출의 노임은 변경되지 않는게 맞음. 단, 만약 M02의 단가산출과 일위대가의 상세화면이라면 사용자가 변경할수 있어야하며 이값은 M01의 값을 변경하는게 아니라 M02 변경항목에 별도로 보관되어야하는 값임. 필요한 경우 데이터 일원화를 위해 변경시 M02에 단가산출에서 자동생성도 필요할 수도 있음.
|
||||
|
||||
## 내역 18 (11-01 · 사용자 · 밤 맡김 · 원문 = 사용자요구_1101.md 요구 560 과 같음)
|
||||
|
||||
구현 완료 시 요구사항 반영을 원문으로 다시 읽어 확인 · 그 뒤 브레인이 사용자처럼 브라우저로 검증 · 최종 완료 시 아침에 볼 아티팩트 결과보고서.
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
"""640 라이다 기본 고름 · implicit 점 수 · 격자 칸 뽑기 · 지문 · 틈 닫기 (요구 558).
|
||||
|
||||
서버 함수만 — 작은 합성 점군 · 임시 폴더.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Filter_Classification import (
|
||||
classified_coverage,
|
||||
has_classified_coverage,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import resolve_auto_source_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelBuild import implicit_point_count
|
||||
from B04_PreProcess.B04_PreProcess_Engine_ModelContext import TerrainContext
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Pipeline import build_all_terrain_models
|
||||
from config import config_system as cfg
|
||||
|
||||
|
||||
def _plane(size_m: float = 60.0, spacing_m: float = 0.5) -> np.ndarray:
|
||||
axis = np.arange(0.0, size_m, spacing_m)
|
||||
x, y = np.meshgrid(axis, axis)
|
||||
z = 100.0 + 0.2 * x + 0.1 * np.sin(y / 5.0)
|
||||
return np.column_stack([x.ravel(), y.ravel(), z.ravel()])
|
||||
|
||||
|
||||
def _structured(points: np.ndarray, classification: np.ndarray | None = None) -> dict:
|
||||
data = {"xyz": points, "bounds": np.array([points.min(axis=0), points.max(axis=0)]).T}
|
||||
if classification is not None:
|
||||
data["classification"] = classification
|
||||
return data
|
||||
|
||||
|
||||
def test_확정_기본값은_implicit_평활_끔() -> None:
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_METHOD == "implicit"
|
||||
assert cfg.SURFACE_CONFIRM_DEFAULT_SMOOTH is False
|
||||
assert cfg.SURFACE_AUTO_METHODS == ("dtm",) # D03 자체 라이다는 dtm 만 그대로
|
||||
|
||||
|
||||
def test_일부만_분류된_LAS_는_자동_기본에서_csf() -> None:
|
||||
points = _plane(100.0)
|
||||
partial = np.ones(len(points), dtype=np.uint8)
|
||||
partial[(points[:, 0] < 30) & (np.arange(len(points)) % 4 == 0)] = 2 # 비율 7 % · 덮임 30 %
|
||||
assert classified_coverage(_structured(points, partial)) < 0.5
|
||||
assert resolve_auto_source_filters(_structured(points, partial)) == ["csf"]
|
||||
|
||||
spread = np.ones(len(points), dtype=np.uint8)
|
||||
spread[::37] = 2 # 비율 2.7 % · 덮임 100 %
|
||||
assert classified_coverage(_structured(points, spread)) == 1.0
|
||||
assert resolve_auto_source_filters(_structured(points, spread)) == ["classification", "csf"]
|
||||
|
||||
|
||||
def test_덮임은_저장본을_다시_씀(tmp_path: Path) -> None:
|
||||
points = _plane(40.0)
|
||||
classes = np.full(len(points), 2, dtype=np.uint8)
|
||||
(tmp_path / "structured.npz").write_bytes(b"x")
|
||||
assert has_classified_coverage(_structured(points, classes), tmp_path)
|
||||
assert (tmp_path / "classification_coverage.json").is_file()
|
||||
|
||||
class _NoXyz(dict):
|
||||
def __getitem__(self, key): # type: ignore[no-untyped-def]
|
||||
assert key != "xyz", "저장본이 맞으면 점군을 풀지 않아야 함"
|
||||
return dict.__getitem__(self, key)
|
||||
|
||||
assert has_classified_coverage(_NoXyz(_structured(points, classes)), tmp_path)
|
||||
|
||||
|
||||
def _config(**override: object) -> dict:
|
||||
config = cfg.build_surface_model_config()
|
||||
config.update({"source_filters": ["a", "b"], "precompute": ["dtm", "implicit"]})
|
||||
config.update(override)
|
||||
return config
|
||||
|
||||
|
||||
def test_implicit_은_기본_필터에만(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool), "b": np.arange(len(points)) % 2 == 0}
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, _config())
|
||||
assert set(manifest["source_filters"]["a"]["methods"]) == {"dtm", "implicit"}
|
||||
assert set(manifest["source_filters"]["b"]["methods"]) == {"dtm"}
|
||||
assert not (tmp_path / "implicit_b.npz").exists()
|
||||
|
||||
|
||||
def test_implicit_점_수_비율_하한_상한() -> None:
|
||||
config = cfg.build_surface_model_config()
|
||||
assert (config["implicit_point_ratio"], config["implicit_min_points"]) == (0.175, 50_000)
|
||||
assert config["implicit_max_points"] == 2_000_000
|
||||
assert implicit_point_count(1_140_716, config) == 199_625 # 용화 분류
|
||||
assert implicit_point_count(768_273, config) == 134_448 # 용화 CSF
|
||||
assert implicit_point_count(100_000, config) == 50_000 # 하한
|
||||
assert implicit_point_count(30_000, config) == 30_000 # 하한보다 적으면 전부
|
||||
assert implicit_point_count(50_000_000, config) == 2_000_000 # 상한
|
||||
|
||||
|
||||
def test_격자_칸_뽑기는_고르고_씨앗_고정() -> None:
|
||||
rng = np.random.default_rng(1)
|
||||
dense = rng.uniform(0, 20, (200_000, 2)) # 왼쪽 아래 트인 곳 — 점 몰림
|
||||
sparse = rng.uniform(0, 100, (30_000, 2)) # 전체 성긴 숲 밑
|
||||
xy = np.vstack([dense, sparse])
|
||||
points = np.column_stack([xy, np.zeros(len(xy))])
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=cfg.build_surface_model_config(),
|
||||
)
|
||||
picked = context.grid_sample(5_000)
|
||||
assert len(picked) == 5_000 and len(np.unique(picked[:, :2], axis=0)) == 5_000
|
||||
assert np.array_equal(picked, context.grid_sample(5_000))
|
||||
in_dense = np.count_nonzero((picked[:, 0] < 20) & (picked[:, 1] < 20)) / len(picked)
|
||||
file_order = context.sample(5_000)
|
||||
in_dense_file = np.count_nonzero((file_order[:, 0] < 20) & (file_order[:, 1] < 20))
|
||||
assert in_dense < 0.1 < 0.5 < in_dense_file / len(file_order) # 파일 차례는 몰림 · 격자는 고름
|
||||
assert len(context.grid_sample(300_000)) == len(points) # 모자라면 전부
|
||||
|
||||
|
||||
def test_설정_지문이_바뀌면_implicit_다시_지음(tmp_path: Path) -> None:
|
||||
points = _plane()
|
||||
data = _structured(points)
|
||||
masks = {"a": np.ones(len(points), bool)}
|
||||
config = _config(
|
||||
source_filters=["a"],
|
||||
precompute=["implicit"],
|
||||
implicit_min_points=500,
|
||||
implicit_max_points=1_000,
|
||||
)
|
||||
build_all_terrain_models(data, masks, tmp_path, config)
|
||||
with np.load(tmp_path / "implicit_a.npz") as old:
|
||||
assert len(old["center_z"]) == 1_000
|
||||
changed = dict(config, implicit_max_points=2_000)
|
||||
manifest = build_all_terrain_models(data, masks, tmp_path, changed)
|
||||
with np.load(tmp_path / "implicit_a.npz") as new:
|
||||
assert len(new["center_z"]) == 2_000
|
||||
assert manifest["source_filters"]["a"]["methods"]["implicit"]["center_count"] == 2_000
|
||||
|
||||
|
||||
def test_외곽_틈_닫기_3m_는_성긴_띠를_살림() -> None:
|
||||
points = _plane(60.0)
|
||||
gap = (points[:, 0] > 28) & (points[:, 0] < 33) # 5 m 빈 띠 — 1 m 닫기면 두 덩이
|
||||
points = points[~gap]
|
||||
|
||||
def area(close_m: float) -> float:
|
||||
config = dict(cfg.build_surface_model_config(), footprint_gap_close_meters=close_m)
|
||||
context = TerrainContext(
|
||||
xyz=points,
|
||||
mask=np.ones(len(points), bool),
|
||||
bounds=np.array([points.min(axis=0), points.max(axis=0)]).T,
|
||||
config=config,
|
||||
)
|
||||
return context.footprint_metadata()["footprint_area_m2"]
|
||||
|
||||
assert cfg.SURFACE_FOOTPRINT_GAP_CLOSE_M == 3.0
|
||||
assert area(3.0) > 1.8 * area(1.0) # 한 덩이(띠 포함) 대 큰 덩이 하나
|
||||
@@ -0,0 +1,163 @@
|
||||
"""PLAN 641 — implicit 굳힌 격자(`implicit_{필터}_grid.npz`) · 화면 일치 · B07 · D03 그대로.
|
||||
|
||||
641-1: 굳힌 격자 축 · 유효 = 같은 필터 dtm · 유효 칸 값 = RBF 값 · 밖 = dtm 값 · 옛것이면 다시 셈 ·
|
||||
서버 표본기 · Node 측점(`source`) · B06 재계산 · 브라우저 격자 통로가 같은 파일.
|
||||
641-2: implicit 확정이어도 B07 음영 · D03 지형은 평활 DTM · dtm 확정은 전과 같음.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
from B05_Profile import B05_Profile_Router_Terrain as terrain_router
|
||||
from B06_Section import B06_Section_Server_Calc_Prebuild as prebuild
|
||||
from common_util.common_util_surface_sampler import (
|
||||
DtmGridSampler,
|
||||
build_surface_sampler,
|
||||
dtm_grid_bytes,
|
||||
implicit_grid_npz,
|
||||
implicit_rbf,
|
||||
)
|
||||
from D03_PlanAnalysis.D03_PlanAnalysis_TerrainSource import road_dem
|
||||
|
||||
IMPLICIT = {"source_filter": "classification", "method": "implicit", "smooth": False}
|
||||
|
||||
|
||||
def _surface(x, y):
|
||||
return 100.0 + 0.2 * x + 0.1 * y + 2.0 * np.sin(x / 7.0) * np.cos(y / 5.0)
|
||||
|
||||
|
||||
def _project(tmp_path: Path) -> Path:
|
||||
models = tmp_path / "B04_PreProcess" / "models"
|
||||
models.mkdir(parents=True)
|
||||
axis = np.arange(0.0, 40.0, dtype=np.float32)
|
||||
valid = np.zeros((40, 40), dtype=bool)
|
||||
valid[5:35, 3:30] = True
|
||||
for name, z in (("dtm_classification", 50.0), ("dtm_classification_smooth", 60.0)):
|
||||
np.savez(
|
||||
models / f"{name}.npz",
|
||||
x=axis,
|
||||
y=axis,
|
||||
z=np.full((40, 40), z, np.float32),
|
||||
valid_mask=valid,
|
||||
resolution=np.array([1.0], np.float32),
|
||||
)
|
||||
rng = np.random.default_rng(641)
|
||||
centers = rng.uniform(0.0, 39.0, (600, 2)).astype(np.float32)
|
||||
np.savez(
|
||||
models / "implicit_classification.npz",
|
||||
centers_xy=centers,
|
||||
center_z=_surface(centers[:, 0], centers[:, 1]).astype(np.float32),
|
||||
smoothing=np.array([0.1], np.float32),
|
||||
)
|
||||
return models
|
||||
|
||||
|
||||
def test_굳힌_격자_축_유효_dtm_같음_값_RBF(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
path = implicit_grid_npz(models, "classification")
|
||||
assert path.name == "implicit_classification_grid.npz"
|
||||
with np.load(path) as grid, np.load(models / "dtm_classification.npz") as dtm:
|
||||
assert np.array_equal(grid["x"], dtm["x"]) and np.array_equal(grid["y"], dtm["y"])
|
||||
assert np.array_equal(grid["valid_mask"], dtm["valid_mask"])
|
||||
z, valid = grid["z"], grid["valid_mask"]
|
||||
assert not np.isnan(z).any()
|
||||
assert np.array_equal(z[~valid], dtm["z"][~valid]) # 밖 = dtm 값
|
||||
rows, cols = np.nonzero(valid)
|
||||
query = np.column_stack([grid["x"][cols], grid["y"][rows]]).astype(np.float64)
|
||||
rbf = implicit_rbf(models / "implicit_classification.npz")
|
||||
assert np.abs(rbf(query) - z[rows, cols]).max() < 1e-4 # 유효 칸 = RBF(float32)
|
||||
|
||||
|
||||
def test_옛것이면_다시_셈_같으면_그대로(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
path = implicit_grid_npz(models, "classification")
|
||||
stamp = path.stat().st_mtime_ns
|
||||
assert implicit_grid_npz(models, "classification").stat().st_mtime_ns == stamp
|
||||
source = models / "implicit_classification.npz"
|
||||
os.utime(source, ns=(stamp + 10**9, stamp + 10**9))
|
||||
assert implicit_grid_npz(models, "classification").stat().st_mtime_ns > stamp
|
||||
|
||||
|
||||
def test_서버_Node_B06_브라우저_같은_파일(tmp_path, monkeypatch):
|
||||
models = _project(tmp_path)
|
||||
sampler = build_surface_sampler(models, "classification", "implicit", False)
|
||||
grid_npz = models / "implicit_classification_grid.npz"
|
||||
assert isinstance(sampler, DtmGridSampler)
|
||||
assert Path(sampler.source) == grid_npz.resolve() # Node 측점 = dtm_grid_file(source)
|
||||
|
||||
# 서버 표본 = 굳힌 격자 표본(같은 측점 같은 표고)
|
||||
points = np.array([[10.3, 12.7], [20.0, 20.0], [1.0, 1.0]])
|
||||
z, valid = sampler.sample_xy(points)
|
||||
z2, valid2 = DtmGridSampler.from_npz(grid_npz).sample_xy(points)
|
||||
assert np.array_equal(valid, valid2) and np.allclose(z[valid], z2[valid2], atol=0)
|
||||
assert valid.tolist() == [True, True, False]
|
||||
|
||||
# B06 서버 재계산 격자 = 같은 npz 의 `.bin`
|
||||
bin_path = prebuild.design_grid_file(tmp_path, IMPLICIT)
|
||||
assert bin_path is not None and bin_path.read_bytes() == dtm_grid_bytes(grid_npz)
|
||||
|
||||
# 브라우저 격자 통로 — implicit 모델 행 → 굳힌 격자
|
||||
row = ("implicit", "B04_PreProcess/models/implicit_classification.npz")
|
||||
|
||||
class _Cursor:
|
||||
async def execute(self, *_args):
|
||||
return None
|
||||
|
||||
async def fetchone(self):
|
||||
return row
|
||||
|
||||
class _Connection:
|
||||
@asynccontextmanager
|
||||
async def cursor(self):
|
||||
yield _Cursor()
|
||||
|
||||
async def stored(_connection, _project_id):
|
||||
return "x"
|
||||
|
||||
monkeypatch.setattr(terrain_router, "get_project_storage_relative_path", stored)
|
||||
monkeypatch.setattr(terrain_router, "resolve_stored_project_path", lambda _s: tmp_path)
|
||||
served = asyncio.run(terrain_router.dtm_grid_npz(_Connection(), "p", 9, False))
|
||||
assert served.resolve() == grid_npz.resolve()
|
||||
|
||||
info = terrain_router._design_grid(
|
||||
IMPLICIT, models, (9, "implicit", "B04_PreProcess/models/implicit_classification.npz")
|
||||
)
|
||||
assert info is not None and info["model_id"] == 9 and info["smooth"] is False
|
||||
assert (
|
||||
terrain_router._design_grid(IMPLICIT, models, (3, "dtm", "x/dtm_classification.npz"))
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_D03_B07_implicit_확정이면_평활_DTM(tmp_path):
|
||||
models = _project(tmp_path)
|
||||
assert road_dem(models, IMPLICIT, "design")[1].name == "dtm_classification_smooth.npz"
|
||||
dtm = {**IMPLICIT, "method": "dtm"}
|
||||
assert road_dem(models, dtm, "design")[1].name == "dtm_classification.npz" # 전과 같음
|
||||
assert road_dem(models, {**dtm, "smooth": True}, "design")[1].name.endswith("_smooth.npz")
|
||||
|
||||
|
||||
def test_B07_음영_implicit_확정이면_평활_DTM(tmp_path, monkeypatch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
from B07_DesignDetail import B07_DesignDetail_Router_Support_Basin as basin
|
||||
|
||||
_project(tmp_path)
|
||||
opened: list[str] = []
|
||||
real_load = np.load
|
||||
monkeypatch.setattr(
|
||||
np, "load", lambda path, **kw: opened.append(Path(path).name) or real_load(path, **kw)
|
||||
)
|
||||
box = (0.0, 0.0, 39.0, 39.0)
|
||||
for params, name in (
|
||||
(IMPLICIT, "dtm_classification_smooth.npz"),
|
||||
({**IMPLICIT, "method": "dtm"}, "dtm_classification.npz"), # 전과 같음
|
||||
):
|
||||
basin._hillshade_for_box(SimpleNamespace(surface_params=params, project_root=tmp_path), box)
|
||||
assert opened[-1] == name
|
||||
@@ -0,0 +1,76 @@
|
||||
"""M02 폴더 · 층 · 단가산출 이름(PLAN 720 · 내역 16) — 임시 storage + 화면 글 대조.
|
||||
|
||||
720-1 단가산출 · 일위대가 층 단추 = 저장 안 한 고침 물음 → 그 층 목록표 ·
|
||||
720-2 최상위 폴더 = 메뉴 줄 · 층 줄 「+」 같은 `addFolder([])` ·
|
||||
720-3 문서 [이름 바꾸기] = 읽고 `이름` 만 갈아 저장(층 목록 제목 새 이름 · 다른 칸 그대로) ·
|
||||
720-4 구조물 조합 층 = 제자리 바꿈(해시 이동 없음).
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from resources.tester.test_m02_precombo_folders import client # noqa: F401 — 같은 임시 층
|
||||
|
||||
M02 = Path(__file__).resolve().parents[2] / "M02_MasterTemplete"
|
||||
ITEM = "/api/m02/layers/personal/templates/{}/{}"
|
||||
|
||||
|
||||
def _text(name: str) -> str:
|
||||
return (M02 / name).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _rows(c: TestClient, kind: str) -> dict[str, dict]:
|
||||
rows = c.get("/api/m02/layers/personal/templates").json()["양식"]
|
||||
return {r["이름"]: r for r in rows if r["종류"] == kind}
|
||||
|
||||
|
||||
def _rename(c: TestClient, kind: str, key: str, name: str) -> None:
|
||||
"""화면 `renameDoc` 과 같은 길 — 읽고 `이름` 만 갈아 같은 판으로 저장"""
|
||||
got = c.get(ITEM.format(kind, key)).json()
|
||||
body = {"판": got.get("판") or "", "문서": {**got["문서"], "이름": name}}
|
||||
res = c.put(ITEM.format(kind, key), json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
|
||||
|
||||
def test_일위대가_이름_바꾸기는_이름만(client: TestClient): # noqa: F811
|
||||
before = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
_rename(client, "precombo", "SC-000002", "나 고침")
|
||||
after = client.get(ITEM.format("precombo", "SC-000002")).json()["문서"]
|
||||
assert after["이름"] == "나 고침"
|
||||
assert {k: v for k, v in after.items() if k != "이름"} == {
|
||||
k: v for k, v in before.items() if k != "이름"
|
||||
}
|
||||
rows = _rows(client, "precombo")
|
||||
assert rows["SC-000002"]["제목"] == "나 고침" and rows["SC-000001"]["제목"] == "가"
|
||||
|
||||
|
||||
def test_단가산출_이름_바꾸기_목록_제목(client: TestClient): # noqa: F811
|
||||
body = {"문서": {"이름": "갑", "단위": "㎡", "바탕": None, "더한줄": []}}
|
||||
res = client.post("/api/m02/layers/personal/library/sangeun", json=body)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["이름"]
|
||||
_rename(client, "sangeun", key, "을")
|
||||
assert _rows(client, "sangeun")[key]["제목"] == "을"
|
||||
assert client.get(ITEM.format("sangeun", key)).json()["문서"]["단위"] == "㎡"
|
||||
|
||||
|
||||
def test_화면_층_단추_최상위_폴더_이름_바꾸기():
|
||||
page = _text("M02_MasterTemplete_UI_Page.ts")
|
||||
lib = _text("M02_MasterTemplete_UI_Side_Library.ts")
|
||||
# 720-1 — 다른 층 단추 = 물음 뒤 목록표
|
||||
assert "setPre(next), void main.open(precomboList(pre))" in page
|
||||
assert "void main.open(sangeunList(sk))" in page.split("const skBar")[1].split("reselect:")[0]
|
||||
# 720-2 — 층 줄 「+」 = 서고 칸 맨 위 메뉴 · 메뉴 줄 = 같은 addFolder([])
|
||||
assert page.count(" add,\n") == 2
|
||||
assert "onClick: (event) => openTreeMenu(event, menuAt([]))" in lib
|
||||
assert "add.hidden = !at.canWrite;" in lib and "opt.bar(add)" in lib
|
||||
assert lib.count('L("M02_TreeAddTopFolder"), () => void addFolder([])') == 1 # 폴더 줄
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([])' in lib # 문서 줄
|
||||
tree = _text("M02_MasterTemplete_TreePanel.ts")
|
||||
assert '{ label: L("M02_TreeAddTopFolder"), run: () => void addFolder([]) }' in tree
|
||||
# 720-3 — 문서 줄 [이름 바꾸기] · 저장 뒤 머리 제목
|
||||
assert "run: () => void renameDoc(row)" in lib
|
||||
assert "이름: name }" in lib and "saveTemplate(at.layer, kind, row.이름, null" in lib
|
||||
main = _text("M02_MasterTemplete_UI_Main.ts")
|
||||
assert "title.textContent = at.label = named ? `${at.name} ${named}` : at.name;" in main
|
||||
@@ -0,0 +1,113 @@
|
||||
"""722 M01 단가산출 상세 개편 지킴 시험 — 글 · 순수 함수(화면 · 서버 없음).
|
||||
|
||||
- 722-1 공용 단가산출 상세(`M01_MasterData_UI_LogicLab_Ho.ts`) — 하위 박스(서버 묶음) · 장비 한 줄 + 펼침 · 계산식 열 · 박스 옵션
|
||||
- 722-2 그 자리 고침(`M01_MasterData_UI_LogicLab_Cover.ts`) — M01 = 시험 값(줄 사본) · 정본 · 캐시 안 바뀜
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
M01 = ROOT / "M01_MasterData"
|
||||
HO = M01 / "M01_MasterData_UI_LogicLab_Ho.ts"
|
||||
COVER = M01 / "M01_MasterData_UI_LogicLab_Cover.ts"
|
||||
|
||||
|
||||
def _text(p: Path) -> str:
|
||||
return p.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _pure(p: Path) -> str:
|
||||
s = _text(p)
|
||||
return s[s.index("/* ── 순수 셈 ── */") : s.index("/* ── 순수 셈 끝 ── */")]
|
||||
|
||||
|
||||
def _node(code: str):
|
||||
out = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", code],
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert out.returncode == 0, out.stderr
|
||||
return json.loads(out.stdout)
|
||||
|
||||
|
||||
def test_상세가_공용_부품을_씀():
|
||||
detail = _text(M01 / "M01_MasterData_UI_LogicLab_Detail.ts")
|
||||
assert "buildHo({ ...ctx, edit: trialEdit(ctx) })" in detail
|
||||
ho = _text(HO)
|
||||
# 부품 상자만 — 표 · 카드 · 계산 줄 · 변수 칸(calc_sheet) · 입력 칩(텍스트 수식)
|
||||
for part in (
|
||||
"tableShell(",
|
||||
"tableRow(",
|
||||
"createCard(",
|
||||
"varRow(",
|
||||
"inputChip(",
|
||||
"sheetFormula(",
|
||||
):
|
||||
assert part in ho, part
|
||||
for s in (ho, _text(COVER)):
|
||||
assert 'el("table"' not in s and 'el("button"' not in s and 'el("input"' not in s
|
||||
assert "document.createElement" not in s and "sessionStorage" not in s
|
||||
assert "export function varRow(" in _text(ROOT / "A00_Common" / "calc_sheet.ts")
|
||||
for p in (HO, COVER, M01 / "M01_MasterData_UI_LogicLab.ts"):
|
||||
assert len(_text(p).splitlines()) <= (
|
||||
627 if p.name == "M01_MasterData_UI_LogicLab.ts" else 700
|
||||
)
|
||||
|
||||
|
||||
def test_시험_고침은_캐시_밖():
|
||||
lab = _text(M01 / "M01_MasterData_UI_LogicLab.ts")
|
||||
# 시험 계산에만 덮은 사본 · 고침이 있으면 줄을 보냄 · drafts(캐시)에는 원본 줄
|
||||
assert ": trial.apply(row);" in lab
|
||||
assert "dirty: () => current.id in drafts || trial.has(current.row)," in lab
|
||||
assert "onTrial: drawCalc," in lab
|
||||
cover = _text(COVER)
|
||||
assert "drafts" not in cover and "touch" not in cover and "/save" not in cover
|
||||
modal = _text(M01 / "M01_MasterData_UI_LogicLab_Modal.ts")
|
||||
assert "...(target.edit ? [target.edit(close)] : [])" in modal
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_박스_묶기():
|
||||
body = """
|
||||
const e = (idx, bundle, merge, amount) => ({ idx, bundle, merge, amount });
|
||||
const got = boxesOf([
|
||||
e(0, "인력", false, 10), e(1, "경비", true, 100), e(1, "경비", true, 20), e(1, "경비", true, 30),
|
||||
e(2, "하위", true, 5), e(2, "하위", true, null), e(3, "재료", false, null),
|
||||
], ["인력", "재료", "경비"]).map((b) => ({ 묶음: b.묶음, 소계: b.소계,
|
||||
줄: b.줄.map((r) => "몫" in r ? [r.idx, r.금액, r.몫.length] : r.idx) }));
|
||||
console.log(JSON.stringify(got));
|
||||
"""
|
||||
got = _node(_pure(HO) + "\n" + body)
|
||||
assert [b["묶음"] for b in got] == ["인력", "재료", "경비", "하위"]
|
||||
assert got[0] == {"묶음": "인력", "소계": 10, "줄": [0]}
|
||||
assert got[1] == {"묶음": "재료", "소계": 0, "줄": [3]}
|
||||
assert got[2] == {"묶음": "경비", "소계": 150, "줄": [[1, 150, 3]]} # 장비 한 줄 = 몫 셋 합
|
||||
assert got[3]["줄"] == [[2, 5, 2]]
|
||||
empty = _node(_pure(HO) + '\nconsole.log(JSON.stringify(boxesOf([], ["인력"])))')
|
||||
assert empty == [{"묶음": "인력", "줄": [], "소계": 0}]
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
|
||||
def test_고침_덮기():
|
||||
body = """
|
||||
const row = { 키: "GF1", 호표: [
|
||||
{ 종류: "인력", 요소: "LB1", 수량: "찾기(Q).수량" },
|
||||
{ 종류: "로직", 요소: "로직(GC1)", 수량: "1 / 작업량", 값묶음: { 경비: 3 } },
|
||||
{ 종류: "재료", 요소: "MT1", 수량: "2" } ], 덧줄: [{ 이름: "공구", 식: "노무비 * 3 / 100" }] };
|
||||
const out = applyCovers(row, { 0: { qty: 0.0000001, labor: 100000 }, 1: { labor: 50000 }, 9: { qty: 1 } });
|
||||
console.log(JSON.stringify({ out, same: row.호표[0].수량, keep: out.호표[2] === row.호표[2] }));
|
||||
"""
|
||||
got = _node(_pure(COVER) + "\n" + body)
|
||||
out = got["out"]
|
||||
assert (
|
||||
out["호표"][0]["수량"] == "0.0000001" and out["호표"][0]["단가"] == 100000
|
||||
) # 지수 꼴 없음 · 손 단가
|
||||
assert out["호표"][1]["값묶음"] == {"경비": 3, "노무비": 50000} and "단가" not in out["호표"][1]
|
||||
assert got["keep"] and got["same"] == "찾기(Q).수량" # 원본 안 바뀜 · 안 고친 줄 그대로
|
||||
assert out["덧줄"] == [{"이름": "공구", "식": "노무비 * 3 / 100"}]
|
||||
@@ -296,9 +296,10 @@ def test_켜진_층_단추는_목록으로_복사_끌기():
|
||||
assert "(to === layer && toOwner === here)) return [];" in page
|
||||
copy_to = _text(M02 / "M02_MasterTemplete_TreePanel_CopyTo.ts")
|
||||
assert "listFolders(buildFolders(view))" in copy_to and ".catch(() => null)" in copy_to
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, list.length);" in _text(
|
||||
assert "const dest = await askCopyFolder(api.fetch, to, clip.refs.length);" in copy_to
|
||||
assert "void copyToLayer({ fetch: api.fetch, paste: paste_ }, clip, to);" in _text(
|
||||
M02 / "M02_MasterTemplete_TreePanel.ts"
|
||||
)
|
||||
) # 720-2 — 몸은 CopyTo 로 뗌(TreePanel 700 줄)
|
||||
panel = _text(M02 / "M02_MasterTemplete_TreePanel.ts")
|
||||
assert "if (opt.canEdit) drag.arm(button, one.id);" in panel
|
||||
drag = _text(M02 / "M02_MasterTemplete_TreePanel_Drag.ts")
|
||||
|
||||
@@ -72,8 +72,12 @@ def test_사전조합_층_단추는_페이지를_다시_안_세움():
|
||||
assert "history.replaceState(" in pre and "side.refreshPrecombo()" in pre
|
||||
assert "navigateTo" not in pre and "setActiveLayer" not in pre # 페이지 층 · 해시 이동 없음
|
||||
assert "entry.pre = pre.root;" in pre # 구조물 조합 층 이동(go) 때도 주소에 남음
|
||||
bar = page.split("const preBar = (): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
assert "go: (next) => setPre(next)," in bar and "main.open(precomboList(pre))" in bar
|
||||
bar = page.split("const preBar = (add: HTMLElement): HTMLElement =>")[1].split("const side = buildSide(")[0]
|
||||
# 720-1 — 다른 층 = 저장 안 한 고침 물음 → 그 층 나무 + 목록표
|
||||
go = bar.split("go: (next) =>")[1].split("reselect:")[0]
|
||||
assert ".confirmLeave()" in go and "setPre(next), void main.open(precomboList(pre))" in go
|
||||
sk = page.split("const skBar = (")[1].split("const side = buildSide(")[0]
|
||||
assert ".confirmLeave()" in sk and "void main.open(sangeunList(sk))" in sk.split("reselect:")[0]
|
||||
# 구조물 조합 층 단추는 그대로 해시 이동 한 벌
|
||||
assert page.count('layerBar("') == 1
|
||||
assert 'layerBar("구조물조합", tree.add, () => void main.openTable(layer)),' in page
|
||||
|
||||
@@ -107,6 +107,7 @@ export const ui_locales_m2 = {
|
||||
M02_TreeAddTop: ["맨 위에 폴더 · 항목 추가", "Add folder or item at top"],
|
||||
M02_TreeEmpty: ["항목이 없음", "No items"],
|
||||
M02_TreeAddFolder: ["폴더 추가", "Add folder"],
|
||||
M02_TreeAddTopFolder: ["최상위 폴더 만들기", "New top-level folder"],
|
||||
M02_TreeAddItem: ["항목 추가", "Add item"],
|
||||
M02_TreeRename: ["이름 바꾸기", "Rename"],
|
||||
M02_TreeMove: ["옮기기", "Move"],
|
||||
|
||||
Reference in New Issue
Block a user