diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index b846d9e9..4fc9e802 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -15,7 +15,8 @@ import { requestJson } from "./B05_Profile_Api_Replan"; const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; /** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */ -export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; +export type RouteInitialLogic = + "follow" | "gradeline" | "earthwork" | "gentle" | "safe" | "crossings" | "cost" | "weighted"; /** 비중 계산의 세 비중(%) — 합 100. */ export interface RouteInitialWeights { @@ -31,6 +32,8 @@ export interface RouteInitialCriteria { min_radius_m?: number; stream_offset_m?: number; grade_smooth_m?: number; + /** 영선 노선(`gradeline`)의 목표 종단기울기(%) — 비우면 종단기울기 상한. */ + target_grade_pct?: number; } export interface RouteInitialRequest { diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial.py b/B05_Profile/B05_Profile_Engine_RouteInitial.py index 55570d39..58cda112 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial.py @@ -1,4 +1,7 @@ -"""초기 계획노선 갈래 — 예상노선 추종 · 토공 최소 · 기울기 순한 노선 · 비중(PLAN 20 · 23장). +"""초기 계획노선 갈래(PLAN 20 · 23장). + +예상노선 추종 · 영선 노선 · 토공 최소 · 기울기 순한 · 안전 우선 · 계류 횡단 최소 · +공사비 어림 최소 · 비중. 계산 자리 = 서버 단독(③). 결과는 화면 캐시용 — 이 모듈은 파일을 쓰지 않는다. 적용은 화면이 받은 노드로 기존 `POST /route/replan`([확인])을 부른다. @@ -8,13 +11,21 @@ 제약 탐색은 하지 않고, 넘은 곳은 `metrics.violations` 에 뜬다. · earthwork — 후보 중 절 · 성토 합(`cut_m3 + fill_m3`) 최소. · gentle — 후보 중 최대 종단기울기 최소(같으면 평균 최소). - · weighted — 후보 중 예상노선 이격 · 절성토 · 기울기(최대, 같으면 평균) 세 값을 후보 - 범위로 0~1 정규화해 비중(합 100)으로 더한 값 최소. 끝값은 그 갈래와 같은 선 — - 토공 100 = 토공 최소 · 기울기 100 = 기울기 순한 · 예상 100 = 추적 선 그대로(23-3e). + · gradeline — 영선 노선: 목표 기울기(`target_grade` · 없으면 상한)로 등고선을 건너가는 + 등경사선(PEGGER 식) · 후보 중 목표 기울기 편차(`grade_dev_pct`) 최소(PLAN 23-5). + · safe — 안전 우선: 후보 중 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) 최소. + · crossings — 계류 횡단 최소: 후보 중 계류 횡단 수 최소 · 같으면 계류에서 먼 쪽. + · cost — 공사비 어림 최소: 후보 중 상대 공사비(토공 + 계류 횡단 구조물 + 연장 · 임시 상대 + 단가 `..._Metrics.COST_*`) 최소. + · weighted — 탐색 비용의 가중합 한 번(후보 고르기 아님 · PLAN 23-4a · `..._Pool.weighted`). + 끝값은 그 갈래와 같은 선 — 예상 100 = 추적 · 토공 100 = 토공 최소 · 기울기 100 = + 기울기 순한. 제약은 (1 − 예상 몫)만큼 벌점으로만 따지고 넘은 곳은 목록에 뜸. -후보 = 목표 비중(토공 · 기울기 · 이격 · 기본 비중)을 바꿔 가며 격자 탐색(`..._Search`)한 노선들. -갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 비교값 -(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +후보 묶음(`..._Pool.build_pool`) = 목표를 바꿔 가며 격자 탐색(`..._Search`)한 노선들 + 토공 ↔ 기울기 +비중 쓸기. 갈래는 **비교값(화면에 보이는 값)으로 고른다** — 탐색 목표(4 m 링크 기울기 · 옆경사)는 +비교값(측점 20 m 기울기 · 절성토 가늠)과 어긋나 탐색 한 벌로는 갈래 순서가 안 맞는다(PLAN 23-1). +가파른 지형(옆경사 중앙값 67 %)에서는 제약 안의 길이 폭 10~20 m 한 띠뿐이라 갈래끼리 가까울 수 +있다 — 응답 `similar`(10 m 안인 다른 갈래) · `band_width_m`(띠 폭 어림)로 알린다(PLAN 23-4). 공통 제약(follow 밖 모든 갈래 · **넘지 못함**) — 종단기울기 상한 · 최소 곡선반지름 · 계류 이격: · 종단기울기 — 지반 기울기가 상한을 넘는 링크는 탐색에서 뺌. 곡선 맞춤 뒤 측점(20 m) 사이 @@ -46,6 +57,13 @@ from typing import Any import numpy as np from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, route_metrics +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import ( + Pool, + build_pool, + pick, + similar, + weighted, +) from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( CORRIDOR_M, ROAD_WIDTH_M, @@ -70,24 +88,21 @@ STREAM_CLASSES = ("소하천", "지방하천", "국가하천") PENDING: tuple[str, ...] = () # ── 탐색 · 측정 값 ─────────────────────────────────────────────────────────────── -MODES = ("follow", "earthwork", "gentle", "weighted") +MODES = ("follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted") DEFAULT_WEIGHTS = {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} MODE_WEIGHTS = { "follow": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}, "earthwork": {"expected": 0.0, "earthwork": 100.0, "grade": 0.0}, "gentle": {"expected": 0.0, "earthwork": 0.0, "grade": 100.0}, + # 비중 몫이 없는 갈래(PLAN 23-5) — 제 목표로 찾은 후보에서 비교값으로 고름 + **{ + mode: {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} + for mode in ("gradeline", "safe", "crossings", "cost") + }, } -# 후보를 만들 탐색 비중 — 목표 하나씩 -CANDIDATE_WEIGHTS = (MODE_WEIGHTS["earthwork"], MODE_WEIGHTS["gentle"], MODE_WEIGHTS["follow"]) -# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 -GRADE_CAP_STEPS = (1.0, 0.93, 0.85) -# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 -# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) -SOFT_GRADE_COST = 500.0 -RELAXED_GRADE_COSTS = (50.0, 5.0) # 후보 묶음은 갈래와 무관 — 같은 입력이면 다시 짜지 않음(최근 몇 벌만 · 프로세스 메모리) POOL_CACHE_SIZE = 4 -_POOLS: dict[bytes, tuple[list[tuple[Any, dict[str, Any]]], float | None]] = {} +_POOLS: dict[bytes, Pool] = {} def load_terrain_and_streams( @@ -200,12 +215,13 @@ def generate_initial_route( min_radius_m: float, stream_offset_m: float | None = None, grade_smooth_m: float | None = None, + target_grade: float | None = None, ) -> dict[str, Any]: """갈래 하나로 초기 계획노선을 만들고 비교값을 붙여 돌려준다. `max_grade` 는 비율(0.14). - follow 밖 갈래는 후보(`_candidates`) 중 제 목표 비교값이 가장 좋은 것을 고른다. - `stream_offset_m`·`grade_smooth_m` 을 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 - 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). + follow 는 전처리 초기값 규칙 그대로 · weighted 는 비중 탐색 한 번(끝값은 그 갈래) · 나머지는 + 후보 묶음(`..._Pool`)에서 제 목표 비교값이 가장 좋은 것. `stream_offset_m`·`grade_smooth_m` 을 + 안 주면(None) 이 머리의 기본값을 쓴다 — 화면에서 프로젝트마다 겹쳐 쓸 수 있다(PLAN 20장 확정). """ if mode not in MODES: raise ValueError(f"모르는 갈래: {mode}") @@ -214,8 +230,12 @@ def generate_initial_route( used = resolve_weights(mode, weights) stream_offset = STREAM_OFFSET_M if stream_offset_m is None else float(stream_offset_m) grade_smooth = GRADE_SMOOTH_M if grade_smooth_m is None else float(grade_smooth_m) + # 영선 노선 목표 기울기(비율) — 없으면 상한 · 상한 넘게 넣어도 상한(제약 안에서만) + goal_grade = max_grade if target_grade is None else min(float(target_grade), max_grade) - def measure(outline: Any, bound: float | None, follow: bool = False) -> dict[str, Any]: + def measure( + outline: Any, *, bound: float | None, follow: bool = False, weighted: bool = False + ) -> dict[str, Any]: return route_metrics( outline.vertices, expected, @@ -229,22 +249,26 @@ def generate_initial_route( bound_pct=None if bound is None else bound * 100.0, grade_smooth_m=grade_smooth, follow=follow, + weighted=weighted, + target_grade=goal_grade, ) def follow_line() -> tuple[Any, dict[str, Any]]: # 예상노선 추적 = 전처리 초기값(`planned_route_initial.csv`)과 같은 규칙 · 같은 입력 → # 같은 선(`_write_planned_polyline` 기본값). 제약은 따지지 않고 넘은 곳만 목록에 뜸. outline = build_planned_polyline(expected, min_radius_m=min_radius_m) - return outline, measure(outline, None, follow=True) + return outline, measure(outline, bound=None, follow=True) bound = None - # 비중 예상 100 = 예상노선 추적 그 선(PLAN 23-3e) — 사이 비중은 제약 지킨 후보에서만 고름 - if mode == "follow" or (mode == "weighted" and used["earthwork"] == used["grade"] == 0): + pool: Pool | None = None + target = _weighted_end(used) if mode == "weighted" else mode + key = _pool_key( + expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth, goal_grade + ) + if target == "follow": # 비중 예상 100 = 추적 그 선(PLAN 23-3e) · 묶음은 짜지 않음(빠른 길) outline, metrics = follow_line() + pool = _POOLS.get(key) else: - key = _pool_key( - expected, terrain, streams, max_grade, min_radius_m, stream_offset, grade_smooth - ) if key not in _POOLS: grid = SearchGrid( expected, @@ -252,13 +276,18 @@ def generate_initial_route( streams, stream_offset_m=stream_offset, grade_smooth_m=grade_smooth, + target_grade=goal_grade, ) graph = RouteGraph(grid, expected, limit=max_grade, min_radius_m=min_radius_m) while len(_POOLS) >= POOL_CACHE_SIZE: _POOLS.pop(next(iter(_POOLS))) - _POOLS[key] = _candidates(graph, max_grade, min_radius_m, measure) - candidates, bound = _POOLS[key] - outline, metrics = _pick(mode, used, candidates) + _POOLS[key] = build_pool(graph, follow_line(), max_grade, min_radius_m, measure) + pool = _POOLS[key] + bound = pool.bound + if target == "weighted": + outline, metrics = weighted(pool, used, min_radius_m, measure) + else: + outline, metrics = pick(target, pool.candidates) return { "mode": mode, "logic": mode, @@ -270,6 +299,9 @@ def generate_initial_route( "nodes": [node.as_dict() for node in outline.nodes], "curves": [curve.as_dict() for curve in outline.curves], "metrics": metrics, + # 10 m 안인 다른 갈래(같은 노선 알림) · 제약 안 띠 폭 어림 — 추적은 묶음이 이미 있을 때만 + "similar": None if pool is None else similar(pool, target, outline), + "band_width_m": None if pool is None else round(pool.band_width_m, 1), "criteria": { "max_grade_pct": round(max_grade * 100.0, 2), "min_radius_m": round(min_radius_m, 2), @@ -277,63 +309,19 @@ def generate_initial_route( "road_width_m": ROAD_WIDTH_M, "corridor_m": CORRIDOR_M, "grade_smooth_m": round(grade_smooth, 2), + "target_grade_pct": round(goal_grade * 100.0, 2), # 기준값 확정(PLAN 20장, 2026-09-29) — 더는 후보가 아니다. 항상 빈 목록. "pending": list(PENDING), }, } -def _candidates( - graph: RouteGraph, - max_grade: float, - min_radius_m: float, - measure: Any, -) -> tuple[list[tuple[Any, dict[str, Any]]], float | None]: - """갈래가 고를 후보 노선들(폴리라인 · 비교값)과 지형상 불가능 때 막은 기울기(아니면 None). - - 제약을 지키는 길이 있으면 비중마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · - 없으면 막은 기울기 안에서 벌점 크기 · 비중을 바꿔 찾은 노선들. 못 지킨 곳 수가 기준 - (엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. - """ - - def trace(nodes: list[tuple[float, float]], bound: float | None) -> tuple[Any, dict]: - # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선 - outline = build_planned_polyline( - nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) - ) - return outline, measure(outline, bound) - - found: list[tuple[Any, dict[str, Any]]] = [] - for weights in CANDIDATE_WEIGHTS: - for step in GRADE_CAP_STEPS: - try: - nodes = graph.solve(weights, cap=max_grade * step, strict_stream=True) - except NoRoute: - break - found.append(trace(nodes, None)) - if found[-1][1]["over_limit"]["total"] == 0: - break - if not found: # 제약을 지키는 길 없음 — 비중과 무관하니 더 볼 것 없음 - break - if found: - least = min(item[1]["over_limit"]["total"] for item in found) - return [item for item in found if item[1]["over_limit"]["total"] == least], None - - # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 - # 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다. - bound = graph.terrain_bound() - cap = bound + 1e-9 - seed = trace( - graph.solve(DEFAULT_WEIGHTS, cap=cap, strict_stream=False, grade_cost=SOFT_GRADE_COST), - bound, - ) - found = [seed] - for weights in CANDIDATE_WEIGHTS: - for cost in RELAXED_GRADE_COSTS: - nodes = graph.solve(weights, cap=cap, strict_stream=False, grade_cost=cost) - found.append(trace(nodes, bound)) - most = seed[1]["over_limit"]["total"] - return [item for item in found if item[1]["over_limit"]["total"] <= most], bound +def _weighted_end(weights: dict[str, float]) -> str: + """비중이 한쪽 100 이면 그 갈래(끝값 = 갈래와 같은 선 · PLAN 23-3e) · 아니면 비중 탐색.""" + for name, mode in (("expected", "follow"), ("earthwork", "earthwork"), ("grade", "gentle")): + if all(value == 0 for key, value in weights.items() if key != name): + return mode + return "weighted" def _pool_key( @@ -348,34 +336,3 @@ def _pool_key( digest.update(np.ascontiguousarray(array).tobytes()) digest.update(repr((streams, numbers)).encode()) return digest.digest() - - -def _pick( - mode: str, weights: dict[str, float], candidates: list[tuple[Any, dict[str, Any]]] -) -> tuple[Any, dict[str, Any]]: - """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" - - def earth(metrics: dict[str, Any]) -> float: - return metrics["cut_m3"] + metrics["fill_m3"] - - def grade(metrics: dict[str, Any]) -> float: - # 최대 먼저 · 평균은 같을 때만(기울기 순한과 같은 순서 — 값은 0.01 단위라 평균 몫 < 0.01) - return metrics["max_grade_pct"] + 1e-4 * metrics["avg_grade_pct"] - - def offset(metrics: dict[str, Any]) -> float: - return metrics["offset_avg_m"] - - if mode == "earthwork": - return min(candidates, key=lambda item: earth(item[1])) - if mode == "gentle": - return min( - candidates, key=lambda item: (item[1]["max_grade_pct"], item[1]["avg_grade_pct"]) - ) - # 비중 — 세 값을 후보 범위로 0~1 로 맞춘 뒤 비중으로 더함(단위가 다른 값을 겨루게) - parts = [] - for key, value in (("expected", offset), ("earthwork", earth), ("grade", grade)): - values = [value(item[1]) for item in candidates] - low, span = min(values), max(values) - min(values) - parts.append([weights[key] * ((v - low) / span if span > 1e-9 else 0.0) for v in values]) - scores = [sum(column) for column in zip(*parts)] - return candidates[scores.index(min(scores))] diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py index bc5070b0..9c74a811 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Metrics.py @@ -25,6 +25,17 @@ SAMPLE_M = 2.0 # 측정 간격(m) GRADE_SMOOTH_M = 5.0 STATION_M = 20.0 # 종단기울기 측정 간격(m) — 측점 체계 · 타당성평가 20 m 간격 GRADE_EPS = 1e-6 # 상한과 같은 기울기는 지킨 것으로 봄 +# 안전 우선 — 옆경사가 이보다 급한 곳을 위험으로 봄(35° = 70 % · 지식DB 「35° 이상 전폭 절토」 +# `산림과임업기술(임도)/2. 임도/4. 노선측량.md`) +SAFE_SIDE_SLOPE = math.tan(math.radians(35.0)) +# 공사비 어림 상대 단가(절성토 1 ㎥ = 1) — ⚠ 임시 상대값 · 브레인 임의 판정(PLAN 23-5 · 지식DB +# 근거가 붙으면 바꿈). 연장 1 m = 벌개 · 노반 · 측구 등 절성토 밖 공종을 흙 2 ㎥ 로 어림(이 현장 +# 절성토가 1 m 당 3~4 ㎥ 라 두 몫이 같은 자릿수) · 계류 횡단 1 곳 = 횡단 배수 구조물(관 · +# 기슭막이) 400 ㎥. +COST_EARTH_M3 = 1.0 +COST_LENGTH_M = 2.0 +COST_CROSSING = 400.0 +CROSSING_MERGE_M = 10.0 # 이 거리 안의 계류 교점은 한 횡단(갈라진 도엽 선 이음매) REASON_TERRAIN = "지형상 불가능 — 복도 안 어떤 길도 {bound:.1f}% 이상 기울기를 지나야 이어짐" REASON_GRADE_FIT = "곡선 맞춤 뒤 지반 차 — 측점 사이 지반이 상한을 넘음" @@ -33,6 +44,8 @@ REASON_STREAM = "계류 버퍼 안 — 건너기 · 기점 · 종점 봐줌을 REASON_FOLLOW_GRADE = "예상노선 추적 — 예상노선이 지나는 지반이 상한을 넘음(제약 탐색 안 함)" REASON_FOLLOW_STREAM = "예상노선 추적 — 예상노선이 계류 버퍼 안을 지남(제약 탐색 안 함)" REASON_STREAM_TERRAIN = "지형상 불가능 — 기울기 · 반지름을 지키며 계류 버퍼를 비켜 잇는 길 없음" +REASON_WEIGHTED_GRADE = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)" +REASON_WEIGHTED_STREAM = "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(계류 버퍼 안을 지남)" def smoothed_ground(terrain: Terrain, grade_smooth_m: float = GRADE_SMOOTH_M) -> np.ndarray: @@ -113,17 +126,20 @@ def route_metrics( bound_pct: float | None, grade_smooth_m: float = GRADE_SMOOTH_M, follow: bool = False, + weighted: bool = False, + target_grade: float | None = None, ) -> dict[str, Any]: """비교값과 제약 못 지킨 구간 목록. 종단기울기 = 측점(20 m) 사이 고른 지반 기울기. `bound_pct` 는 제약을 다 지키는 길이 없을 때만 — 복도 안 어떤 길도 넘어야 하는 기울기(%). - `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) — 사유가 그 뜻으로 뜬다. + `follow` 는 예상노선 추적(제약 탐색 없이 예상노선 그대로) · `weighted` 는 비중 계산(제약을 + 벌점으로만 따짐) — 사유가 그 뜻으로 뜬다. `target_grade` = 영선 노선 목표 기울기(비율 · 없으면 + 상한) — 기울기 편차(`grade_dev_pct`)의 기준. """ grid = (terrain.y, terrain.x) sampler = RegularGridInterpolator(grid, terrain.z, bounds_error=False, fill_value=None) - smooth = RegularGridInterpolator( - grid, smoothed_ground(terrain, grade_smooth_m), bounds_error=False, fill_value=None - ) + smoothed = smoothed_ground(terrain, grade_smooth_m) + smooth = RegularGridInterpolator(grid, smoothed, bounds_error=False, fill_value=None) points, chain = _resample(vertices, SAMPLE_M) length = float(chain[-1]) lookup = points[:, ::-1] @@ -149,7 +165,13 @@ def route_metrics( float(ends[last]), float(grades[first : last + 1].max()) * 100.0, max_grade * 100.0, - REASON_FOLLOW_GRADE if follow else terrain_reason if relaxed else REASON_GRADE_FIT, + REASON_FOLLOW_GRADE + if follow + else REASON_WEIGHTED_GRADE + if weighted + else terrain_reason + if relaxed + else REASON_GRADE_FIT, ) ) @@ -171,8 +193,12 @@ def route_metrics( # 계류 이격 — 버퍼 안 구간 중 봐줄 길이를 넘는 것. 봐줌 = 건너기(계류에 닿음) 한 번의 # 버퍼 안 길이(45° 로 가로지름) + 기점 · 종점이 버퍼 안이면 45° 로 빠져나가는 길이 route_points = shapely.points(points) + near = np.full(len(points), np.inf) + crossings = 0 if streams: - near = shapely.distance(route_points, MultiLineString(streams)) + network = MultiLineString(streams) + near = shapely.distance(route_points, network) + crossings = _crossings(LineString(vertices), network) for first, last in _runs(near < stream_offset_m): ends_in = int(first == 0) + int(last == len(near) - 1) crosses = float(near[first : last + 1].min()) <= SAMPLE_M @@ -187,6 +213,8 @@ def route_metrics( stream_offset_m, REASON_FOLLOW_STREAM if follow + else REASON_WEIGHTED_STREAM + if weighted else REASON_STREAM_TERRAIN if relaxed else REASON_STREAM, @@ -211,6 +239,17 @@ def route_metrics( cut = float((np.maximum(depth, 0.0).mean(axis=1) * road_width_m * weight).sum()) fill = float((np.maximum(-depth, 0.0).mean(axis=1) * road_width_m * weight).sum()) + # 옆경사(고른 지반 · 노선에 수직) · 위험 통과 길이(옆경사 35° 이상 또는 계류 버퍼 안) + sy, sx = np.gradient(smoothed, _spacing(terrain.y), _spacing(terrain.x)) + side = np.abs( + RegularGridInterpolator(grid, sx, bounds_error=False, fill_value=None)(lookup) + * -tangent[:, 1] + + RegularGridInterpolator(grid, sy, bounds_error=False, fill_value=None)(lookup) + * tangent[:, 0] + ) + hazard = float(weight[(side >= SAFE_SIDE_SLOPE) | (near < stream_offset_m)].sum()) + target = max_grade if target_grade is None else target_grade + deviation = np.abs(grades - target) offsets = shapely.distance(route_points, LineString(expected)) counts = { kind: sum(1 for v in violations if v["kind"] == kind) @@ -228,12 +267,39 @@ def route_metrics( "raw_max_grade_pct": round(float(raw_grades.max()) * 100.0 if raw_grades.size else 0.0, 2), "cut_m3": round(cut, 1), "fill_m3": round(fill, 1), + # 흙 남음(절토 − 성토 · + = 남아 버릴 흙 · − = 모자라 들여올 흙) — 흙 균형 갈래의 목표 + "surplus_m3": round(cut - fill, 1), "over_limit": {**counts, "total": len(violations)}, "offset_avg_m": round(float(offsets.mean()), 2), "offset_max_m": round(float(offsets.max()), 2), + # PLAN 23-5 — 옆경사 · 위험 통과 길이(안전 우선) · 계류 횡단(계류 횡단 최소) · 계류 평균 + # 거리(계류 없으면 None) · 목표 기울기 편차(영선 노선 · 측점 기울기와 목표의 차 평균) · + # 공사비 어림 + "side_slope_avg_pct": round(float((side * weight).sum() / max(length, 1e-9)) * 100.0, 2), + "side_slope_max_pct": round(float(side.max()) * 100.0, 2), + "hazard_m": round(hazard, 1), + "stream_crossings": crossings, + "stream_offset_avg_m": round(float(near.mean()), 2) if streams else None, + "grade_dev_pct": round( + float((deviation * spans[keep]).sum() / spans[keep].sum()) * 100.0 + if grades.size + else 0.0, + 2, + ), + "cost_index": round( + COST_EARTH_M3 * (cut + fill) + COST_LENGTH_M * length + COST_CROSSING * crossings + ), "violations": violations, } +def _crossings(route: LineString, network: MultiLineString) -> int: + """노선이 계류를 건너는 횟수 — 교점을 노선 따라 세고 `CROSSING_MERGE_M` 안은 한 번.""" + hit = route.intersection(network) + points = [geom for geom in getattr(hit, "geoms", [hit]) if not geom.is_empty] + at = sorted(route.project(point.centroid) for point in points) + return sum(1 for i, value in enumerate(at) if i == 0 or value - at[i - 1] > CROSSING_MERGE_M) + + def _spacing(coords: np.ndarray) -> float: return float((coords[-1] - coords[0]) / (len(coords) - 1)) if len(coords) > 1 else 1.0 diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py new file mode 100644 index 00000000..9c4fdcea --- /dev/null +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Pool.py @@ -0,0 +1,285 @@ +"""초기 계획노선 갈래의 후보 묶음 · 비중 탐색(PLAN 23-1 · 23-4). + +`B05_Profile_Engine_RouteInitial` 이 부름. + +후보 묶음(`build_pool`) = 같은 입력(지형 · 예상노선 · 계류 · 제약)이면 한 번만 짠다: + · 제약 탐색 후보 — 목표 하나씩(`TARGETS` · 토공 · 기울기 · 이격 · 목표 기울기 편차 · 위험 · + 계류 횡단 · 공사비) × 탐색 상한 단계(지킬 수 있을 + 때) 또는 벌점 크기(지형상 불가능할 때 · 막은 기울기 안) — PLAN 23-1 그대로. + · 토공 ↔ 기울기 쓸기 — 비중 계산(예상 0)을 토공 100 → 기울기 100 사이 `SWEEP_STEPS` 벌. + 갈래가 그 사이 어느 비중보다 제 목표에서 좋게(토공 최소 ≤ 쓸기 절성토 · 기울기 순한 ≤ + 쓸기 최대 기울기). + · 못 지킨 곳 수가 기준(엄격: 후보 최소 · 풂: 센 벌점 기준 길)보다 많은 후보는 뺀다. + +갈래는 **비교값으로** 고른다(`pick` · `GOALS`) — 토공 최소 = 절성토 합 · 기울기 순한 = 최대(같으면 +평균) · 영선 노선 = 목표 기울기 편차 · 안전 우선 = 위험 통과 길이(옆경사 35° 이상 · 계류 버퍼 안) · +계류 횡단 최소 = 횡단 수(같으면 계류에서 먼 쪽) · 공사비 어림 최소 = 상대 공사비(PLAN 23-5). + +비중 계산(`weighted`)은 고르기가 아니라 **탐색 비용의 가중합** 한 번(PLAN 23-4a): + · 몫 = 예상노선에서 벗어난 거리 · 토공 가늠 · 기울기 — 각 몫을 끝 노선 셋(추적 · 토공 최소 · + 기울기 순한) 사이 범위로 나눠(`scale`) 비중 1 이 비슷한 크기로 움직이게. + · 제약은 막지 않는다 — 벌점 = (1 − 예상 몫) × 센 벌점(기울기 · 계류). 예상 100 이면 벌점 0 + (추적 노선도 넘는 지형) · 예상 0 이면 갈래와 같은 세기. 넘은 곳은 `violations` 에 뜬다. +""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass, field +from typing import Any + +import numpy as np +from shapely.geometry import LineString + +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( + COST_CROSSING, + COST_EARTH_M3, + COST_LENGTH_M, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Search import ( + CROSSING_BAND, + OBJECTIVES, + NoRoute, + RouteGraph, +) +from common_util.common_util_route_polyline import build_planned_polyline + +ZERO = {"expected": 0.0, "earthwork": 0.0, "grade": 0.0} +# 갈래 후보를 찾는 탐색 목표(PLAN 23-1 · 23-5) — 공사비는 상대 단가 그대로(`_solve`) +TARGETS: dict[str, dict[str, float]] = { + "earthwork": {**ZERO, "earthwork": 100.0}, + "gentle": {**ZERO, "grade": 100.0}, + "follow": {**ZERO, "expected": 100.0}, + "gradeline": {"gradeline": 100.0}, + "safe": {"risk": 100.0}, + "crossings": {"crossing": 100.0}, + "cost": {}, +} +# 탐색 상한 = 상한 × 이 값들 차례로 — 곡선 맞춤 뒤 측점 사이 지반이 넘으면 다음 값으로 다시 찾음 +GRADE_CAP_STEPS = (1.0, 0.93, 0.85) +# 모든 제약을 지키는 길이 없을 때만 쓰는 벌점(1 m 당 × 넘은 기울기 / 상한) — 기준 길(가장 적게 +# 넘긴 길)은 센 값, 갈래 후보는 목표와 겨룰 만한 값들(목표는 1 m 당 평균 1 로 정규화됨) +SOFT_GRADE_COST = 500.0 +RELAXED_GRADE_COSTS = (50.0, 5.0) +# 토공 100 → 기울기 100 쓸기 벌 수(양 끝 포함 · 10 % 걸음) +SWEEP_STEPS = 11 +# 끝 노선 사이 범위가 링크 평균의 이 몫보다 작으면(끝 노선끼리 같음) 링크 평균으로 크기를 맞춤 +MIN_RANGE_SHARE = 0.05 +# 두 노선 사이 가장 먼 거리가 이보다 작으면 「같은 노선」으로 알림(`similar` · PLAN 23-4) +SIMILAR_M = 10.0 +# 비중 계산의 제약 몫(기울기 · 계류 각각) = (1 − 예상 몫) × 이 값 — 크기 맞춘 목표 한 몫과 같은 세기 +CONSTRAINT_SHARE = 1.0 + +Measure = Callable[..., dict[str, Any]] +Route = tuple[Any, dict[str, Any]] + + +@dataclass +class Pool: + """같은 입력의 후보 묶음 — 갈래 고르기 · 비중 탐색이 같이 쓴다.""" + + graph: RouteGraph + candidates: list[Route] + bound: float | None + limit: int # 후보로 남길 못 지킨 곳 수 상한 + # 비중 몫 크기(1 m 당) — 이격 · 토공 · 기울기 · 넘은 기울기 · 계류 나란히 + scale: np.ndarray = field(default_factory=lambda: np.ones(5)) + # 비중 탐색 상한 — 예상 0 이면 갈래 상한(`base_cap`) · 예상 100 이면 추적 노선이 지나는 + # 가장 센 링크 기울기(`follow_cap`) · 사이는 예상 몫만큼(추적 쪽으로 갈수록 덜 막음) + base_cap: float = np.inf + follow_cap: float = np.inf + searched: dict[tuple[float, ...], Route] = field(default_factory=dict) + traced: dict[tuple, Route] = field(default_factory=dict) # 노드 → 잰 노선(`_trace`) + follow: Route | None = None + # 제약 안에서 갈 수 있는 띠 폭 어림 — 후보끼리 가장 먼 거리(m) + band_width_m: float = 0.0 + + +def _trace( + nodes: list[tuple[float, float]], + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], + **kw: Any, +) -> Route: + # 노드마다 곡선(묶지 않음) — [확인] 재구성(`/route/replan`)과 같은 규칙 → 적용 뒤도 같은 선. + # 같은 노드 · 같은 잼이면 다시 재지 않음(쓸기는 이웃 비중이 같은 길을 자주 냄 · 묶음마다 따로). + key = (tuple(nodes), tuple(sorted(kw.items()))) + if key not in traced: + outline = build_planned_polyline( + nodes, min_radius_m=min_radius_m, simplify=False, curve_flags=[True] * len(nodes) + ) + traced[key] = (outline, measure(outline, **kw)) + return traced[key] + + +def _solve(graph: RouteGraph, target: str, **constraint: Any) -> list[tuple[float, float]]: + """갈래 후보 탐색 한 번. 공사비 = 토공 가늠 · 계류 횡단 · 연장을 상대 단가 그대로 더한 비용 + (링크 평균이 1 쯤 되게 한 값으로 나눔 — 제약 벌점과 겨루는 크기를 다른 목표와 맞춤).""" + if target != "cost": + return graph.solve(TARGETS[target], **constraint) + crossing = COST_CROSSING / (2.0 * CROSSING_BAND * graph.grid.cell) # 1 m 당(횡단 한 곳 몫) + per_m = COST_EARTH_M3 * float(np.average(graph.terms[1], weights=graph.length)) + COST_LENGTH_M + weights = {"earthwork": COST_EARTH_M3 / per_m, "crossing": crossing / per_m} + total = sum(weights.values()) + return graph.solve( + weights, + scale=np.full(len(OBJECTIVES), 1.0 / total), + length_cost=COST_LENGTH_M / per_m, + **constraint, + ) + + +def _candidates( + graph: RouteGraph, + max_grade: float, + min_radius_m: float, + measure: Measure, + traced: dict[tuple, Route], +) -> tuple[list[Route], float | None, int]: + """제약 탐색 후보들 · 지형상 불가능 때 막은 기울기(아니면 None) · 후보로 남길 못 지킨 곳 상한. + + 제약을 지키는 길이 있으면 목표마다 탐색 상한을 `GRADE_CAP_STEPS` 차례로 낮춰 찾은 노선들 · + 없으면 막은 기울기 안에서 벌점 크기 · 목표를 바꿔 찾은 노선들. + """ + found: list[Route] = [] + for target in TARGETS: + for step in GRADE_CAP_STEPS: + try: + nodes = _solve(graph, target, cap=max_grade * step, strict_stream=True) + except NoRoute: + break + found.append(_trace(nodes, min_radius_m, measure, traced, bound=None)) + if found[-1][1]["over_limit"]["total"] == 0: + break + if not found: # 제약을 지키는 길 없음 — 목표와 무관하니 더 볼 것 없음 + break + if found: + least = min(item[1]["over_limit"]["total"] for item in found) + return [item for item in found if item[1]["over_limit"]["total"] == least], None, least + + # 지형상 불가능 — 반지름 · 계류 규칙을 지키는 어떤 길도 넘어야 하는 가장 작은 기울기를 + # 구해(`terrain_bound`) 그 위는 막고, 상한과 그 사이는 벌점으로 넘긴다. + bound = graph.terrain_bound() + cap = bound + 1e-9 + seed = _trace( + graph.solve( + {"expected": 50.0, "earthwork": 25.0, "grade": 25.0}, + cap=cap, + strict_stream=False, + grade_cost=SOFT_GRADE_COST, + ), + min_radius_m, + measure, + traced, + bound=bound, + ) + found = [seed] + for target in TARGETS: + for cost in RELAXED_GRADE_COSTS: + nodes = _solve(graph, target, cap=cap, strict_stream=False, grade_cost=cost) + found.append(_trace(nodes, min_radius_m, measure, traced, bound=bound)) + most = seed[1]["over_limit"]["total"] + return [item for item in found if item[1]["over_limit"]["total"] <= most], bound, most + + +def build_pool( + graph: RouteGraph, + follow: Route, + max_grade: float, + min_radius_m: float, + measure: Measure, +) -> Pool: + """후보 묶음 — 제약 탐색 후보 → 끝 노선 셋으로 비중 크기 맞춤 → 토공 ↔ 기울기 쓸기를 더함.""" + traced: dict[tuple, Route] = {} + candidates, bound, limit = _candidates(graph, max_grade, min_radius_m, measure, traced) + pool = Pool(graph, candidates, bound, limit, traced=traced, follow=follow) + ends = [follow[0], pick("earthwork", candidates)[0], pick("gentle", candidates)[0]] + pool.scale, steepest = _scale(graph, [outline.vertices for outline in ends], max_grade) + pool.base_cap = (max_grade if bound is None else bound) + 1e-9 + pool.follow_cap = max(steepest + 1e-9, pool.base_cap) + for k in np.linspace(0.0, 1.0, SWEEP_STEPS): + weights = {"expected": 0.0, "earthwork": 100.0 * k, "grade": 100.0 * (1.0 - k)} + route = weighted(pool, weights, min_radius_m, measure) + if route[1]["over_limit"]["total"] <= limit: + pool.candidates.append(route) + lines = list( + {id(outline): LineString(outline.vertices) for outline, _ in pool.candidates}.values() + ) + pool.band_width_m = max( + (a.hausdorff_distance(b) for i, a in enumerate(lines) for b in lines[i + 1 :]), default=0.0 + ) + return pool + + +def similar(pool: Pool, mode: str, outline: Any) -> list[dict[str, Any]]: + """이 노선과 `SIMILAR_M` 안인 다른 갈래 — 갈래 이름 · 가장 먼 거리(m). + + 화면이 「이 지형은 제약 안에서 갈 길이 한 띠뿐 · ○○ 과 거의 같은 노선」을 알리는 데 쓴다.""" + here = LineString(outline.vertices) + others = {"follow": pool.follow, **{name: pick(name, pool.candidates) for name in GOALS}} + found = [] + for name, (other, _) in others.items(): + if name == mode or other is None: + continue + gap = here.hausdorff_distance(LineString(other.vertices)) + if gap < SIMILAR_M: + found.append({"logic": name, "max_gap_m": round(gap, 1)}) + return sorted(found, key=lambda item: item["max_gap_m"]) + + +def _scale( + graph: RouteGraph, lines: list[list[tuple[float, float]]], max_grade: float +) -> tuple[np.ndarray, float]: + """비중 몫의 크기(1 m 당) — 끝 노선 셋 사이 몫 적분 범위 / 추적 연장 · 추적의 가장 센 기울기. + + 비중 1 을 옮기면 몫마다 「끝 노선 사이 차이의 1 %」만큼 무게가 옮겨 가 몫끼리 영향이 비슷하다. + 끝 노선끼리 그 몫이 거의 같으면(평지 등) 링크 평균으로 맞춤. + """ + measured = [graph.grid.objectives(line, limit=max_grade) for line in lines] + values = np.array([value for value, _ in measured]) + reference = max(float(np.hypot(*np.diff(np.asarray(lines[0]), axis=0).T).sum()), 1.0) + spread = (values.max(axis=0) - values.min(axis=0)) / reference + per_link = np.vstack([graph.terms[:3], graph.over / graph.limit, graph.along]) + mean = (per_link * graph.length).sum(axis=1) / max(float(graph.length.sum()), 1e-9) + return np.where(spread > MIN_RANGE_SHARE * mean, spread, mean), measured[0][1] + + +def weighted(pool: Pool, weights: dict[str, float], min_radius_m: float, measure: Measure) -> Route: + """비중 계산 — 탐색 비용의 가중합 한 번(같은 비중은 다시 안 찾음). 제약은 벌점만.""" + key = tuple(round(float(weights[name]), 6) for name in ZERO) + if key not in pool.searched: + share = weights["expected"] / max(sum(weights.values()), 1e-9) + soft = CONSTRAINT_SHARE * (1.0 - share) + nodes = pool.graph.solve( + weights, + cap=pool.base_cap + share * (pool.follow_cap - pool.base_cap), + strict_stream=False, + grade_cost=soft / max(pool.scale[3], 1e-12), + stream_cost=soft / max(pool.scale[4], 1e-12), + scale=np.concatenate([pool.scale[:3], np.ones(len(OBJECTIVES) - 3)]), + ) + pool.searched[key] = _trace( + nodes, min_radius_m, measure, pool.traced, bound=pool.bound, weighted=True + ) + return pool.searched[key] + + +def _earth(metrics: dict[str, Any]) -> float: + return metrics["cut_m3"] + metrics["fill_m3"] + + +GOALS: dict[str, Callable[[dict[str, Any]], tuple[float, ...]]] = { + "earthwork": lambda m: (_earth(m),), + "gentle": lambda m: (m["max_grade_pct"], m["avg_grade_pct"]), + "gradeline": lambda m: (m["grade_dev_pct"], m["max_grade_pct"]), + "safe": lambda m: (m["hazard_m"], m["side_slope_avg_pct"]), + "crossings": lambda m: (m["stream_crossings"], -(m["stream_offset_avg_m"] or 0.0)), + "cost": lambda m: (m["cost_index"],), +} + + +def pick(mode: str, candidates: list[Route]) -> Route: + """갈래 목표 비교값이 가장 좋은 후보. 같으면 앞 후보.""" + goal = GOALS[mode] + return min(candidates, key=lambda item: goal(item[1])) diff --git a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py index 53e3d625..cb97542c 100644 --- a/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py +++ b/B05_Profile/B05_Profile_Engine_RouteInitial_Search.py @@ -15,10 +15,15 @@ from dataclasses import dataclass import numpy as np from scipy import ndimage +from scipy.interpolate import RegularGridInterpolator from scipy.sparse import csr_matrix from scipy.sparse.csgraph import breadth_first_order, connected_components, dijkstra -from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import GRADE_SMOOTH_M, smoothed_ground +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( + GRADE_SMOOTH_M, + SAFE_SIDE_SLOPE, + smoothed_ground, +) # 토공 가늠 폭(m) = 유효너비 3 + 길어깨 0.5 × 2(별표2 간선). ROAD_WIDTH_M = 4.0 @@ -30,7 +35,10 @@ TURN_COST = 1.0 # 꺾을 때 벌점 = 최소 곡선반지름 원호 길이(R· SOFT_STREAM_COST = 20.0 # 계류 버퍼 안 나란히 달리기(지형상 불가능할 때만 · 1 m 당) BOUND_BISECT = 7 # 지형상 불가능할 때 막는 기울기를 찾는 이분 탐색 횟수(구간 1/128 까지) STREAM_CROSS_COS = math.cos(math.radians(45.0)) # 버퍼 안 이동 방향 한도(계류 쪽 · 반대쪽 ±45°) -OBJECTIVES = ("expected", "earthwork", "grade") +# 탐색 목표(1 m 당) — 이격 · 토공 가늠 · 기울기 · 목표 기울기 편차(영선) · 위험(급한 옆경사 · 계류 +# 가까움) · 계류 횡단(계류 선 위 링크) — PLAN 23-5 +OBJECTIVES = ("expected", "earthwork", "grade", "gradeline", "risk", "crossing") +CROSSING_BAND = 0.75 # 계류 횡단 링크 = 링크 가운데가 계류에서 칸 × 이 값 안 # 16 방향(행, 열) — +x 에서 반시계. 나이트 걸음(1, 2) 등을 넣어 22.5° 안팎 방향을 곧게 달림. DIRECTIONS = ( (0, 1), @@ -100,8 +108,10 @@ class SearchGrid: *, stream_offset_m: float, grade_smooth_m: float = GRADE_SMOOTH_M, + target_grade: float | None = None, ) -> None: step = max(1, int(round(SEARCH_CELL_M / _cell_size(terrain.x)))) + self.target_grade = target_grade # 영선 노선 목표 기울기(비율) · 없으면 링크 상한 self.xs, self.ys = terrain.x[::step], terrain.y[::step] # 기울기 · 옆경사는 비교값과 같은 고른 지반에서 잰다(`smoothed_ground`) self.z = smoothed_ground(terrain, grade_smooth_m)[::step, ::step] @@ -153,9 +163,64 @@ class SearchGrid: # 반 간격(10 m) 동안 쌓는 높이 차 단면(지형상 불가능할 때만 0 이 아님) earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 offset = 0.5 * (self.d_expected[first][ok] + self.d_expected[second][ok]) - terms = np.stack([offset, earth, (grade / limit) ** 2]) + target = limit if self.target_grade is None else self.target_grade + stream_mid = 0.5 * (self.d_stream[first][ok] + self.d_stream[second][ok]) + # 위험 = 비교값(`hazard_m`)과 같은 뜻 — 옆경사 35° 이상 · 계류 버퍼 안을 지나는 길이(칸 + # 두 끝 가운데 급한 쪽) · 옆경사 몫을 조금 더해 같은 길이면 덜 급한 쪽 + risk = ((cross >= SAFE_SIDE_SLOPE) | (stream_mid < self.stream_offset_m)).astype( + np.float64 + ) + 0.1 * (cross / SAFE_SIDE_SLOPE) ** 2 + crossing = (stream_mid < CROSSING_BAND * self.cell).astype(np.float64) + terms = np.stack( + [offset, earth, (grade / limit) ** 2, ((grade - target) / limit) ** 2, risk, crossing] + ) return a[ok], b[ok], self.cell * norm, terms, over, along, grade + def objectives( + self, vertices: list[tuple[float, float]], *, limit: float + ) -> tuple[np.ndarray, float]: + """폴리라인 하나의 몫 적분(`links` 의 1 m 당 값 × 길이)과 지나는 가장 센 링크 기울기. + + 몫 = 이격 · 토공 가늠 · 기울기 · 넘은 기울기(/ 상한) · 계류 버퍼 안 나란히(m) — 칸 길이 + 간격으로 잰다. 비중 계산의 크기 맞춤(끝 노선 셋 사이 범위)에 쓴다 — 탐색 경로가 아닌 + 선(추적)도 잰다. + """ + xy = np.asarray(vertices, dtype=np.float64) + chain = np.concatenate([[0.0], np.cumsum(np.hypot(*np.diff(xy, axis=0).T))]) + at = np.linspace(0.0, chain[-1], max(2, int(chain[-1] / self.cell) + 1)) + points = np.column_stack([np.interp(at, chain, xy[:, 0]), np.interp(at, chain, xy[:, 1])]) + mid = 0.5 * (points[1:] + points[:-1]) + step = np.diff(points, axis=0) + length = np.maximum(np.hypot(step[:, 0], step[:, 1]), 1e-9) + + def sample(field: np.ndarray, where: np.ndarray) -> np.ndarray: + field = np.where(np.isfinite(field), field, 1e9) + return RegularGridInterpolator( + (self.ys, self.xs), field, bounds_error=False, fill_value=None + )(where[:, ::-1]) + + grade = np.abs(np.diff(sample(self.z, points))) / length + over = np.maximum(0.0, grade - limit) + normal = np.column_stack([-step[:, 1], step[:, 0]]) / length[:, None] + cross = np.abs( + sample(self.grad_x, mid) * normal[:, 0] + sample(self.grad_y, mid) * normal[:, 1] + ) + earth = ROAD_WIDTH_M**2 * cross / 4.0 + ROAD_WIDTH_M * over * 10.0 + away = np.column_stack([sample(self.away_x, mid), sample(self.away_y, mid)]) + size = np.hypot(away[:, 0], away[:, 1]) + across = np.abs((away * step).sum(axis=1)) >= STREAM_CROSS_COS * length * size + along = (sample(self.d_stream, mid) < self.stream_offset_m) & ~across & (size > 1e-6) + terms = np.stack( + [ + np.minimum(sample(self.d_expected, mid), CORRIDOR_M), + earth, + (grade / limit) ** 2, + over / limit, + along.astype(np.float64), + ] + ) + return (terms * length).sum(axis=1), float(grade.max(initial=0.0)) + def _turn_steps(min_radius_m: float, cell: float) -> int: """꺾은 뒤 곧게 가야 할 칸 수 — 앞뒤 원호 접선이 한 직선에 들어가게. @@ -226,6 +291,7 @@ class RouteGraph: shape=(self.size, self.size), ) self.indices, self.indptr = order.indices, order.indptr + self._frame: tuple | None = None # 마지막으로 쓴 간선 틀(`_graph`) edge = (order.data - 1.0).astype(np.int64) self.edge_link = link_of[edge] self.edge_turn = np.concatenate(turn)[edge] @@ -243,15 +309,22 @@ class RouteGraph: return usable & ~self.along if strict_stream else usable def _graph(self, usable: np.ndarray, link_cost: np.ndarray | None) -> csr_matrix: - """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`).""" - keep = usable[self.edge_link] - indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] - indptr = np.append(indptr, indptr[-1] + len(self.starts)) - indices = np.concatenate([self.indices[keep], self.starts]) + """쓸 수 있는 간선만 남긴 그래프 + 시점 상태 모두로 가는 원점(마지막 상태 `size`). + + 틀(남길 간선 · 행 포인터)은 같은 `usable` 이면 다시 쓴다 — 후보 묶음은 같은 상한으로 + 여러 번 찾는다(비용만 다름).""" + signature = usable.tobytes() + if self._frame is None or self._frame[0] != signature: + keep = usable[self.edge_link] + indptr = np.concatenate([[0], np.cumsum(keep)])[self.indptr] + indptr = np.append(indptr, indptr[-1] + len(self.starts)) + indices = np.concatenate([self.indices[keep], self.starts]) + self._frame = (signature, keep, indptr, indices, self.edge_link[keep]) + _, keep, indptr, indices, links = self._frame if link_cost is None: data = np.ones(indices.size) else: - cost = link_cost[self.edge_link[keep]] + self.edge_turn[keep] + cost = link_cost[links] + self.edge_turn[keep] data = np.concatenate([cost, np.full(len(self.starts), 1e-9)]) return csr_matrix((data, indices, indptr), shape=(self.size + 1, self.size + 1)) @@ -268,24 +341,31 @@ class RouteGraph: cap: float, strict_stream: bool, grade_cost: float = 0.0, + stream_cost: float = SOFT_STREAM_COST, + scale: np.ndarray | None = None, + length_cost: float = LENGTH_COST, ) -> list[tuple[float, float]]: """노드(꺾임점) 목록 — 첫 · 끝은 예상노선 양 끝 그대로. - 세 목표는 쓸 수 있는 링크 평균이 1 이 되게 정규화(길이 가중)한 뒤 비중을 곱한다. - `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 비율 / 상한) — `cap` 이 상한보다 - 클 때(지형상 불가능)만 뜻이 있다. + 목표(`OBJECTIVES`)는 `scale`(1 m 당 크기)로 나눈 뒤 비중을 곱한다 — 안 주면 쓸 수 있는 + 링크 평균이 1 이 되게(길이 가중). `grade_cost` = 상한을 넘은 기울기 벌점(1 m 당 × 넘은 + 비율 / 상한) · + `stream_cost` = 계류 버퍼 안 나란히 벌점(엄격하지 않을 때만) · + `length_cost` = 1 m 당 거리 비용. """ usable = self._usable(cap, strict_stream) if not usable.any(): raise NoRoute("예상노선 둘레에 제약을 지키며 지날 수 있는 링크가 없습니다.") - length = self.length[usable] - scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) - share = np.array([weights[key] for key in OBJECTIVES]) / max(sum(weights.values()), 1e-9) + if scale is None: + length = self.length[usable] + scale = (self.terms[:, usable] * length).sum(axis=1) / max(length.sum(), 1e-9) + share = np.array([weights.get(key, 0.0) for key in OBJECTIVES]) + share = share / max(sum(weights.values()), 1e-9) factor = np.divide(share, scale, out=np.zeros_like(share), where=scale > 1e-12) penalty = grade_cost * self.over / self.limit if not strict_stream: - penalty = penalty + SOFT_STREAM_COST * self.along - link_cost = self.length * (LENGTH_COST + factor @ self.terms + penalty) + penalty = penalty + stream_cost * self.along + link_cost = self.length * (length_cost + factor @ self.terms + penalty) distance, predecessors = dijkstra( self._graph(usable, link_cost), indices=self.size, return_predecessors=True ) diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index d9a7a24d..569e6286 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -105,14 +105,17 @@ class RouteInitialCriteria(BaseModel): min_radius_m: float | None = Field(None, ge=0) stream_offset_m: float | None = Field(None, ge=0) grade_smooth_m: float | None = Field(None, ge=0) + # 영선 노선 목표 종단기울기(%) — 비우면 종단기울기 상한 · 상한보다 크면 상한(PLAN 23-5) + target_grade_pct: float | None = Field(None, ge=0, le=100) class RouteInitialRequest(BaseModel): - """초기 계획노선 갈래 — follow(예상노선 추종) · earthwork · gentle · weighted.""" + """초기 계획노선 갈래 — follow(예상노선 추종) · gradeline(영선) · earthwork · gentle · + safe(안전 우선) · crossings(계류 횡단 최소) · cost(공사비 어림 최소) · weighted(PLAN 23-5).""" - mode: Literal["follow", "earthwork", "gentle", "weighted"] = Field( - "follow", validation_alias=AliasChoices("logic", "mode") - ) + mode: Literal[ + "follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted" + ] = Field("follow", validation_alias=AliasChoices("logic", "mode")) weights: RouteInitialWeights | None = None criteria: RouteInitialCriteria | None = None diff --git a/B05_Profile/B05_Profile_Router_RouteInitial.py b/B05_Profile/B05_Profile_Router_RouteInitial.py index b0639479..2dcc75f8 100644 --- a/B05_Profile/B05_Profile_Router_RouteInitial.py +++ b/B05_Profile/B05_Profile_Router_RouteInitial.py @@ -70,6 +70,7 @@ async def initial_route( radius_m = criteria.min_radius_m stream_offset_m = criteria.stream_offset_m if criteria else None grade_smooth_m = criteria.grade_smooth_m if criteria else None + target_pct = criteria.target_grade_pct if criteria else None pool = get_db_pool() async with pool.acquire() as connection: selection = await get_surface_confirmation_params(connection, str(project_id)) @@ -90,6 +91,7 @@ async def initial_route( min_radius_m=radius_m, stream_offset_m=stream_offset_m, grade_smooth_m=grade_smooth_m, + target_grade=None if target_pct is None else target_pct / 100.0, ) ) except (FileNotFoundError, ValueError) as error: diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts index eaa7f8e4..8c440275 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial.ts @@ -27,6 +27,7 @@ import type { RouteEditSnapshot } from "./B05_Profile_UI_RouteEdit_History"; import { choiceLabel, DEFAULT_WEIGHTS, + NO_WEIGHT_LOCKS, initialRouteSnapshot, normalizeRouteInitial, readInitialChoice, @@ -85,6 +86,11 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { let opening: RouteEditSnapshot | null = null; let edited = false; let busy = false; + /** 비중 · 고정 — 모달을 닫았다 열어도 이 편집 창이 사는 동안 남는다. */ + const weightMemory = { + weights: null as RouteInitialWeights | null, + locks: { ...NO_WEIGHT_LOCKS }, + }; /** `/route/plan` 한 번 읽은 것 — 예상노선(지도 바탕) · 초기값 노선([초기화]). 창이 사는 동안 안 바뀐다. */ let planRead: Promise | null = null; @@ -130,6 +136,7 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel { criteria: choice?.criteriaOverrides ?? base.criteria, defaults, weights: { ...(choice?.weights ?? base.weights ?? DEFAULT_WEIGHTS) }, + weightMemory, planMinRadiusM: params.planMinRadiusM(), expected: (plan?.expected ?? []).map(([x, y]) => [x, y]), current: opening!.planned.map(([x, y]) => [x, y]), diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts index a8ad81c6..e60e958c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Initial_Model.ts @@ -17,8 +17,12 @@ type Vertex = [number, number]; export const LOGIC_LABEL: Record = { follow: "예상노선 추적", + gradeline: "영선 노선", earthwork: "토공 최소", gentle: "기울기 순한", + safe: "안전 우선", + crossings: "계류 횡단 최소", + cost: "공사비 어림 최소", weighted: "비중 계산", }; @@ -36,6 +40,12 @@ export const CRITERIA_KEYS = [ ] as const; export type CriteriaKey = (typeof CRITERIA_KEYS)[number]; +/** 영선 노선 목표 기울기 칸 — 공통 제약 네 칸과 따로(영선을 고를 때만 보이고 보낸다). */ +export const TARGET_GRADE_KEY = "target_grade_pct" as const; + +/** 겹침 값에 담길 수 있는 모든 키 — 캐시 · 서버 기억 견주기에 쓴다. */ +const OVERRIDE_KEYS = [...CRITERIA_KEYS, TARGET_GRADE_KEY] as const; + /** 한 비중을 옮기면 나머지 둘이 **지금 비율대로** 나눠 합 100 을 지킨다(정수 %). */ export function rebalanceWeights( weights: RouteInitialWeights, @@ -51,6 +61,68 @@ export function rebalanceWeights( return { expected: out.expected, earthwork: out.earthwork, grade: out.grade }; } +/** 비중마다 고정 표시 — 고정한 값은 다른 비중을 움직여도 안 바뀐다. */ +export type WeightLocks = Record; + +export const NO_WEIGHT_LOCKS: WeightLocks = { expected: false, earthwork: false, grade: false }; + +const lockedKeys = (locks: WeightLocks): WeightKey[] => WEIGHT_KEYS.filter((name) => locks[name]); + +/** 나머지 둘이 고정돼 저절로 정해지는 비중 — 없으면 null. 이 비중은 잠김으로 보인다. */ +export function derivedWeightKey(locks: WeightLocks): WeightKey | null { + const free = WEIGHT_KEYS.filter((name) => !locks[name]); + return free.length === 1 ? free[0] : null; +} + +/** 이 비중의 고정 단추를 새로 누를 수 있나 — 셋 다 고정은 막는다(마지막 하나는 저절로 잠김). + * 이미 고정한 것을 푸는 것은 늘 된다. */ +export function canLockWeight(locks: WeightLocks, key: WeightKey): boolean { + return locks[key] || lockedKeys(locks).length < 2; +} + +/** 슬라이더로 한 비중을 옮긴다 — 고정한 것은 그대로, 안 고정한 것끼리 합 100 을 맞춘다. + * 고정하지 않은 것이 셋이면 나머지 둘이 지금 비율대로(`rebalanceWeights`), 둘이면 다른 하나가 남는 몫을 + * 받고, 이 비중 혼자면(나머지 둘 고정) 값이 정해져 있어 안 움직인다. 값은 남는 몫 안으로 잘린다. */ +export function moveWeight( + weights: RouteInitialWeights, + locks: WeightLocks, + key: WeightKey, + value: number, +): RouteInitialWeights { + if (locks[key]) return { ...weights }; + const fixedSum = lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0); + const room = 100 - fixedSum; + const free = WEIGHT_KEYS.filter((name) => !locks[name]); + if (free.length === WEIGHT_KEYS.length) return rebalanceWeights(weights, key, value); + const out = { ...weights }; + if (free.length === 1) { + out[key] = room; + return out; + } + const other = free.find((name) => name !== key)!; + const next = Math.min(room, Math.max(0, Math.round(Number.isFinite(value) ? value : 0))); + out[key] = next; + out[other] = room - next; + return out; +} + +/** 값 칸에 직접 넣은 숫자 — 0~100 정수이고 고정 규칙 아래 합 100 을 맞출 수 있을 때만 받는다. + * 못 맞추면 `ok: false`(칸에 빨간 테두리 · 서버에 안 보냄). 고정한 비중은 고칠 수 없다. */ +export function inputWeight( + weights: RouteInitialWeights, + locks: WeightLocks, + key: WeightKey, + value: number, +): { ok: boolean; weights: RouteInitialWeights } { + const same = { ok: false, weights: { ...weights } }; + if (locks[key] || !Number.isInteger(value) || value < 0 || value > 100) return same; + const room = 100 - lockedKeys(locks).reduce((sum, name) => sum + weights[name], 0); + const free = WEIGHT_KEYS.filter((name) => !locks[name]); + if (free.length === 1) return value === room ? { ok: true, weights: { ...weights } } : same; + if (value > room) return same; + return { ok: true, weights: moveWeight(weights, locks, key, value) }; +} + /** 결과 비교값 — 서버가 셈해 준 것을 보이기만 한다. 없는 칸은 null. */ export interface InitialMetrics { lengthM: number | null; @@ -58,6 +130,20 @@ export interface InitialMetrics { meanGradePct: number | null; cutM3: number | null; fillM3: number | null; + /** 옆경사(지형이 노선 옆으로 기운 정도, %) 평균 · 최대. */ + sideSlopeAvgPct: number | null; + sideSlopeMaxPct: number | null; + /** 위험 구간(옆경사 35° 이상 · 계류 버퍼 안)을 지나는 길이(m). */ + hazardM: number | null; + /** 계류를 건너는 횟수 · 계류와의 평균 이격(m). */ + streamCrossings: number | null; + streamOffsetAvgM: number | null; + /** 측점 기울기와 영선 목표 기울기 차의 평균(%p). */ + gradeDevPct: number | null; + /** 공사비 어림 — 절성토 · 연장 · 계류 횡단을 섞은 **상대값**(단위 없음 · 실제 공사비 아님). */ + costIndex: number | null; + /** 남는 흙(㎥) = 절토 − 성토 — + 남음 / − 모자람. 서버 `surplus_m3`. */ + surplusM3: number | null; overLimitCount: number | null; offsetM: number | null; /** 제약 못 지킨 구간 — 서버 `metrics.violations`. 칸이 없으면 null(옛 답). */ @@ -80,6 +166,8 @@ export interface InitialCriteria { streamOffsetM: number | null; /** 기울기를 잴 지반을 고른 폭(m) — 서버 `grade_smooth_m`. */ gradeSmoothM: number | null; + /** 영선 노선이 실제로 쓴 목표 기울기(%) — 서버 `target_grade_pct`. */ + targetGradePct: number | null; } /** 마지막에 적용한 초기값 한 벌 — 캐시(`routeedit-initial`)에 든 꼴. */ @@ -93,6 +181,16 @@ export interface InitialChoice { criteriaOverrides: RouteInitialCriteria | null; /** 제약을 다 지키는 길이 없어 기울기 상한을 풀었나 — 풀었으면 그 값(%). */ terrainBoundPct?: number | null; + /** 이 노선과 거의 같은(10 m 안) 다른 갈래 — 서버 `similar`. 없으면 null(옛 답 · 묶음 없음). */ + similar?: SimilarRoute[] | null; + /** 제약 안에서 갈 수 있는 길의 띠 폭 어림(m) — 서버 `band_width_m`. */ + bandWidthM?: number | null; +} + +/** 거의 같은 노선인 다른 갈래 하나 — 두 노선 사이 가장 먼 거리(m). */ +export interface SimilarRoute { + logic: RouteInitialLogic; + gapM: number; } const num = (value: unknown): number | null => @@ -167,6 +265,19 @@ export function mapPaths( })); } +/** 서버 `similar` — 갈래 이름을 아는 것만 담는다. 목록이 아니면 null · 빈 목록이면 빈 배열. */ +function parseSimilar(raw: unknown): SimilarRoute[] | null { + if (!Array.isArray(raw)) return null; + const out: SimilarRoute[] = []; + for (const item of raw as Array>) { + const logic = typeof item?.logic === "string" ? item.logic : ""; + const gap = num(item?.max_gap_m); + if (logic in LOGIC_LABEL && gap !== null) + out.push({ logic: logic as RouteInitialLogic, gapM: gap }); + } + return out; +} + /** 서버 답을 편다. 노드가 두 개 못 되면 던진다(적용할 노선이 없다). * `criteriaOverrides` 는 이 요청에 실어 보낸 제약 겹침(빈 칸 뺀 것) — 캐시·재열기에 남긴다. */ export function normalizeRouteInitial( @@ -195,6 +306,14 @@ export function normalizeRouteInitial( meanGradePct: pick(m, "avg_grade_pct"), cutM3: pick(m, "cut_m3"), fillM3: pick(m, "fill_m3"), + sideSlopeAvgPct: pick(m, "side_slope_avg_pct"), + sideSlopeMaxPct: pick(m, "side_slope_max_pct"), + hazardM: pick(m, "hazard_m"), + streamCrossings: pick(m, "stream_crossings"), + streamOffsetAvgM: pick(m, "stream_offset_avg_m"), + gradeDevPct: pick(m, "grade_dev_pct"), + costIndex: pick(m, "cost_index"), + surplusM3: pick(m, "surplus_m3"), overLimitCount: pick(over, "total"), offsetM: pick(m, "offset_avg_m"), violations: Array.isArray(m.violations) @@ -209,12 +328,15 @@ export function normalizeRouteInitial( } : null, terrainBoundPct: body.relaxed === true ? num(body.terrain_bound_pct) : null, + similar: parseSimilar(body.similar), + bandWidthM: num(body.band_width_m), criteria: body.criteria ? { maxGradePct: pick(c, "max_grade_pct"), minRadiusM: pick(c, "min_radius_m"), streamOffsetM: pick(c, "stream_offset_m"), gradeSmoothM: pick(c, "grade_smooth_m"), + targetGradePct: pick(c, "target_grade_pct"), } : null, criteriaOverrides, @@ -235,7 +357,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null { if (!raw || typeof raw !== "object") return null; const source = raw as Record; const out: RouteInitialCriteria = {}; - for (const key of CRITERIA_KEYS) { + for (const key of OVERRIDE_KEYS) { const value = num(source[key]); if (value !== null) out[key] = value; } @@ -244,7 +366,7 @@ function parseCriteriaOverrides(raw: unknown): RouteInitialCriteria | null { /** 두 겹침 값이 같은가 — 없는 칸은 서로 같다고 본다. */ function criteriaEqual(a: RouteInitialCriteria | null, b: RouteInitialCriteria | null): boolean { - return CRITERIA_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null)); + return OVERRIDE_KEYS.every((key) => (a?.[key] ?? null) === (b?.[key] ?? null)); } /** 서버 `current_initial {logic, weights, criteria}` 를 편다. 모르는 값이면 예상노선 추적. */ @@ -289,26 +411,94 @@ const fixed = (value: number | null, digits: number, unit: string): string => ? "—" : `${value.toLocaleString("ko-KR", { maximumFractionDigits: digits, minimumFractionDigits: digits })}${unit}`; -/** 결과 비교값 한 줄 — 연장 · 최대/평균 기울기 · 절성토량 · 기준 초과 구간 · 이격. */ -export function metricsLine(metrics: InitialMetrics | null): string { - if (!metrics) return "비교값 — 로직을 누르면 서버가 셈해 보입니다."; - return [ +/** 남는 흙(절토 − 성토) — 부호를 늘 붙여 + 남음 / − 모자람으로 읽힌다. */ +export function surplusText(surplusM3: number): string { + const rounded = Math.round(surplusM3); + if (rounded === 0) return "0㎥(균형)"; + const sign = rounded > 0 ? "+" : "−"; + return `${sign}${Math.abs(rounded).toLocaleString("ko-KR")}㎥(${rounded > 0 ? "남음" : "모자람"})`; +} + +/** 조사 「과 / 와」 — 받침이 있으면 과. */ +const withParticle = (name: string): string => { + const code = name.charCodeAt(name.length - 1); + const hasFinal = code >= 0xac00 && code <= 0xd7a3 && (code - 0xac00) % 28 !== 0; + return `${name}${hasFinal ? "과" : "와"}`; +}; + +/** 같은 노선 알림 한 줄 — 이 지형은 제약 안에서 갈 길이 한 띠뿐일 때. 없으면 빈 글(줄을 숨긴다). + * 「이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 토공 최소 · 짧은 노선과 거의 같은 노선(가장 먼 거리 6.1m)」 */ +export function similarLine(choice: InitialChoice | null): string { + const similar = choice?.similar; + if (!choice || !similar || similar.length === 0) return ""; + const names = similar.map((item) => LOGIC_LABEL[item.logic]).join(" · "); + const gap = Math.max(...similar.map((item) => item.gapM)); + const band = + choice.bandWidthM === null || choice.bandWidthM === undefined + ? "" + : `폭 ${fixed(choice.bandWidthM, 1, "m")} `; + return `이 지형은 제약 안에서 갈 길이 ${band}한 띠뿐 — ${withParticle(names)} 거의 같은 노선(가장 먼 거리 ${fixed(gap, 1, "m")})`; +} + +/** 같은 노선 알림 아래 흐린 글 — 제약을 바꾸면 갈래가 벌어질 수 있다. */ +export const SIMILAR_HINT = + "기울기 · 최소R · 계류 제약 값을 바꾸면 갈래가 서로 벌어질 수 있습니다(다시 셈)."; + +/** 결과 비교값 — 두 묶음. 노선 · 기울기 / 흙 · 안전 · 비용. 서버가 안 준 칸(옛 답)은 빠진다. */ +export interface MetricsLines { + route: string; + work: string; +} + +const NO_METRICS = "비교값 — 알약을 누르면 서버가 셈해 보입니다."; + +export function metricsLines(metrics: InitialMetrics | null): MetricsLines { + if (!metrics) return { route: NO_METRICS, work: "" }; + const has = (value: number | null): value is number => value !== null; + const route = [ `연장 ${fixed(metrics.lengthM, 1, "m")}`, `기울기 최대 ${fixed(metrics.maxGradePct, 1, "%")} · 평균 ${fixed(metrics.meanGradePct, 1, "%")}`, - `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + ...(has(metrics.gradeDevPct) + ? [`목표 기울기 편차 ${fixed(metrics.gradeDevPct, 1, "%p")}`] + : []), + ...(has(metrics.sideSlopeAvgPct) + ? [ + `옆경사 평균 ${fixed(metrics.sideSlopeAvgPct, 1, "%")} · 최대 ${fixed(metrics.sideSlopeMaxPct, 1, "%")}`, + ] + : []), `기준 초과 ${metrics.overLimitCount === null ? "—" : `${metrics.overLimitCount}구간`}`, - `이격 ${fixed(metrics.offsetM, 1, "m")}`, + `예상노선과 이격 ${fixed(metrics.offsetM, 1, "m")}`, ].join(" · "); + const work = [ + `절토 ${fixed(metrics.cutM3, 0, "㎥")} · 성토 ${fixed(metrics.fillM3, 0, "㎥")}`, + ...(has(metrics.surplusM3) ? [`남는 흙 ${surplusText(metrics.surplusM3)}`] : []), + ...(has(metrics.hazardM) ? [`위험 구간 통과 ${fixed(metrics.hazardM, 0, "m")}`] : []), + ...(has(metrics.streamCrossings) ? [`계류 횡단 ${metrics.streamCrossings}회`] : []), + ...(has(metrics.streamOffsetAvgM) + ? [`계류 이격 평균 ${fixed(metrics.streamOffsetAvgM, 1, "m")}`] + : []), + ...(has(metrics.costIndex) + ? [`공사비 어림 ${fixed(metrics.costIndex, 0, "")}(어림 · 상대값)`] + : []), + ].join(" · "); + return { route, work }; } /** 공통 제약 기준값 한 줄 — 서버가 실제로 쓴 값(계획서 20 확정 · 화면 칸에서 바꿀 수 있음). */ -export function criteriaLine(criteria: InitialCriteria | null, planMinRadiusM: number): string { +export function criteriaLine( + criteria: InitialCriteria | null, + planMinRadiusM: number, + /** 영선 노선일 때만 — 서버가 쓴 목표 기울기를 함께 보인다. */ + showTarget = false, +): string { const radius = criteria?.minRadiusM ?? (planMinRadiusM > 0 ? planMinRadiusM : null); const parts = [ `종단기울기 ≤ ${fixed(criteria?.maxGradePct ?? null, 1, "%")}`, `최소 R ${fixed(radius, 0, "m")}`, `계류 이격 ≥ ${fixed(criteria?.streamOffsetM ?? null, 1, "m")}`, ]; + if (showTarget && criteria?.targetGradePct != null) + parts.push(`영선 목표 기울기 ${fixed(criteria.targetGradePct, 1, "%")}`); // 기울기는 고른 지반으로 잰다 — 그 폭을 서버가 주면 그대로 보인다. if (criteria?.gradeSmoothM) parts.push(`기울기 잴 지반 ${fixed(criteria.gradeSmoothM, 0, "m")} 고름`); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts index bb43b44a..6f728f9d 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts @@ -23,9 +23,15 @@ import { criteriaLine, CRITERIA_KEYS, LOGIC_LABEL, - metricsLine, - rebalanceWeights, + metricsLines, + TARGET_GRADE_KEY, + canLockWeight, + derivedWeightKey, + inputWeight, + moveWeight, relaxedLine, + SIMILAR_HINT, + similarLine, violationsLine, violationText, WEIGHT_KEYS, @@ -33,6 +39,7 @@ import { type InitialChoice, type InitialCriteria, type MapLayer, + type WeightLocks, type WeightKey, } from "./B05_Profile_UI_RouteEdit_Initial_Model"; import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map"; @@ -40,12 +47,25 @@ import "./B05_Profile_UI_Style_RouteRecommend.css"; type Vertex = [number, number]; -const PILLS: RouteInitialLogic[] = ["follow", "earthwork", "gentle", "weighted"]; +const PILLS: RouteInitialLogic[] = [ + "follow", + "gradeline", + "earthwork", + "gentle", + "safe", + "crossings", + "cost", + "weighted", +]; const PILL_TITLE: Record = { follow: "전처리가 만든 기본 노선 — 예상노선을 따라갑니다", earthwork: "절토 · 성토가 가장 적은 노선", gentle: "종단기울기가 가장 순한 노선", + gradeline: "정한 종단기울기로 등고선을 따라가는 등경사선(영선) — 목표 기울기를 넣을 수 있음", + safe: "급한 옆경사 · 계류 가까운 곳을 피하는 노선", + crossings: "계류를 건너는 횟수가 가장 적은 노선", + cost: "토공 + 연장 + 계류 횡단을 섞은 공사비 어림(상대값)이 가장 낮은 노선", weighted: "예상 · 토공 · 기울기 비중(합 100)으로 셈한 노선", }; @@ -55,6 +75,11 @@ const WEIGHT_NAME: Record = { grade: "기울기", }; +/** 고정 단추 그림 — 자물쇠(글자 색을 따라 밝음 · 어두움에서 보인다). */ +const LOCK_ICON = ``; + /** 공통 제약 칸 — 표시 이름 · 단위 · 서버가 준 값(`InitialCriteria`)에서 그 칸을 꺼내는 법. */ const CRITERIA_FIELD: Record< CriteriaKey, @@ -92,6 +117,8 @@ export interface RecommendParams { /** 칸을 비운 채 처음 물었을 때 서버가 답한 값 — 빈 칸 placeholder. 여기서 채워 간다. */ defaults: Partial>; weights: RouteInitialWeights; + /** 비중 · 고정 상태 — 편집 창이 사는 동안 모달을 닫았다 열어도 남는다. 여기서 고쳐 간다. */ + weightMemory: { weights: RouteInitialWeights | null; locks: WeightLocks }; planMinRadiusM: number; /** 지도 바탕 — 예상노선(원본 점 묶음) · 창을 연 노선(노드). */ expected: Vertex[]; @@ -108,7 +135,10 @@ export interface RecommendParams { export function openRecommendModal(params: RecommendParams): void { let logic: RouteInitialLogic = "follow"; - let weights: RouteInitialWeights = { ...params.weights }; + const { locks } = params.weightMemory; + let weights: RouteInitialWeights = { ...(params.weightMemory.weights ?? params.weights) }; + /** 값 칸에 넣은 숫자가 고정 규칙 아래 합 100 을 못 맞추는 비중 — 빨간 테두리 · 서버에 안 보냄. */ + const invalid = new Set(); let preview: InitialChoice | null = null; let busy = false; /** 마지막 미리 보기 뒤 슬라이더 · 제약 칸을 만졌다 — 미리 본 노선이 칸 값과 다르다. */ @@ -156,12 +186,14 @@ export function openRecommendModal(params: RecommendParams): void { +
+
+ +
@@ -199,7 +238,8 @@ export function openRecommendModal(params: RecommendParams): void { const criteriaInputs = body.querySelectorAll("[data-criteria]"); criteriaInputs.forEach((input) => { - const value = params.criteria?.[input.dataset.criteria as CriteriaKey]; + const value = + params.criteria?.[input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY]; input.value = value === undefined ? "" : String(value); }); @@ -207,10 +247,13 @@ export function openRecommendModal(params: RecommendParams): void { function currentCriteria(): RouteInitialCriteria { const out: RouteInitialCriteria = {}; criteriaInputs.forEach((input) => { + const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; + // 목표 기울기는 영선 노선을 물을 때만 보낸다. + if (key === TARGET_GRADE_KEY && logic !== "gradeline") return; const raw = input.value.trim(); if (raw === "") return; const value = Number(raw); - if (Number.isFinite(value)) out[input.dataset.criteria as CriteriaKey] = value; + if (Number.isFinite(value)) out[key] = value; }); return out; } @@ -232,26 +275,55 @@ export function openRecommendModal(params: RecommendParams): void { pill.disabled = busy; }); weightsBox.hidden = logic !== "weighted"; + const derived = derivedWeightKey(locks); for (const key of WEIGHT_KEYS) { - q(`[data-weight="${key}"]`).value = String(weights[key]); - q(`[data-weight-out="${key}"]`).value = `${weights[key]}`; + const slider = q(`[data-weight="${key}"]`); + const num = q(`[data-weight-num="${key}"]`); + const lock = q(`[data-weight-lock="${key}"]`); + // 고정했거나 나머지 둘이 고정돼 저절로 정해진 비중은 잠김 — 움직이지 않는다. + const shut = locks[key] || derived === key; + slider.value = String(weights[key]); + slider.disabled = busy || shut; + // 넣다 만 틀린 글은 지우지 않는다 — 빨간 테두리로 남겨 고치게 한다. + if (!invalid.has(key)) num.value = String(weights[key]); + num.disabled = busy || shut; + num.classList.toggle("is-invalid", invalid.has(key)); + num.setAttribute("aria-invalid", String(invalid.has(key))); + lock.setAttribute("aria-pressed", String(shut)); + lock.classList.toggle("is-on", shut); + lock.classList.toggle("is-auto", derived === key); + // 셋 다 고정은 막는다 — 마지막 하나는 고정 단추가 꺼지고 잠김으로만 보인다. + lock.disabled = busy || !canLockWeight(locks, key) || derived === key; + lock.title = + derived === key + ? "나머지 둘이 고정돼 저절로 정해집니다" + : locks[key] + ? "고정 풀기" + : "이 값 고정 — 다른 비중을 움직여도 안 바뀝니다"; } q("[data-weight-sum]").textContent = `합 ${weights.expected + weights.earthwork + weights.grade}`; criteriaInputs.forEach((input) => { - const key = input.dataset.criteria as CriteriaKey; + const key = input.dataset.criteria as CriteriaKey | typeof TARGET_GRADE_KEY; + // 목표 기울기를 비우면 종단기울기 상한을 쓴다 — 그 값이 자리표시. + const known = + key === TARGET_GRADE_KEY ? params.defaults.max_grade_pct : params.defaults[key]; input.placeholder = - params.defaults[key] !== undefined ? String(params.defaults[key]) : "기본값"; + known !== undefined ? String(known) : key === TARGET_GRADE_KEY ? "상한" : "기본값"; input.disabled = busy; }); + q("[data-target-row]").hidden = logic !== "gradeline"; rerun.hidden = !dirty; - rerun.disabled = busy; - applyButton.disabled = busy || dirty || !preview; + rerun.disabled = busy || invalid.size > 0; + applyButton.disabled = busy || dirty || !preview || invalid.size > 0; + const shown = metricsLines(preview?.metrics ?? null); line("metrics").textContent = preview ? preview.metrics - ? metricsLine(preview.metrics) + ? shown.route : "비교값 — 서버 답에 없습니다." : "비교값 — 알약을 누르면 서버가 셈해 미리 보여 줍니다."; + line("metrics-work").textContent = preview ? shown.work : ""; + line("metrics-work").hidden = !line("metrics-work").textContent; const violations = violationsLine(preview?.metrics ?? null); line("violations").textContent = violations; line("violations").title = (preview?.metrics?.violations ?? []) @@ -261,14 +333,23 @@ export function openRecommendModal(params: RecommendParams): void { const relaxed = relaxedLine(preview); line("relaxed").textContent = relaxed; line("relaxed").hidden = !relaxed; + const similar = similarLine(preview); + line("similar").textContent = similar; + line("similar").hidden = !similar; + line("similar-hint").textContent = similar ? SIMILAR_HINT : ""; + line("similar-hint").hidden = !similar; line("criteria").textContent = preview - ? criteriaLine(preview.criteria, params.planMinRadiusM) + ? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline") : ""; line("criteria").hidden = !preview; drawMap(); } async function run(): Promise { + if (logic === "weighted" && invalid.size > 0) { + showToast("비중 값이 합 100 을 못 맞춥니다 — 빨간 칸을 고쳐 주세요.", "error"); + return; + } const mine = ++seq; const sentWeights = logic === "weighted" ? { ...weights } : null; const criteria = currentCriteria(); @@ -314,17 +395,59 @@ export function openRecommendModal(params: RecommendParams): void { void run(); }); }); + const remember = (): void => { + params.weightMemory.weights = { ...weights }; + }; body.querySelectorAll("[data-weight]").forEach((input) => { input.addEventListener("input", () => { - weights = rebalanceWeights( + weights = moveWeight( weights, + locks, input.dataset.weight as WeightKey, Number(input.value), ); + invalid.clear(); + remember(); dirty = preview !== null; render(); }); }); + // 값 칸 — 0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않고 빨간 테두리. + body.querySelectorAll("[data-weight-num]").forEach((input) => { + input.addEventListener("input", () => { + const key = input.dataset.weightNum as WeightKey; + const raw = input.value.trim(); + const result = inputWeight(weights, locks, key, raw === "" ? Number.NaN : Number(raw)); + if (result.ok) { + weights = result.weights; + invalid.clear(); + remember(); + dirty = preview !== null; + // 다른 칸의 글도 새 값으로 — 이 칸은 넣은 그대로 둔다. + body.querySelectorAll("[data-weight-num]").forEach((other) => { + if (other !== input) + other.value = String(weights[other.dataset.weightNum as WeightKey]); + }); + } else { + invalid.add(key); + } + render(); + }); + // 칸을 떠날 때 맞는 값이면 글을 값으로 다듬는다(예: 070 → 70). + input.addEventListener("blur", () => { + if (!invalid.has(input.dataset.weightNum as WeightKey)) render(); + }); + }); + // 고정 단추 — 지금 값을 붙든다. 셋 다 고정은 막혀 있다(단추가 꺼짐). + body.querySelectorAll("[data-weight-lock]").forEach((button) => { + button.addEventListener("click", () => { + const key = button.dataset.weightLock as WeightKey; + if (!canLockWeight(locks, key)) return; + locks[key] = !locks[key]; + invalid.clear(); + render(); + }); + }); criteriaInputs.forEach((input) => { input.addEventListener("input", () => { dirty = preview !== null; diff --git a/B05_Profile/B05_Profile_UI_Structures_Panel.ts b/B05_Profile/B05_Profile_UI_Structures_Panel.ts index d38f48ae..357b6e80 100644 --- a/B05_Profile/B05_Profile_UI_Structures_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Structures_Panel.ts @@ -25,6 +25,7 @@ import { type FacilityAttributes } from "./B05_Profile_UI_Drainage_Facility"; import { field } from "./B05_Profile_UI_Structures_Fields"; import { renderOptionFields as renderOptionFieldsInto } from "./B05_Profile_UI_Structures_Panel_Options"; import { openItemPicker } from "@ui/ui_template_item_picker"; +import { pipeStructureId } from "@ui/ui_template_item_picker_project"; import { buildStructuresForm } from "./B05_Profile_UI_Structures_Form"; import { bindStructuresEvents, @@ -501,6 +502,10 @@ export function createStructuresSection( current: summary.get(), canCopy: true, projectId: sectionOptions.projectId ?? null, + // 복사본은 구조물마다 — 고치는 구조물의 id(관 지점 = `pipe@측점`) · 추가 전이면 null(원본 보기만) + structureId: + editingId ?? + (selectedPipeChainage !== null ? pipeStructureId(selectedPipeChainage) : null), }).then((result) => { if (result.action === "cancel") return; summary.set(result.action === "apply" ? result.item : null); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css index 0eef5e46..a3895d00 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css @@ -26,14 +26,32 @@ } /* 알약 — 하나만 고른 상태(라디오 묶음). */ +/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */ .b05-routerec__pills { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--spacing-8); } +/* 좁은 폭 — 두 줄을 지키려 글자를 줄이고, 글자가 잘릴 만큼 좁으면(560px 이하) 두 칸 네 줄. */ +@media (max-width: 720px) { + .b05-routerec__pill { + padding-inline: 4px; + font-size: var(--text-body-sm); + } +} + +@media (max-width: 560px) { + .b05-routerec__pills { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + .b05-routerec__pill { - padding: 8px var(--spacing-16); + padding: 8px var(--spacing-8); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; border: 1px solid var(--color-border); border-radius: 999px; background: var(--color-surface); @@ -163,13 +181,73 @@ accent-color: var(--color-primary); } -.b05-routerec__bar output { +/* 값 칸 — 슬라이더 옆에서 숫자를 직접 넣는다. 합 100 을 못 맞추면 빨간 테두리. */ +.b05-routerec__num { + width: 100%; + min-width: 3.2em; + padding: 2px 4px; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); color: var(--color-text-body); font-size: var(--text-body); font-weight: 600; + text-align: center; + appearance: textfield; + -moz-appearance: textfield; } -.b05-routerec__bar span { +.b05-routerec__num::-webkit-outer-spin-button, +.b05-routerec__num::-webkit-inner-spin-button { + margin: 0; + -webkit-appearance: none; +} + +.b05-routerec__num:disabled { + opacity: 0.6; +} + +.b05-routerec__num.is-invalid { + border-color: var(--color-danger, #dc2626); + box-shadow: 0 0 0 1px var(--color-danger, #dc2626); +} + +/* 고정 단추 — 자물쇠. 눌린 것 = 고정 · 저절로 정해진 비중은 눌린 채 꺼져(잠김 표시). */ +.b05-routerec__lock { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 26px; + padding: 0; + border: 1px solid var(--color-border); + border-radius: var(--radius-4, 4px); + background: var(--color-surface); + color: var(--color-text-secondary); + cursor: pointer; +} + +.b05-routerec__lock:hover:not(:disabled) { + border-color: var(--color-primary); +} + +.b05-routerec__lock.is-on { + border-color: var(--color-primary); + background: var(--color-primary); + color: var(--color-on-primary, #fff); +} + +.b05-routerec__lock:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +.b05-routerec__lock.is-auto { + opacity: 0.8; + border-style: dashed; +} + +.b05-routerec__bar > span { color: var(--color-text-secondary); } @@ -185,7 +263,8 @@ gap: var(--spacing-8); } -.b05-routerec__criteria label { +.b05-routerec__criteria label, +.b05-routerec__target { display: grid; grid-template-columns: minmax(0, 1fr) 5.5em 1.5em; align-items: center; @@ -193,7 +272,8 @@ color: var(--color-text-body); } -.b05-routerec__criteria input { +.b05-routerec__criteria input, +.b05-routerec__target input { width: 100%; padding: 4px 8px; border: 1px solid var(--color-border); @@ -204,7 +284,8 @@ text-align: right; } -.b05-routerec__criteria input::placeholder { +.b05-routerec__criteria input::placeholder, +.b05-routerec__target input::placeholder { color: var(--color-text-secondary); } @@ -221,6 +302,13 @@ line-height: 1.5; } +/* 같은 노선 알림 아래 흐린 글. */ +.b05-routerec__lines .is-dim { + color: var(--color-text-secondary); + font-size: var(--text-caption); + opacity: 0.8; +} + /* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */ .b05-routerec__lines .is-warn { color: var(--color-warning); @@ -251,3 +339,7 @@ height: 300px; } } + +.b05-routerec__target[hidden] { + display: none; +} diff --git a/M02_MasterTemplete/M02_Item_Copy.py b/M02_MasterTemplete/M02_Item_Copy.py index f7e3cd8c..7708e664 100644 --- a/M02_MasterTemplete/M02_Item_Copy.py +++ b/M02_MasterTemplete/M02_Item_Copy.py @@ -1,8 +1,11 @@ -"""프로젝트 집계 항목 복사본 읽기 · 쓰기 · 새로 가져오기 · 미리 가져오기 · 계산 (PLAN 25-2b). +"""구조물 집계 항목 복사본 읽기 · 쓰기 · 재적용 · 미리 가져오기 · 개인에 저장 · 새 항목 · 계산 +(PLAN 25-2b · 26-2b). -복사본 = `{프로젝트}/templates/items/{폴더}/` (`M02_Item_Snapshot`). 파일마다 판 = 내용 해시 — -쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 구조물에 안 쓰여도 안 지움. -옛 복사본(새 파일 없음)은 읽을 때 채움(`complete_old`). 계산은 복사본만 씀(마스터 현재값 안 읽음). +복사본 = `{프로젝트}/templates/structures/{구조물 id}/` +(`M02_Item_Snapshot`) · 길의 폴더 = 구조물 id. +파일마다 판 = 내용 해시 — 쓸 때 판이 다르면 `Stale`(409). 고치면 manifest 에 `고침` 을 남겨 지울 때 +`_지난/` 에 옮겨 둠. 옛 복사본(새 파일 없음) · 옛 항목 폴더는 읽을 때 채우고 옮겨 받음. +계산은 복사본만 씀(마스터 현재값 안 읽음) — 원본 항목이 바뀌거나 지워져도 그대로. """ from __future__ import annotations @@ -13,17 +16,25 @@ from pathlib import Path from typing import Any from common_util.common_util_json import atomic_write_json -from common_util.common_util_summary_item import snapshot_folder_name +from M02_MasterTemplete import M02_Item_Personal as personal from M02_MasterTemplete import M02_Item_Prices as prices from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_Item_Snapshot import ( + FOLDER_PATTERN, + LOCAL_FILES, MANIFEST_NAME, PICKS_FILE, VALUES_FILE, build_snapshot, complete_old, + folder_name, items_dir, + migrate_items, now_text, + retire, + same_item, + source_files, + structures_dir, write_snapshot, ) from M02_MasterTemplete.M02_Item_Source import ( @@ -48,7 +59,6 @@ KINDS = { #: 뼈대 검사에 쓰는 층 양식 종류(도면 · 면적산출 · 재료산출) SKELETON = {"도면": "structure", "면적": "area", "재료": "material"} SEATS = ("면적", "재료", "선택", None) -_FOLDER = re.compile(r"^(system|company|personal)_[0-9A-Za-z-]+_SS\d{6}$") _MT = re.compile(r"^MT\d{6}$") @@ -58,11 +68,18 @@ class Stale(ValueError): self.current = current +def _migrated(project_root: str | Path) -> Path: + """구조물 복사본 뿌리 — 옛 항목 폴더가 남았으면 먼저 옮겨 받음.""" + if items_dir(project_root).is_dir(): + migrate_items(project_root) + return structures_dir(project_root) + + def folder_of(project_root: str | Path, folder: str) -> Path: - """복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404).""" - if not _FOLDER.match(folder): + """구조물 복사본 폴더 — 이름 모양이 틀리거나 없으면 LookupError(404).""" + if not FOLDER_PATTERN.match(folder): raise LookupError(f"복사본 폴더 이름 모양이 아님 「{folder}」") - target = items_dir(project_root) / folder + target = _migrated(project_root) / folder if not (target / MANIFEST_NAME).is_file(): raise LookupError(f"복사본이 없음 「{folder}」") complete_old(target) @@ -79,10 +96,10 @@ def source_changed(manifest: dict[str, Any]) -> bool | None: def list_items(project_root: str | Path) -> list[dict[str, Any]]: - base = items_dir(project_root) + base = _migrated(project_root) out = [] for child in sorted(base.iterdir()) if base.is_dir() else []: - if not child.is_dir() or not _FOLDER.match(child.name): + if not child.is_dir() or not FOLDER_PATTERN.match(child.name): continue if not (child / MANIFEST_NAME).is_file(): continue @@ -101,6 +118,7 @@ def list_items(project_root: str | Path) -> list[dict[str, Any]]: "원본없음": changed is None, "구조물": manifest.get("구조물") or [], "고침": bool(manifest.get("고침")), + "값있음": bool(manifest.get("값있음")), } ) return out @@ -215,12 +233,15 @@ def _kept_picks(picks: Any, labor: dict[str, Any]) -> dict[str, Any]: return out -def refresh(project_root: str | Path, folder: str) -> dict[str, Any]: - """층 · 마스터에서 다시 가져옴 — 입력값 · 고른자재는 같은 키면 살림.""" +def reapply(project_root: str | Path, folder: str) -> dict[str, Any]: + """재적용 — 원본(층 · 마스터)을 다시 가져옴 · 입력값 · 고른자재는 같은 키면 살림. + + 원본과 달라 덮인 파일(도면 · 면적 · 재료 · 자재조합 · 입력연결)은 `덮임` 에 알림.""" target = folder_of(project_root, folder) manifest = read_json(target / MANIFEST_NAME) or {} values = read_json(target / VALUES_FILE) picks = read_json(target / PICKS_FILE) + before = {name: read_json(target / file) for name, file in KINDS.items()} files = build_snapshot(manifest["원본"]) labor = files.get(LABOR_FILE) or {} files[VALUES_FILE] = _kept_values(values, labor) @@ -230,24 +251,78 @@ def refresh(project_root: str | Path, folder: str) -> dict[str, Any]: old.unlink() for name, content in files.items(): atomic_write_json(target / name, content) - source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)} + covered = [ + name + for name, file in KINDS.items() + if file not in LOCAL_FILES and before[name] != files.get(file) + ] + source = version_of(source_files(files)) stamp = now_text() - fresh = {**manifest, "판": version_of(source), "원본판": version_of(source), "가져온날": stamp} + # 원본을 다시 가져왔으니 시트 고침은 없음 — 살린 입력값 · 고른 자재는 `값있음` 으로 따로 알림 + kept = bool(files[VALUES_FILE] or files[PICKS_FILE]) + fresh = {**manifest, "판": source, "원본판": source, "가져온날": stamp, "고침": False} + fresh["값있음"] = kept + fresh.pop("고친날", None) atomic_write_json(target / MANIFEST_NAME, fresh) - return {"폴더": folder, "가져온날": stamp, "원본판": fresh["원본판"]} + return {"폴더": folder, "가져온날": stamp, "원본판": source, "덮임": covered, "값있음": kept} -def prefetch(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any]: - """구조물에 아직 안 이은 항목을 미리 가져옴 — 있으면 그대로 · 없으면 새로(쓰는 구조물 없음).""" - folder = snapshot_folder_name(item) - target = items_dir(project_root) / folder +refresh = reapply # 25-1b 옛 이름(새로 가져오기) + + +def prefetch(project_root: str | Path, owner_id: str, item: dict[str, Any]) -> dict[str, Any]: + """구조물 복사본을 미리 가져옴(아직 [저장] 전) — 같은 항목이면 그대로 · 다르면 새로. + + [저장] 뒤에도 그 구조물이 이 항목을 안 쓰면 맞추기가 지움(고친 흔적이 있으면 `_지난/`).""" + folder = folder_name(owner_id) + if not FOLDER_PATTERN.match(folder): + raise ValueError(f"구조물 id 모양이 아님 「{owner_id}」") + target = _migrated(project_root) / folder manifest = read_json(target / MANIFEST_NAME) - made = not isinstance(manifest, dict) + made = not (isinstance(manifest, dict) and same_item(manifest, item)) if made: - manifest = write_snapshot(target, item, [], {"미리가져옴": now_text()}) + if target.is_dir(): + retire(target) + manifest = write_snapshot(target, item, owner_id, {"미리가져옴": now_text()}) return {"폴더": folder, "만듦": made, "가져온날": manifest.get("가져온날")} +def save_as( + project_root: str | Path, folder: str, company: int, user: int, path: list[str], name: str +) -> dict[str, Any]: + """[개인에 저장] — 복사본 내용으로 개인 층에 새 항목(단가 · 입력값 · 고른자재는 안 가져감).""" + target = folder_of(project_root, folder) + manifest = read_json(target / MANIFEST_NAME) or {} + made = personal.create_item( + personal.personal_folder(company, user), + path, + name, + None, + copy=target, + origin=manifest.get("원본"), + ) + return {**made, "summary_item": personal.personal_item(company, user, made)} + + +def new_item( + project_root: str | Path, + owner_id: str, + company: int, + user: int, + path: list[str], + name: str, + unit: str | None, +) -> dict[str, Any]: + """프로젝트에서 새 항목 — 개인 층에 빈 항목을 만들고 그 구조물 복사본으로. + + 돌려준 `summary_item` 을 화면이 그 구조물에 지정해 [저장] 해야 복사본이 남음(안 하면 맞추기가 + 다른 항목 · 지정 없음으로 보고 지움).""" + made = personal.create_item(personal.personal_folder(company, user), path, name, unit) + item = personal.personal_item(company, user, made) + got = prefetch(project_root, owner_id, item) + return {**made, "폴더": got["폴더"], "summary_item": item} + + def calc(project_root: str | Path, folder: str) -> dict[str, Any]: """복사본(입력값 · 고른자재 · 단가 · 일위대가)만으로 계산 — 마스터 현재값을 안 읽음.""" target = folder_of(project_root, folder) diff --git a/M02_MasterTemplete/M02_Item_Personal.py b/M02_MasterTemplete/M02_Item_Personal.py new file mode 100644 index 00000000..806cf7d0 --- /dev/null +++ b/M02_MasterTemplete/M02_Item_Personal.py @@ -0,0 +1,128 @@ +"""프로젝트에서 개인 층으로 — [개인에 저장](save-as) · 프로젝트에서 새 항목(new) (PLAN 26-2b). + +개인 층 새 항목 = 구조물집계표 새 열 하나 + 빈 도면 · 면적산출 · 재료산출(번호 · SS 키는 저장소가 +줌). [개인에 저장]은 구조물 복사본의 열 정의 · 도면 · 면적 · 재료 · 자재조합 · 입력연결을 담음 — +단가 묶음 · 입력값 · 고른자재는 프로젝트 몫이라 안 가져감. 쓰기는 지금 층 저장소 함수(`store.at` · +`store.write` · `matset.create` · 항목 연결 `write_file`) — 나무 고침(`M02_MasterTemplete_Tree`)과 +같은 집계표 저장 한 줄기(경로 · 머리 줄 수 맞춤 · 번호 · SS 장부)를 탐(PLAN 26-2a). +권한(본인 개인 층 · 회사 없음 409)은 부르는 쪽 몫. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store +from M02_MasterTemplete import M02_MasterTemplete_Items as items_store +from M02_MasterTemplete import M02_MasterTemplete_Matset as matset +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers +from M02_MasterTemplete.M02_Item_Source import LINK_FILE, UM_FILE, read_json + +#: 복사본 파일 → 층 양식 종류 +PART_KINDS = (("도면.json", "structure"), ("면적.json", "area"), ("재료.json", "material")) + + +def heads_of(path: list[str], name: str | None) -> list[str]: + """경로 + 이름 → 항목 경로(깊이 자유 · 빈 칸은 뺌) — 머리 줄 수는 집계표 저장이 맞춤.""" + full = [str(p).strip() for p in [*path, name or ""] if str(p or "").strip()] + if not full: + raise ValueError("새 항목의 경로 · 이름이 비어 있습니다.") + return full + + +def _free_path(doc: dict[str, Any], full: list[str]) -> list[str]: + """같은 경로 항목이 이미 있으면 마지막 이름을 「이름 (2)」 로 + (나무는 같은 경로 두 항목을 안 둠).""" + taken = [tree.path_of(c) for c in doc.get("열", []) if tree.is_item(c)] + out, n = list(full), 2 + while out in taken: + out, n = [*full[:-1], f"{full[-1]} ({n})"], n + 1 + return out + + +def _new_matset(row: dict[str, Any], fallback: str) -> str: + """자재 조합 줄을 이 층에 새 키로 — 이름이 겹치면 「이름 (2)」.""" + taken = {str(r.get("이름") or "") for r in matset.listing()["줄"]} + name = base = str(row.get("이름") or fallback) + n = 2 + while name in taken: + name, n = f"{base} ({n})", n + 1 + got = {k: row.get(k) for k in ("담은자재", "소유", "비고")} + return matset.create({**got, "이름": name, "본뜬키": None})["키"] + + +def create_item( + folder: Path, + path: list[str], + name: str | None, + unit: str | None, + copy: Path | None = None, + origin: dict[str, Any] | None = None, +) -> dict[str, Any]: + """개인 층 `folder` 에 새 항목 — `copy` = 구조물 복사본 폴더(있으면 그 내용을 담음). + + 돌려줌 `{key, 열, 이름}`.""" + heads = heads_of(path, name) + total = read_json(copy / "집계.json") if copy else None + base_col = dict((total or {}).get("열") or {}) + base_row = (total or {}).get("줄") or {} + items_store.ensure(folder) + with store.at(folder): + table = store.read("table", store.STRUCTURE_TABLE) + doc = table["문서"] + used = {c.get("id") for c in doc.get("열", []) if isinstance(c, dict)} + new_id = items_store._fresh_id(str(base_col.get("id") or "item").split("@")[0], used) + um_row = read_json(copy / UM_FILE) if copy else None + um_key = _new_matset(um_row, heads[0]) if isinstance(um_row, dict) else None + heads = _free_path(doc, heads) + column = { + **json.loads(json.dumps(base_col)), + "id": new_id, + "머리": heads, + "경로": heads, + "꼴": "수", + "단위": unit or base_col.get("단위") or base_row.get("단위"), + "일위대가": base_col.get("일위대가") or base_row.get("일위대가"), + "자재조합": um_key, + } + column.pop("본뜬열", None) + if origin: + column["본뜬"] = origin + tree._place(doc, column) # 같은 앞마디 형제 곁(제목행 병합이 이어지게) + store.write("table", store.STRUCTURE_TABLE, table["판"], doc) # 빈 세 파일 · 번호 · SS + for file, kind in PART_KINDS if copy else (): + got = read_json(copy / file) + if not isinstance(got, dict): + continue + number = store._KEY[kind] + have = store.read(kind, new_id)["문서"] + body = {k: v for k, v in got.items() if k != "계산값"} + body = {**body, "열": new_id, number: have.get(number)} + if kind in store.SHEETS: + body = store._server_values(body) + with store._LOCK: + atomic_write_json(store._path(kind, new_id), body) + links = read_json(copy / LINK_FILE) if copy else None + if isinstance(links, dict) and links.get("줄"): + inputs_store.write_file(new_id, {"판": 1, "줄": links["줄"]}) + ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) + return {"key": ss["키"], "열": new_id, "이름": tree.name_of(heads)} + + +def personal_item(company: int, user: int, made: dict[str, Any]) -> dict[str, Any]: + """새로 만든 개인 층 항목의 `summary_item`.""" + return { + "layer": "personal", + "owner": f"{company}/{user}", + "key": made["key"], + "name": made["이름"], + } + + +def personal_folder(company: int, user: int) -> Path: + return layers.personal_dir(company, user) diff --git a/M02_MasterTemplete/M02_Item_Snapshot.py b/M02_MasterTemplete/M02_Item_Snapshot.py index cd583500..3cb25ea8 100644 --- a/M02_MasterTemplete/M02_Item_Snapshot.py +++ b/M02_MasterTemplete/M02_Item_Snapshot.py @@ -1,20 +1,24 @@ -"""구조물 집계표 항목 스냅샷 — 프로젝트 `templates/items/{폴더}/` (PLAN 21-1 · 21-3 · 25-2b). +"""구조물 집계 항목 복사본 — 프로젝트 `templates/structures/{구조물 id}/` (PLAN 25-2b · 26-2b). + +복사본은 **구조물마다 하나** — 같은 항목을 고른 구조물 둘도 각자 도면 · 면적 · 재료 · 자재조합 · +일위대가 · 입력연결 · 입력값 · 고른자재 · 단가를 가짐(길이 · 소재 · 높이 · 옵션이 구조물마다 다름). +폴더 이름 = 구조물 id(`structure_id`) · 관 지점은 `pipe@측점`. [저장](`PUT /route/structures` · `PUT /drainage/pipe-points`) 뒤 서버가 맞춤: - - 구조물 · 관 지점이 가리키는 항목(`summary_item`)마다 원본 층에서 내용을 복사 - (`M02_Item_Source.build_source` — 집계 · 도면 · 면적 · 재료 · 자재조합 · 일위대가 · 입력연결 · - 단가) + 프로젝트 값 `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · - `manifest.json` - - 이미 있는 폴더는 내용을 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌어도 프로젝트 - 값은 지정한 때 그대로 · `manifest` 의 쓰는 구조물 목록만 새로([새로 가져오기]는 사용자가) - - 아무 구조물도 안 쓰는 폴더는 지움(지정 바뀜 · 해제 · 구조물 삭제) — 단 프로젝트에서 고친 - 흔적(`manifest.고침`)이 있으면 남김 -원본 층 자리 = `M02_Template_Layers` 경로(시스템 층은 지금 파일) · 일위대가 · 단가는 M01 시스템 것. + - 항목을 지정한 구조물마다 원본 층에서 내용을 복사(`M02_Item_Source.build_source`) + 프로젝트 값 + `입력값.json`(처음엔 빈 값 · 자동값은 안 적음) · `고른자재.json` · `manifest.json` + - 이미 있는 복사본은 다시 쓰지 않음 — 원본(마스터 · 회사 · 개인)이 바뀌거나 지워져도 그대로 + ([재적용]은 사용자가) + - 지정을 다른 항목으로 바꾸면 새로 가져옴 · 구조물을 지우거나 지정을 풀면 복사본을 지움 — + 프로젝트에서 고친 흔적(`manifest.고침`)이 있으면 지우지 않고 `_지난/` 에 옮겨 둠 + - 옛 항목 폴더(`templates/items/{층}_{주인}_{키}/` · 25-2b)는 그 항목을 쓰는 구조물마다 복사해 + 옮겨 받음(고친 값 유지) """ from __future__ import annotations import logging +import re import shutil from datetime import datetime from pathlib import Path @@ -29,118 +33,197 @@ from M02_MasterTemplete.M02_Item_Source import ( LINK_FILE, PRICE_FILE, build_source, + item_path, read_json, version_of, ) logger = logging.getLogger(__name__) -ITEMS_DIRNAME = "items" +STRUCTURES_DIRNAME = "structures" +OLD_ITEMS_DIRNAME = "items" # 25-2b 항목 폴더 — 옮겨 받기만 +PAST_DIRNAME = "_지난" # 고친 흔적이 있는 복사본을 지우지 않고 옮겨 두는 자리 MANIFEST_NAME = "manifest.json" VALUES_FILE = "입력값.json" # {로직키: {입력이름: {값, 확인}}} PICKS_FILE = "고른자재.json" # {로직키: {호표 차례: MT키}} +LOCAL_FILES = (VALUES_FILE, PICKS_FILE) +#: 복사본 폴더 이름 — 구조물 id(영문 · 숫자 · `_` `-`) 또는 `pipe@측점` +FOLDER_PATTERN = re.compile(r"^(?:pipe@\d+(?:\.\d+)?|[0-9A-Za-z][0-9A-Za-z_-]*)$") + + +def structures_dir(project_root: str | Path) -> Path: + return layers.project_dir(project_root) / STRUCTURES_DIRNAME def items_dir(project_root: str | Path) -> Path: - return layers.project_dir(project_root) / ITEMS_DIRNAME + """옛 항목 폴더 뿌리(25-2b) — 옮겨 받기에만 씀.""" + return layers.project_dir(project_root) / OLD_ITEMS_DIRNAME + + +def folder_name(owner_id: str) -> str: + """구조물 id → 복사본 폴더 이름 — id 없는 옛 구조물(`#차례`)은 `n차례`.""" + return owner_id.replace("#", "n") def now_text() -> str: return datetime.now().isoformat(timespec="seconds") +def same_item(manifest: Any, item: dict[str, Any]) -> bool: + origin = manifest.get("원본") if isinstance(manifest, dict) else None + if not isinstance(origin, dict): + return False + return all(origin.get(k) == item.get(k) for k in ("layer", "owner", "key")) + + +def origin_of(item: dict[str, Any]) -> dict[str, Any]: + """manifest `원본` — 층 · 주인 · 키 · 이름 + 경로(26-1 · 가져온 때 층 SS 줄).""" + try: + path = item_path(item) + except LookupError: + path = [] + return {**item, "경로": path} + + def build_snapshot(item: dict[str, Any]) -> dict[str, Any]: """원본 내용 + 빈 프로젝트 값 — `{파일 이름: 내용}` · SS 줄이 없으면 LookupError.""" return {**build_source(item), VALUES_FILE: {}, PICKS_FILE: {}} -def write_snapshot(target: Path, item: dict[str, Any], users: list[str], extra: dict) -> dict: +def source_files(files: dict[str, Any]) -> dict[str, Any]: + return {k: v for k, v in files.items() if k not in LOCAL_FILES} + + +def write_snapshot(target: Path, item: dict[str, Any], owner_id: str, extra: dict) -> dict: """폴더에 새로 씀 — manifest 는 마지막(있으면 다 쓴 폴더 · 끊기면 다음 저장이 다시 씀).""" files = build_snapshot(item) target.mkdir(parents=True, exist_ok=True) for name, content in files.items(): atomic_write_json(target / name, content) - source = {k: v for k, v in files.items() if k not in (VALUES_FILE, PICKS_FILE)} stamp = now_text() + source = version_of(source_files(files)) manifest = { - "원본": item, - "판": version_of(source), - "원본판": version_of(source), + "원본": origin_of(item), + "판": source, + "원본판": source, "적용일": stamp, "가져온날": stamp, - "구조물": users, + "구조물": [owner_id], **extra, } atomic_write_json(target / MANIFEST_NAME, manifest) return manifest -def assignments(project_root: str | Path) -> dict[str, tuple[dict[str, Any], list[str]]]: - """`{폴더 이름: (항목, 쓰는 구조물 id)}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`).""" - found: dict[str, tuple[dict[str, Any], list[str]]] = {} - - def add(item: dict[str, Any] | None, owner_id: str) -> None: - if item: - found.setdefault(snapshot_folder_name(item), (item, []))[1].append(owner_id) - +def assignments(project_root: str | Path) -> dict[str, dict[str, Any]]: + """`{구조물 id: 항목}` — 구조물 정본 + 관 지점 정본(id = `pipe@측점`) · 지정한 것만.""" + found: dict[str, dict[str, Any]] = {} _, structures = load_structures(str(project_root)) for index, structure in enumerate(structures): - add(structure.summary_item, structure.structure_id or f"#{index}") + if structure.summary_item: + found[structure.structure_id or f"#{index}"] = structure.summary_item for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): - add(point.summary_item, f"pipe@{round(float(point.chainage_m), 3)}") + if point.summary_item: + found[f"pipe@{round(float(point.chainage_m), 3)}"] = point.summary_item return found -def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: - """지정 항목 스냅샷을 맞춤 — `{만듦, 지움, 못만듦}` 폴더 이름.""" - base = items_dir(project_root) +def retire(folder: Path) -> str | None: + """지울 복사본 — 고친 흔적이 있으면 `_지난/{이름}_{때}` 로 옮기고 그 이름 · 아니면 지움.""" + manifest = read_json(folder / MANIFEST_NAME) + if isinstance(manifest, dict) and manifest.get("고침"): + past = folder.parent / PAST_DIRNAME + past.mkdir(parents=True, exist_ok=True) + target = past / f"{folder.name}_{datetime.now():%Y%m%d%H%M%S}" + shutil.move(str(folder), str(target)) + return target.name + shutil.rmtree(folder) + return None + + +def migrate_items(project_root: str | Path) -> list[str]: + """옛 항목 폴더 → 그 항목을 쓰는 구조물마다 복사(고친 값 유지) — 옮겨 받은 구조물 폴더 이름. + + 다 옮긴 옛 폴더는 지움 · 쓰는 구조물이 없고 고친 흔적이 있으면 `structures/_지난/` 에 둠.""" + old = items_dir(project_root) + if not old.is_dir(): + return [] + base = structures_dir(project_root) wanted = assignments(project_root) + moved: list[str] = [] + for child in sorted(p for p in old.iterdir() if p.is_dir()): + manifest = read_json(child / MANIFEST_NAME) + if not isinstance(manifest, dict): + continue + users = [o for o, item in wanted.items() if snapshot_folder_name(item) == child.name] + for owner in users: + target = base / folder_name(owner) + if (target / MANIFEST_NAME).is_file(): + continue # 이미 새 복사본이 있음 + shutil.copytree(child, target, dirs_exist_ok=True) + origin = manifest.get("원본") or {} + atomic_write_json( + target / MANIFEST_NAME, + {**manifest, "원본": origin_of(origin), "구조물": [owner], "옮겨받음": now_text()}, + ) + moved.append(target.name) + if users or not manifest.get("고침"): + shutil.rmtree(child) + else: + (base / PAST_DIRNAME).mkdir(parents=True, exist_ok=True) + shutil.move(str(child), str(base / PAST_DIRNAME / f"items_{child.name}")) + if not any(old.iterdir()): + old.rmdir() + return moved + + +def sync_snapshots(project_root: str | Path) -> dict[str, list[str]]: + """구조물 복사본을 지정에 맞춤 — `{만듦, 지움, 못만듦, 옮겨받음}` 폴더 이름.""" + moved = migrate_items(project_root) + base = structures_dir(project_root) + wanted = { + folder_name(owner): (owner, item) for owner, item in assignments(project_root).items() + } made: list[str] = [] failed: list[str] = [] - for folder, (item, users) in sorted(wanted.items()): + removed: list[str] = [] + for folder, (owner, item) in sorted(wanted.items()): target = base / folder manifest = read_json(target / MANIFEST_NAME) - if isinstance(manifest, dict): - if manifest.get("구조물") != users: - atomic_write_json(target / MANIFEST_NAME, {**manifest, "구조물": users}) + if isinstance(manifest, dict) and same_item(manifest, item): continue + if target.is_dir(): # 다른 항목으로 바뀜 — 옛 것은 지우거나(고침이면) `_지난` 에 + retire(target) + removed.append(folder) try: - write_snapshot(target, item, users, {}) + write_snapshot(target, item, owner, {}) except (LookupError, ValueError) as error: - logger.warning("집계 항목 스냅샷을 못 만듦 (%s): %s", folder, error) + logger.warning("집계 항목 복사본을 못 만듦 (%s): %s", folder, error) failed.append(folder) continue made.append(folder) - removed: list[str] = [] if base.is_dir(): for child in sorted(base.iterdir()): - if not child.is_dir() or child.name in wanted: - continue - manifest = read_json(child / MANIFEST_NAME) - if isinstance(manifest, dict) and manifest.get("고침"): - if manifest.get("구조물"): # 고친 복사본은 남기되 쓰는 구조물은 비움 - atomic_write_json(child / MANIFEST_NAME, {**manifest, "구조물": []}) - continue - shutil.rmtree(child) - removed.append(child.name) - return {"만듦": made, "지움": removed, "못만듦": failed} + if child.is_dir() and not child.name.startswith("_") and child.name not in wanted: + retire(child) + removed.append(child.name) + return {"만듦": made, "지움": removed, "못만듦": failed, "옮겨받음": moved} def safe_sync(project_root: str | Path) -> None: - """저장 뒤 부름 — 실패해도 저장은 그대로(스냅샷은 다음 저장이 다시 맞춤).""" + """저장 뒤 부름 — 실패해도 저장은 그대로(복사본은 다음 저장이 다시 맞춤).""" try: result = sync_snapshots(project_root) if any(result.values()): - logger.info("집계 항목 스냅샷: %s", result) + logger.info("집계 항목 복사본: %s", result) except Exception: - logger.exception("집계 항목 스냅샷 맞춤 실패 (저장은 유지): %s", project_root) + logger.exception("집계 항목 복사본 맞춤 실패 (저장은 유지): %s", project_root) def complete_old(folder: Path) -> bool: - """옛 스냅샷(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것. + """옛 복사본(21-3 · 새 파일 없음)을 채움 — 입력연결 · 단가는 지금 원본에서 · 값은 빈 것. - 옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날). - """ + 옛 내용 파일(집계 · 도면 …)은 그대로 둠. 채웠으면 True(manifest 에 `옮겨받음` 날).""" manifest = read_json(folder / MANIFEST_NAME) if not isinstance(manifest, dict): return False @@ -151,15 +234,19 @@ def complete_old(folder: Path) -> bool: ] if not lacking and "원본판" in manifest: return False - source = ( - build_source(manifest["원본"]) if (LINK_FILE in lacking or PRICE_FILE in lacking) else {} - ) + need_source = LINK_FILE in lacking or PRICE_FILE in lacking + try: + source = build_source(manifest["원본"]) if need_source else {} + except LookupError: + source = {} # 원본이 지워짐 — 빈 연결 · 빈 단가로(계산은 멈춤으로 알림) for name in lacking: - atomic_write_json(folder / name, source.get(name, {})) + atomic_write_json( + folder / name, source.get(name, {"판": None, "줄": []} if name == LINK_FILE else {}) + ) kept = { name: read_json(folder / name) for name in sorted(p.name for p in folder.glob("*.json")) - if name not in (MANIFEST_NAME, VALUES_FILE, PICKS_FILE) + if name not in (MANIFEST_NAME, *LOCAL_FILES) } atomic_write_json( folder / MANIFEST_NAME, @@ -167,13 +254,15 @@ def complete_old(folder: Path) -> bool: **manifest, "가져온날": manifest.get("가져온날") or manifest.get("적용일"), "원본판": manifest.get("원본판") or version_of(kept), - "옮겨받음": now_text(), + "옮겨받음": manifest.get("옮겨받음") or now_text(), }, ) return True -def read_snapshot(project_root: str | Path, item: dict[str, Any]) -> dict[str, Any] | None: - """프로젝트 스냅샷의 `집계.json`(`{줄, 열}`) — 없으면 None.""" - data = read_json(items_dir(project_root) / snapshot_folder_name(item) / "집계.json") +def read_snapshot(project_root: str | Path, owner_id: str) -> dict[str, Any] | None: + """그 구조물 복사본의 `집계.json`(`{줄, 열}`) — 없으면 None(옛 항목 폴더는 먼저 옮겨 받음).""" + if items_dir(project_root).is_dir(): + migrate_items(project_root) + data = read_json(structures_dir(project_root) / folder_name(owner_id) / "집계.json") return data if isinstance(data, dict) else None diff --git a/M02_MasterTemplete/M02_Item_Source.py b/M02_MasterTemplete/M02_Item_Source.py index 134c62ce..66b4dca9 100644 --- a/M02_MasterTemplete/M02_Item_Source.py +++ b/M02_MasterTemplete/M02_Item_Source.py @@ -49,18 +49,30 @@ def read_json(path: Path) -> Any: def row_of(document: Any, key: str) -> dict[str, Any] | None: rows = document.get("줄") if isinstance(document, dict) else None for row in rows or []: - if isinstance(row, dict) and row.get("키") == key: + # 옛 키로도 — 회사 · 개인 층 SS 키는 새 모양(SC · SP)으로 옮기며 `옛키` 에 남김(PLAN 26-4a) + if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])): return row return None -def item_name(item: dict[str, Any]) -> str: - """층 SS 줄의 보이는 이름(종류 · 분류1~3) — 없으면 LookupError.""" +def row_path(row: dict[str, Any]) -> list[str]: + """SS 줄의 경로(26-1) — `경로` 칸이 있으면 그것 · 옛 줄은 종류 · 분류1~3(빈 분류는 건너뜀).""" + if isinstance(row.get("경로"), list): + return [str(p) for p in row["경로"] if str(p or "").strip()] + return [str(row[k]) for k in ("종류", "분류1", "분류2", "분류3") if row.get(k)] + + +def item_path(item: dict[str, Any]) -> list[str]: + """층 SS 줄의 경로 — 없으면 LookupError.""" row = row_of(read_json(layer_root(item) / layers.COMBO_FILE), str(item.get("key"))) if row is None: raise LookupError(f"집계 항목 {item.get('key')} 이 {item.get('layer')} 층에 없음") - parts = (row.get(k) for k in ("종류", "분류1", "분류2", "분류3")) - return " ".join(str(p) for p in parts if p) or str(row["키"]) + return row_path(row) or [str(row["키"])] + + +def item_name(item: dict[str, Any]) -> str: + """층 SS 줄의 보이는 이름(경로를 이은 글) — 없으면 LookupError.""" + return " ".join(item_path(item)) def version_of(content: Any) -> str: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts index 52499b89..3a3d6919 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Api_Fetch.ts @@ -403,3 +403,39 @@ export const saveInputLinks = ( 줄: InputLine[], ): Promise<{ 판: string }> => call(itemPath(layer, key, "input-links"), { method: "PUT", body: JSON.stringify({ 판, 줄 }) }); + +/* --- 항목 나무(PLAN 26-1 · 26-3a) — 폴더 경로 깊이 자유 · 고침은 층 쓰기 권한 --- */ +export interface TreeItem { + key: string; + 열: string; + 경로: string[]; + 이름: string; + 단위?: string | null; + 일위대가?: string | null; +} +export interface TreeNote { + op: string; + 경로?: string[]; + 열?: string; + 지운항목?: string[]; +} +export interface TreeView { + 판: string; + 폴더: string[][]; + items: TreeItem[]; + 알림?: TreeNote; +} +export interface TreeEdit { + op: "add-folder" | "add-item" | "rename" | "move" | "delete"; + 경로?: string[]; + 이름?: string; + 새경로?: string[]; + 키?: string; + 단위?: string; +} + +export const fetchTree = (layer: Layer): Promise => call(`/layers/${layer}/tree`); + +/** 판이 다르면 409(`StaleError`) — 부르는 쪽이 다시 읽음 */ +export const editTree = (layer: Layer, edit: TreeEdit, 판: string): Promise => + call(`/layers/${layer}/tree`, { method: "POST", body: JSON.stringify({ ...edit, 판 }) }); diff --git a/M02_MasterTemplete/M02_MasterTemplete_Book.py b/M02_MasterTemplete/M02_MasterTemplete_Book.py new file mode 100644 index 00000000..7cab2074 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Book.py @@ -0,0 +1,147 @@ +"""M02 구조물집계표조합(SS 장부) — 구조물 열마다 줄 하나 · 경로 옮겨 받기(PLAN 26-2a · 26-4a). + +제어용 장부 · 관리자는 집계표 화면(또는 나무)으로 고침 · 서버가 집계표 [저장] 때 맞춤 · +키(층마다 머리가 다름 · 폴더 · 종류 뜻 없는 일련번호 6자리) — + 마스터 `SS000001` · 회사 `SC{회사번호}-000001` · 개인 `SP{사용자번호}-000001` +한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) · 층 사이 복사 · +개인에 저장 · 프로젝트에서 새 항목은 그 층의 새 키(본뜬 곳은 열 `본뜬`). 회사 · 개인 층에 남은 +옛 `SS` 키는 맞출 때 새 모양으로 옮기고(숫자 여섯 그대로 · 겹치면 다음 번호) 옛 키는 줄 `옛키` 에 +남김 — 프로젝트 복사본이 들고 있는 옛 키로도 찾음(`find`). +줄 = `{키, 열, 경로, 이름, 종류, 분류1~3(경로 앞 넷 — 옛 코드가 읽음), 단위, 일위대가, 자재조합, +도번, 면적번, 재료번, 바인딩, 펼침, 옛키?}`. `M02_MasterTemplete_Store.py` 가 커서 떼어 냄 — +저장소는 `store.sync_combos` · `store.read_combos` · `store.find_combo` 이름으로 이 파일을 부름. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers + +HEAD = "SS" # 마스터(과 회사 · 개인 · 프로젝트 층 밖 모든 층) +_OLD = re.compile(r"^SS\d{6}$") +NOTE = ( + "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · " + "키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 " + "지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열" +) + + +def key_head(root: Path) -> str: + """층 폴더 → 키 머리 — 회사 `SC{회사}-` · 개인 `SP{사용자}-` · + 그 밖(마스터 · 프로젝트 사본) `SS`.""" + try: + parts = Path(root).resolve().relative_to(layers.storage_root()).parts + except ValueError: + return HEAD + if len(parts) == 2 and parts[1] == layers.TEMPLATES_DIRNAME: + return f"SC{parts[0]}-" + if len(parts) == 3 and parts[2] == layers.TEMPLATES_DIRNAME: + return f"SP{parts[1]}-" + return HEAD + + +def _number(head: str, key: str) -> int | None: + """그 머리의 키면 여섯 자리 수 · 아니면 None.""" + found = re.fullmatch(re.escape(head) + r"(\d{6})", str(key)) + return int(found.group(1)) if found else None + + +def find(rows: list[dict[str, Any]], key: str) -> dict[str, Any] | None: + """키로 줄 — 지금 키 또는 옮기기 전 옛 키(프로젝트 복사본 · 구조물 기록이 들고 있는 것).""" + key = str(key) + for row in rows: + if isinstance(row, dict) and (row.get("키") == key or key in (row.get("옛키") or [])): + return row + return None + + +def _row( + key: str, col: dict[str, Any], numbers: dict[str, str], olds: list[str] | None = None +) -> dict[str, Any]: + path = tree.path_of(col) + extra = {"옛키": olds} if olds else {} + return { + "키": key, + "열": col["id"], + "경로": path, + "이름": tree.name_of(path), + **tree.old_fields(path), + "단위": col.get("단위"), + "일위대가": col.get("일위대가"), + "자재조합": col.get("자재조합"), + **{field: numbers.get(field, "") for _, field, _, _ in store.PARTS}, + "바인딩": col.get("바인딩"), + "펼침": col.get("펼침"), + **extra, + } + + +def sync() -> dict[str, Any]: + """장부를 집계표에 맞춤 — 새 열 = 그 층 새 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · + 옛 모양 키(회사 · 개인 층의 SS)는 새 모양으로(옛 키는 `옛키`) · 줄 차례 = 집계표 열 차례. + 바뀐 것이 없으면 파일을 안 씀.""" + path = store.root() / store.COMBO_FILE + head = key_head(store.root()) + with store._LOCK: + old = json.loads(path.read_bytes()) if path.is_file() else {} + before = {row["열"]: row for row in old.get("줄", []) if isinstance(row, dict)} + taken = {n for row in before.values() if (n := _number(head, row.get("키"))) is not None} + after = max(old.get("다음", 1), max(taken, default=0) + 1) + numbers = store.structure_numbers() + rows = [] + for col in store._structure_columns(): + was = before.get(col["id"]) or {} + key, olds = was.get("키"), list(was.get("옛키") or []) + if key is not None and _number(head, key) is None: # 옛 모양 — 숫자 여섯 그대로 옮김 + olds.append(key) + digits = int(key[2:]) if _OLD.match(str(key)) else None + key = f"{head}{digits:06d}" if digits and digits not in taken else None + if key: + taken.add(digits) + if key is None: + key = f"{head}{after:06d}" + taken.add(after) + after += 1 + rows.append(_row(key, col, numbers.get(col["id"], {}), olds)) + doc = {"그룹": "구조물집계표조합", "판": 1, "비고": NOTE, "다음": after} + if old.get("지운번호"): + doc["지운번호"] = old["지운번호"] # 지운 열의 번호 — 다시 안 줌 + doc["줄"] = rows + if doc != old: + atomic_write_json(path, doc) + return doc + + +def read() -> dict[str, Any]: + """장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦 · 회사 · 개인 층의 옛 SS 키는 새 + 모양으로 옮김 · `경로` 가 없던 옛 줄은 읽을 때 종류 · 분류1~3 에서 채움(빈 분류 건너뜀).""" + path = store.root() / store.COMBO_FILE + doc = json.loads(path.read_bytes()) if path.is_file() else sync() + head = key_head(store.root()) + if head != HEAD and any(_number(head, r.get("키")) is None for r in doc.get("줄") or []): + doc = sync() # 회사 · 개인 층의 옛 SS 키 — 처음 읽을 때 한 번 새 모양으로(옛키 남김) + for row in doc.get("줄") or []: + if "경로" not in row: + row["경로"] = tree.row_path(row) + row["이름"] = tree.name_of(row["경로"]) + return doc + + +def migrate_paths() -> bool: + """옛 집계표 · 장부 → 경로 모양(열 `경로` · 머리 줄 수 · 장부 `경로` · `이름`) — 한 번 · 두 번 + 돌려도 같음. 바뀐 것이 없으면 파일을 안 씀 · 바꿨으면 참. 지금 층(`store.at`) 기준.""" + got = store.read("table", store.STRUCTURE_TABLE) + if tree.normalize_table(got["문서"]) != got["문서"]: + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + return True + path = store.root() / store.COMBO_FILE + before = path.read_bytes() if path.is_file() else b"" + sync() + return (path.read_bytes() if path.is_file() else b"") != before diff --git a/M02_MasterTemplete/M02_MasterTemplete_Branch.py b/M02_MasterTemplete/M02_MasterTemplete_Branch.py index 47fe3430..802c013e 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Branch.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Branch.py @@ -15,6 +15,7 @@ from M01_MasterData import M01_MasterData_Store as m01 from M01_MasterData import M01_MasterData_Store_Combo as combo_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree _OWNER = "현장" @@ -150,7 +151,7 @@ def branch_material_combo( return matset.create(row)["키"] heads = next((c for c in store._structure_columns() if c["id"] == column), {}) - default = " ".join(h for h in store._heads(heads) if h) if heads else column + default = tree.name_of(tree.path_of(heads)) if heads else column # 경로 이음(26-1) name = name.strip() or str(was.get("이름") or default or column) rows = matset.listing()["줄"] return _branch( diff --git a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts index f1bc2361..6311a601 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_ComboPanel.ts @@ -38,7 +38,7 @@ const TEXT = { empty: "맞는 조합 없음", none: "조합 없음", use: "이 열에 쓰기", - useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물집계표에 바로 적힘 · 조합은 그대로", + useAsk: "이 열이 {value} 을 쓰게 바꿈 — 구조물 조합에 바로 적힘 · 조합은 그대로", useOk: "바꿈", used: "이 열이 {value} 을 씀", loadFailed: "조합 목록을 못 받음 — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py index 92692c6f..348278ef 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.py @@ -45,7 +45,7 @@ def pill_key(logic: str, name: str) -> str: def _item(key: str) -> dict[str, Any]: """지금 층(`store.at`)의 항목 줄 — 없으면 404.""" - row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None) + row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도 if row is None: raise store.StoreError(404, f"없는 항목 「{key}」") return row diff --git a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts index 77a3c59c..586a1378 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Inputs.ts @@ -48,9 +48,9 @@ const TEXT = { count: "로직 {logics} · 입력 {inputs}", chosen: "면적산출 {area} · 재료산출 {material} · 선택 {pick}", auto: "자동값", - noItem: "집계표를 먼저 저장하면 입력 목록이 나옴", - noCombo: "일위대가를 먼저 고름 — 집계표 일위대가 줄에서 고름", - unsavedCombo: "고른 일위대가는 집계표를 저장하면 입력 목록이 나옴", + noItem: "구조물 조합을 먼저 저장하면 입력 목록이 나옴", + noCombo: "일위대가를 먼저 고름 — 구조물 조합 일위대가 줄에서 고름", + unsavedCombo: "고른 일위대가는 구조물 조합을 저장하면 입력 목록이 나옴", noLogic: "이 일위대가에 담긴 로직이 없음", missing: "빠진 로직 {value}", failed: "입력 목록을 못 받음 — {value}", diff --git a/M02_MasterTemplete/M02_MasterTemplete_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Items.py index f535a722..5fbcc4b8 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Items.py @@ -22,6 +22,8 @@ from M02_MasterTemplete import M02_MasterTemplete_Store as store # 21-1 목록 칸 — SS 줄에서 그대로(키는 `key`) ITEM_FIELDS = ( "열", + "경로", + "이름", "종류", "분류1", "분류2", @@ -74,8 +76,9 @@ def _combo_names() -> dict[str, str]: def items(folder: Path) -> list[dict[str, Any]]: - """그 층 항목 — SS 줄마다 `{key, 열, 종류, 분류1~3, 단위, 일위대가, 자재조합, - 도번, 면적번, 재료번, names}` · `names` = 화면 이름(키 대신 · 없으면 null) — + """그 층 항목 — SS 줄마다 `{key, 열, 경로, 이름, 종류, 분류1~3, 단위, 일위대가, 자재조합, + 도번, 면적번, 재료번, names}` · 경로 = 폴더 이름 배열 · 이름 = 경로 이음(26-1 · 종류 · + 분류1~3 은 경로 앞 넷) · `names` = 화면 이름(키 대신 · 없으면 null) — 일위대가 = 조합 이름 · 자재조합 = 그 층 자재 조합 이름 · 도번 · 면적번 · 재료번 = 항목(열) 이름.""" ua = _combo_names() @@ -84,7 +87,7 @@ def items(folder: Path) -> list[dict[str, Any]]: um = {str(r.get("키")): str(r.get("이름") or "") for r in matset.listing()["줄"]} out = [] for r in rows: - head = " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) + head = r.get("이름") names = { "일위대가": ua.get(str(r.get("일위대가"))) if r.get("일위대가") else None, "자재조합": um.get(str(r.get("자재조합"))) if r.get("자재조합") else None, @@ -109,7 +112,7 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d 새 번호 · 새 SS 키 · 열 `본뜬` = `origin`(원래 층 · 주인 · 키). 돌려줌 `{key, 열, 이름}`.""" with store.at(source): - row = next((r for r in store.read_combos().get("줄", []) if r.get("키") == key), None) + row = store.find_combo(key) # 옛 키(회사 · 개인 층 SS)로도 if row is None: raise store.StoreError(404, f"없는 항목 「{key}」") col_id = row["열"] @@ -155,4 +158,4 @@ def copy_item(source: Path, target: Path, key: str, origin: dict[str, Any]) -> d atomic_write_json(store._path(kind, new_id), body) ss = next(r for r in store.read_combos()["줄"] if r.get("열") == new_id) inputs_store.copy_between(source, target, col_id, new_id) # 항목 연결도 같이(판 1) - return {"key": ss["키"], "열": new_id, "이름": " ".join(h for h in store._heads(made) if h)} + return {"key": ss["키"], "열": new_id, "이름": ss["이름"]} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Matset.py b/M02_MasterTemplete/M02_MasterTemplete_Matset.py index 09788153..47784a50 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Matset.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Matset.py @@ -13,6 +13,7 @@ from typing import Any from common_util.common_util_json import atomic_write_json from M01_MasterData import M01_MasterData_Store as m01 from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree mmc = m01.cm.mmc # 자재 조합 틀(`resources/master_data/scripts/master_material_combo.py`) FILE = mmc.FILE @@ -47,7 +48,7 @@ def listing() -> dict[str, Any]: def _name(col: dict) -> str: - return " ".join(h for h in store._heads(col) if h) or col["id"] + return tree.name_of(tree.path_of(col)) or col["id"] # 경로 이음(26-1) def one(key: str) -> dict[str, Any]: diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router.py b/M02_MasterTemplete/M02_MasterTemplete_Router.py index 8a4ebfe6..e4759adb 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router.py @@ -169,7 +169,7 @@ def get_template(kind: str, name: str) -> dict: @router.put("/templates/{kind}/{name}") def put_template(kind: str, name: str, body: SaveBody) -> dict: - return _call(store.write, kind, name, body.판, body.문서) + return _call(store.write, kind, name, body.판, body.문서, True) # 화면 [저장](26-7e) @router.delete("/templates/{kind}/{name}") diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py index 08f8ad31..4399ecce 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Items.py @@ -24,6 +24,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Inputs as inputs_store from M02_MasterTemplete import M02_MasterTemplete_Items as items_store from M02_MasterTemplete import M02_MasterTemplete_Matset as matset from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import ( BranchBody, @@ -44,6 +45,18 @@ class InputLinksBody(BaseModel): 줄: list[Any] = [] +class TreeBody(BaseModel): + """26-1 나무 고침 — `{op, 경로, 이름?, 새경로?, 키?, 판}` (+ 항목 더하기 때 `단위?`).""" + + op: str + 경로: list[str] | None = None + 이름: str | None = None + 새경로: list[str] | None = None + 키: str | None = None + 단위: str | None = None + 판: str + + class CopyItemBody(BaseModel): from_layer: Literal["system", "company"] from_owner: str | None = None @@ -195,6 +208,53 @@ def get_item_pills( return _call(_root(session, layer, owner), inputs_store.pills, key, place) +# ── 폴더 구조(PLAN 26-1 · 26-2a) — 읽기 = 층 읽기 권한 · 고침 = 층 쓰기 권한 ── + + +def _tree_view(folder: Path) -> dict[str, Any]: + """나무 — `{판(집계표), 폴더: [경로 …], items: [항목 …]}` · 폴더 = 빈 폴더 + 항목 앞마디.""" + with store.at(folder): + got = store.read("table", store.STRUCTURE_TABLE) + doc = tree.normalize_table(got["문서"]) # 옛 파일도 경로로 읽음 + return {"판": got["판"], "폴더": tree.folders_of(doc), "items": items_store.items(folder)} + + +@router.get("/layers/{layer}/tree") +def get_tree(layer: Layer, owner: str | None = None, session: Session = Depends(verify_session)): + folder = _root(session, layer, owner) + return _call(folder, _tree_view, folder) + + +def _edit_tree(folder: Path, body: dict[str, Any]) -> dict[str, Any]: + """집계표를 고쳐 집계표 저장 한 줄기로 — 번호 · SS 장부 · 지운 항목의 세 파일 · 입력 연결.""" + got = store.read("table", store.STRUCTURE_TABLE) + if got["판"] != body.get("판"): + raise store.StoreError(409, {"stale": [store.STRUCTURE_TABLE], "판": got["판"]}) + ids = {} # 키 · 옛 키(회사 · 개인 층 SS) → 열 id + for r in store.read_combos().get("줄", []): + for key in [r.get("키"), *(r.get("옛키") or [])]: + ids[str(key)] = str(r.get("열")) + try: + doc, note = tree.apply(got["문서"], body, ids) + except tree.TreeError as e: + raise store.StoreError(e.status, e.detail) from e + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + return {**_tree_view(folder), "알림": note} + + +@router.post("/layers/{layer}/tree") +def post_tree( + layer: Layer, + body: TreeBody, + owner: str | None = None, + session: Session = Depends(verify_session), +): + """폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 → 새 나무 · 판 다르면 409. + 쓰이는 항목을 지워도 프로젝트엔 영향 없음(프로젝트는 복사본을 씀 · 알림의 `지운항목`).""" + folder = _root(session, layer, owner, write=True) + return _call(folder, _edit_tree, folder, body.model_dump()) + + # ── 층 길 — 옛 시스템 길과 같은 모양 ───────────────────── diff --git a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py index 5743a60b..81a867aa 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Router_Layers.py @@ -305,7 +305,7 @@ async def save_layer_template( def _store_save(folder: Path, kind: str, name: str, version: str, doc: Any, layer: str) -> dict: with store.at(folder): try: - info = store.write(kind, name, version, doc) + info = store.write(kind, name, version, doc, screen=True) # 화면 [저장](26-7e) except store.StoreError as error: raise HTTPException(status_code=error.status, detail=error.detail) from error return {"종류": kind, "이름": name, "판": info["판"], "층": layer} diff --git a/M02_MasterTemplete/M02_MasterTemplete_Store.py b/M02_MasterTemplete/M02_MasterTemplete_Store.py index 4aa8d3f1..ced3a7fc 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Store.py +++ b/M02_MasterTemplete/M02_MasterTemplete_Store.py @@ -19,6 +19,7 @@ from typing import Any from common_util import common_util_spreadsheet as spreadsheet from common_util.common_util_json import atomic_write_json +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree from M02_MasterTemplete import M02_Template_Layers as layers FOLDER: Path = Path(__file__).resolve().parent.parent / "resources" / "master_template" @@ -46,9 +47,13 @@ def at(folder: str | Path) -> Iterator[Path]: # 구조물집계표 구조물 열(꼴=수) 마다 세 파일(파일 이름 = 열 id) — # 구조물 도면 `structure/` (도각 없는 전용 화면) · 면적산출 `area/` · 재료산출 `material/` # (둘 다 스프레드시트 통합문서) -# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 8자리 [종류][분류1][분류2][분류3] -# (제목행 = 열 `머리` 네 층 · 없는 층 00) — 한 열의 세 번호는 같은 8자리 · -# 한 번 준 번호는 파일에 남아 안 바뀜(PLAN 13장) +# 번호 = 도번 SD · 면적번 SA · 재료번 SM + 뒷자리 — 한 열의 세 번호는 같은 뒷자리 · 한 번 준 번호는 +# 파일에 남아 안 바뀜(PLAN 13장). 뒷자리 두 모양: +# 옛 8자리 `SD01020300` = [종류][분류1][분류2][분류3] 두 자리씩(그대로 유효 · 더는 새로 안 줌) +# 새 일련 `SD-000001` = 붙임표 + 6자리 · 자리 뜻 없음(PLAN 26-1 · 26-4a) — 옛 8자리 · +# 12장 옛 6자리(`SD010101`)와 글자 모양이 달라 겹치지 않음 · 층마다 따로 셈 +# (파일은 열 id 로 가름 · 번호는 보이는 이름) +# 항목 경로(폴더 이름 배열) = 열 `머리` 의 빈 칸 건너뛴 이름(`M02_MasterTemplete_Tree`) STRUCTURE_TABLE = "구조물집계표" # 프로그램이 이름으로 부르는 양식 — 지우면 그 기능이 죽음(B07 이름은 `B07_DesignDetail_Engine_*`) IN_USE = { @@ -65,8 +70,7 @@ PARTS = ( ) _KEY = {kind: key for kind, key, _, _ in PARTS} SHEETS = ("area", "material") # 통합문서 종류 — [저장] 때 서버가 계산값을 새로 적음 -_LEVELS = 4 -_CODE = re.compile(r"^S[DAM](\d{8})$") +_CODE = re.compile(r"^S[DAM](\d{8}|-\d{6})$") _OLD_CODE = re.compile(r"^S[DCA](\d{6})$") # PLAN 12장 6자리 — 끝에 00 을 붙여 읽음 _BAD_NAME = re.compile(r'[\\/:*?"<>|\x00-\x1f]|\.\.') # 도각 없는 빈 CAD 문서의 기본 도면층 — 없으면 B07 CAD 가 못 엶("Cannot read properties @@ -146,7 +150,7 @@ def _check_skeleton(kind: str, doc: Any) -> None: raise StoreError(400, str(e)) from e -def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: +def write(kind: str, name: str, version: str, doc: Any, screen: bool = False) -> dict[str, Any]: """`version` 이 빈 글이면 새로 만듦(이미 있으면 409) · 아니면 그 판일 때만 덮어씀. 구조물집계표를 저장하면 — 새 구조물 열의 빈 구조물 도면 · 면적산출 · 재료산출을 번호와 같이 만들고 지운 열(또는 구조물이 아니게 된 열)의 세 파일을 지움(잠금 밖에서 · 안에서 부르면 죽음). @@ -154,16 +158,22 @@ def write(kind: str, name: str, version: str, doc: Any) -> dict[str, Any]: (브라우저 값 버림 · 5장 ②) · 알약 `연결` 은 문서와 같이 그대로 적힘. 번호 칸(도번 · 면적번 · 재료번)은 서버 것 — 있던 파일 번호를 그대로 둠(화면 값을 안 받음). 표 열의 `본뜬열`(분류 더하기로 복사한 새 열)은 적지 않음 — 집계표면 아직 파일이 없는 새 열이 - 원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로).""" + 원본 열의 일위대가 · 자재조합 키와 세 파일 내용을 받음(새 번호 · 그 뒤로는 열마다 따로). + `screen` = 화면 표 [저장](PLAN 26-7e) — 있던 항목의 경로 · 층 이름 · 빈 폴더는 저장본 그대로 + (정본 = 나무 · 머리는 경로에서 다시 만듦 · `tree.keep_paths`).""" path = _path(kind, name) _check_skeleton(kind, doc) origins = _take_origins(doc) if kind == "table" else {} + if kind == "table" and name == STRUCTURE_TABLE: + doc = tree.normalize_table(doc) # 경로 · 머리 줄 수 · 빈 폴더(표 저장 · 나무 고침 한 줄기) if kind in SHEETS: doc = _server_values(doc) with _LOCK: have = version_of(path.read_bytes()) if path.is_file() else "" if have != (version or ""): raise StoreError(409, {"stale": [name], "판": have}) + if screen and have and kind == "table" and name == STRUCTURE_TABLE: + doc = tree.normalize_table(tree.keep_paths(doc, json.loads(path.read_bytes()))) if have and kind in _KEY: number = json.loads(path.read_bytes()).get(_KEY[kind]) doc = {**doc, _KEY[kind]: number} if number else doc @@ -201,7 +211,8 @@ def _docs(kind: str) -> list[tuple[str, Any]]: def _code(doc: Any, key: str) -> str | None: - """문서의 번호 8자리 — 12장 6자리는 끝에 00 · 옛 `구-01` · `표-01` · 없음은 None.""" + """문서 번호의 뒷자리 — 옛 8자리 · 새 `-` + 6자리 · 12장 6자리는 끝에 00 · + 옛 `구-01` · `표-01` · 없음은 None.""" text = str(doc.get(key) or "") if isinstance(doc, dict) else "" if found := _CODE.match(text): return found.group(1) @@ -210,7 +221,7 @@ def _code(doc: Any, key: str) -> str | None: def _codes() -> dict[str, str]: - """`{열 id: 8자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번.""" + """`{열 id: 뒷자리}` — 구조물 도면 도번 먼저 · 없으면 면적번 · 재료번.""" codes: dict[str, str] = {} for kind, key, _, _ in reversed(PARTS): codes.update({name: c for name, doc in _docs(kind) if (c := _code(doc, key))}) @@ -228,9 +239,8 @@ def structure_numbers() -> dict[str, dict[str, str]]: def _heads(col: dict[str, Any]) -> tuple[str | None, ...]: - """제목행 네 층(종류 · 분류1 · 분류2 · 분류3) — 빈 층은 None.""" - parts = [*(col.get("머리") or []), *[None] * _LEVELS][:_LEVELS] - return tuple((str(p).strip() if p is not None else "") or None for p in parts) + """옛 네 칸(종류 · 분류1 · 분류2 · 분류3) = 경로 앞 넷 — 없는 칸은 None(다른 코드가 읽음).""" + return tuple(tree.old_fields(tree.path_of(col)).values()) def _structure_columns() -> list[dict[str, Any]]: @@ -247,53 +257,24 @@ def _column_ids() -> list[str]: return [col["id"] for col in _structure_columns()] -def _pick_code( - col: dict[str, Any], - taken: dict[str, str], - heads: dict[str, tuple], - retired: list[str] | None = None, -) -> str: - """새 열 번호 — 층마다 같은 무리(그 층까지 제목행이 같은 열)가 이미 번호를 가졌으면 그 두 자리를 - 이어 받고 · 없으면 그 층 가장 큰 번호 + 1 · 빈 층(종류 밖)은 00 · 맨 아래 층(분류3)은 늘 다음 - 번호. 같은 8자리가 이미 있으면(같은 제목행 · 빈 분류3 겹침) 맨 아래 층을 그 무리 다음 번호로. - `taken` = 이미 번호 있는 열 `{열 id: 8자리}` · `heads` = 열 id → 제목행 · - `retired` = 지운 열의 번호(다시 안 줌 — 「가장 큰 번호」 · 겹침 셈에만 듦).""" - parts = _heads(col) - codes = [*taken.values(), *(retired or [])] - mates = [(heads[cid], code) for cid, code in taken.items() if cid in heads] +_SERIAL = re.compile(r"^-(\d{6})$") - def next_of(prefix: str) -> str: - at = slice(len(prefix), len(prefix) + 2) - found = [int(c[at]) for c in codes if c.startswith(prefix)] - return f"{max(found, default=0) + 1:02d}" - code = "" - for depth, head in enumerate(parts): - if depth and head is None: - code += "00" - continue - at = slice(2 * depth, 2 * depth + 2) - group = parts[: depth + 1] - same = (c[at] for h, c in mates if h[: depth + 1] == group and c.startswith(code)) - leaf = depth == _LEVELS - 1 - code += (None if leaf else next(same, None)) or next_of(code) - if code in codes: - code = code[:-2] + next_of(code[:-2]) - return code +def _serial(taken: dict[str, str], retired: list[str] | None = None) -> str: + """새 항목 번호 뒷자리 — 붙임표 + 일련 6자리(새 모양 번호 · 지운 번호 가운데 가장 큰 수 + 1) · + 옛 8자리는 셈에 안 넣음(모양이 달라 겹칠 일 없음).""" + found = (_SERIAL.match(str(c)) for c in [*taken.values(), *(retired or [])]) + return f"-{max((int(m.group(1)) for m in found if m), default=0) + 1:06d}" def _new_code(column: str) -> str: - """이 열이 쓸 8자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" + """이 열이 쓸 번호 뒷자리 — 짝 파일에 이미 있으면 그것 · 없으면 새로(잠금 안에서 부름).""" taken = _codes() - if column in taken: - return taken[column] - cols = _structure_columns() - col = next((c for c in cols if c["id"] == column), {"id": column}) - return _pick_code(col, taken, {c["id"]: _heads(c) for c in cols}, _retired()) + return taken[column] if column in taken else _serial(taken, _retired()) def _retired() -> list[str]: - """지운 열의 8자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌).""" + """지운 열의 번호 뒷자리 — 조합 장부 `지운번호`(한 번 준 번호는 다시 안 줌).""" path = root() / COMBO_FILE return json.loads(path.read_bytes()).get("지운번호", []) if path.is_file() else [] @@ -321,7 +302,7 @@ def _blank(kind: str, column: str) -> dict[str, Any]: def _ensure(kind: str, column: str, source: str | None = None) -> bool: - """그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 8자리)와 같이 만듦 · 있으면 그대로. + """그 열의 파일이 없으면 빈 문서를 번호(세 파일이 같은 뒷자리)와 같이 만듦 · 있으면 그대로. `source` 열 파일이 있으면 빈 문서 대신 그 내용(알약 `연결` 포함 · 계산값은 서버가 다시 풂).""" path = _path(kind, column) if path.is_file(): @@ -361,8 +342,8 @@ def _take_origins(doc: Any) -> dict[str, str]: def create_structure(column: str) -> dict[str, Any]: - """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 8자리 · 이미 있으면 409. - 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 8자리).""" + """구조물 열 하나의 구조물 도면 새로 — 도번 SD + 새 일련(`-` + 6자리) · 이미 있으면 409. + 면적산출 · 재료산출 파일이 없으면 빈 통합문서도 같이 만듦(같은 뒷자리).""" if column not in _column_ids(): raise StoreError(404, f"{STRUCTURE_TABLE}에 없는 구조물 열 「{column}」") if not _ensure("structure", column): @@ -447,7 +428,7 @@ def _move_basis() -> list[str]: def renumber() -> dict[str, list[str]]: """한 번 — 옛 산출근거를 면적산출로 옮김 · 옛 번호(6자리 · 구-NN · 표-NN · 없음)를 8자리로 - (6자리는 끝에 00 · 없는 열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · + (6자리는 끝에 00 · 번호 없는 열은 열 차례로 일련번호) · 구조물 아닌 열 파일 지움 · 빠진 파일(재료산출 등) 만듦. 내용은 그대로 · 두 번 돌려도 같음.""" moved = _move_basis() gone = prune_removed_structures() @@ -455,12 +436,11 @@ def renumber() -> dict[str, list[str]]: made: list[str] = [] with _LOCK: cols = _structure_columns() - heads = {c["id"]: _heads(c) for c in cols} taken = _codes() retired = _retired() for col in cols: if col["id"] not in taken: - taken[col["id"]] = _pick_code(col, taken, heads, retired) + taken[col["id"]] = _serial(taken, retired) docs = {kind: dict(_docs(kind)) for kind in _KEY} for col in cols: cid = col["id"] @@ -485,7 +465,7 @@ def _server_values(doc: Any) -> dict[str, Any]: def backfill_missing_structures() -> list[str]: - """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(그 무리 다음 번호). + """구조물 열마다 세 파일이 있게 — 없는 것만 열 차례로 새로 만듦(다음 일련번호). 그 사이 남이 만들었으면 그대로 둠.""" made: list[str] = [] for cid in _column_ids(): @@ -493,61 +473,26 @@ def backfill_missing_structures() -> list[str]: return made -# ── 구조물집계표조합 ───────────────────── -# 구조물 열마다 줄 하나(제어용 장부 · 관리자는 집계표 화면으로 고침 · 서버가 집계표로 맞춤) · -# 키 = SS + 6자리 · 한 번 준 키는 그 열에 남음 · 지운 열의 키는 다시 안 씀(`다음` 이 기억) +# ── 구조물집계표조합(SS 장부) — `M02_MasterTemplete_Book` 몫 ───────────────────── COMBO_FILE = layers.COMBO_FILE -_COMBO_HEAD = "SS" -_COMBO_NOTE = ( - "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · " - "키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀" -) - - -def _combo_row(key: str, col: dict[str, Any], numbers: dict[str, str]) -> dict[str, Any]: - kind, level1, level2, level3 = _heads(col) - return { - "키": key, - "열": col["id"], - "종류": kind, - "분류1": level1, - "분류2": level2, - "분류3": level3, - "단위": col.get("단위"), - "일위대가": col.get("일위대가"), - "자재조합": col.get("자재조합"), - **{field: numbers.get(field, "") for _, field, _, _ in PARTS}, - "바인딩": col.get("바인딩"), - "펼침": col.get("펼침"), - } def sync_combos() -> dict[str, Any]: - """조합 표를 집계표에 맞춤 — 새 열 = 새 SS 키 · 지운 열 = 줄 지움 · 고친 열 = 칸 갱신 · - 줄 차례 = 집계표 열 차례. 바뀐 것이 없으면 파일을 안 씀.""" - path = root() / COMBO_FILE - with _LOCK: - old = json.loads(path.read_bytes()) if path.is_file() else {} - keys = {row["열"]: row["키"] for row in old.get("줄", [])} - taken = [int(k[len(_COMBO_HEAD) :]) for k in keys.values()] - after = max(old.get("다음", 1), max(taken, default=0) + 1) - numbers = structure_numbers() - rows = [] - for col in _structure_columns(): - if col["id"] not in keys: - keys[col["id"]] = f"{_COMBO_HEAD}{after:06d}" - after += 1 - rows.append(_combo_row(keys[col["id"]], col, numbers.get(col["id"], {}))) - doc = {"그룹": "구조물집계표조합", "판": 1, "비고": _COMBO_NOTE, "다음": after} - if old.get("지운번호"): - doc["지운번호"] = old["지운번호"] # 지운 열의 도번 8자리 — 다시 안 줌 - doc["줄"] = rows - if doc != old: - atomic_write_json(path, doc) - return doc + """조합 장부를 집계표에 맞춤(`Book.sync`) — 부를 때 읽음(장부 파일이 이 파일을 부름).""" + from M02_MasterTemplete import M02_MasterTemplete_Book as book + + return book.sync() + + +def find_combo(key: str) -> dict[str, Any] | None: + """키(또는 옮기기 전 옛 키)로 장부 줄 — 없으면 None(`Book.find`).""" + from M02_MasterTemplete import M02_MasterTemplete_Book as book + + return book.find(book.read().get("줄", []), key) def read_combos() -> dict[str, Any]: - """조합 표 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦.""" - path = root() / COMBO_FILE - return json.loads(path.read_bytes()) if path.is_file() else sync_combos() + """조합 장부 — 파일이 아직 없으면 지금 열 전부로 처음 한 번 만듦(`Book.read`).""" + from M02_MasterTemplete import M02_MasterTemplete_Book as book + + return book.read() diff --git a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts index d7fc619b..8163f1f6 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_Structure.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_Structure.ts @@ -57,12 +57,12 @@ const TEXT = { none: "고를 것이 없음", pickPrice: "일위대가 고르기", combosFailed: "일위대가 목록을 못 받음 — {value}", - saveFirst: "새 열은 집계표를 먼저 저장해야 번호가 생김", + saveFirst: "새 열은 구조물 조합을 먼저 저장해야 번호가 생김", overwrite: "{from} 의 {what} 내용을 {to} 에 덮어씀 — 번호 · 열은 그대로", pickCopy: "{value} 복사해 가져오기", copied: "복사해 가져옴", copyFailed: "복사 실패 — {value}", - noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물집계표 일위대가 줄에서 고름", + noPrice: "이 열에 일위대가가 없어 알약 없음 — 구조물 조합 일위대가 줄에서 고름", noInputs: "일위대가 {value} 에 입력값이 없어 알약 없음", inputsFailed: "일위대가 입력값을 못 받음 — {value}", }; @@ -180,6 +180,30 @@ export interface StructureColumn { /** 구조물 열 = 꼴=수 — NO · 측점 · 비고 같은 글 열은 도면 · 면적산출 · 재료산출 · 번호가 없음(서버와 같은 규칙) */ export const isStructureColumn = (col: StructureColumn): boolean => col.꼴 === "수"; +/** 관리 화면 「구조물 조합」에서 숨기는 것(PLAN 26-7b) — 비고 열 · 전구간 줄(본문 줄) · 합계 줄. + * 양식 파일은 그대로 — 보일 문서에서만 빼고 [저장] 때 저장본 것을 다시 끼움(`comboMerge`). */ +const HIDDEN_COLUMNS = ["memo"]; +type ComboDoc = { 열: StructureColumn[]; 줄?: unknown[]; 합계줄?: unknown[] }; + +export function comboView(doc: T): T { + const d = doc as unknown as ComboDoc; + const 열 = (d.열 ?? []).filter((c) => !HIDDEN_COLUMNS.includes(c.id)); + return { ...d, 열, 줄: [], 합계줄: [] } as unknown as T; +} + +/** 보인 표 고침 + 저장본의 숨긴 것 — 열 차례는 저장본(표에서 열을 더하거나 지우지 않음 · 26-7d) */ +export function comboMerge(stored: unknown, edited: T): T { + const s = stored as ComboDoc; + const e = edited as unknown as ComboDoc; + const byId = new Map(e.열.map((c) => [c.id, c])); + const kept = (s.열 ?? []).flatMap((c) => + HIDDEN_COLUMNS.includes(c.id) ? [c] : byId.has(c.id) ? [byId.get(c.id)!] : [], + ); + const known = new Set(kept.map((c) => c.id)); + const 열 = [...kept, ...e.열.filter((c) => !known.has(c.id))]; + return { ...e, 열, 줄: s.줄 ?? [], 합계줄: s.합계줄 ?? [] } as unknown as T; +} + /** 구조물 이름 + 변수값 — 제목행 층을 빈칸으로 이음(빈 층은 건너뜀 · 다 비면 열 id) */ export const columnHead = (col: StructureColumn): string => col.머리.filter((part): part is string => !!part && part.trim() !== "").join(" ") || col.id; diff --git a/M02_MasterTemplete/M02_MasterTemplete_Tree.py b/M02_MasterTemplete/M02_MasterTemplete_Tree.py new file mode 100644 index 00000000..32d16e1a --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_Tree.py @@ -0,0 +1,317 @@ +"""M02 집계 항목 **폴더 구조**(PLAN 26-1 · 26-2a) — 항목 경로 · 집계표 맞춤 · 나무 고침. + +항목 = 구조물집계표의 구조물 열(꼴=수) 하나 · 경로 = 폴더 이름 배열(깊이 자유 · 마지막이 항목 이름) +· 이름 = 경로를 이은 글. 자료의 자리는 집계표 한 파일 — 열 `머리`(제목행)가 곧 경로(빈 칸은 건너뜀) +이고, 열에 `경로` 를 같이 적어 둠. 머리 줄 수(`층`)는 가장 깊은 경로에 맞춤(넷보다 적게는 안 줄임 — +옛 코드가 종류 · 분류1~3 넷을 읽음). 빈 폴더는 집계표 `폴더`(경로 목록)에 적음. + +나무 고침(폴더 더하기 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기)은 집계표 문서를 고쳐 +돌려줄 뿐 — 저장은 부르는 쪽이 집계표 저장(`store.write`)으로 해서 표 저장과 한 줄기 +(번호 · SS 장부 · 세 파일 · 입력 연결 파일 지우기)를 탐. 이 파일은 저장소를 부르지 않음(순수 함수). +""" + +from __future__ import annotations + +import copy +import re +from typing import Any + +MIN_LEVELS = 4 # 옛 약속 — 종류 · 분류1 · 분류2 · 분류3 +_OLD_NAMES = ("종류", "분류1", "분류2", "분류3") +OPS = ("add-folder", "add-item", "rename", "move", "delete") +MAX_NAME = 60 +_BAD = re.compile(r"[\x00-\x1f]") + + +class TreeError(Exception): + def __init__(self, status: int, detail: Any): + super().__init__(detail) + self.status, self.detail = status, detail + + +# ── 경로 읽기 ───────────────────────────────────────── + + +def _clean(parts: Any) -> list[str]: + if not isinstance(parts, list): + return [] + return [str(p).strip() for p in parts if p is not None and str(p).strip()] + + +def path_of(col: dict[str, Any]) -> list[str]: + """열의 경로 — 머리(제목행)의 빈 칸을 건너뛴 이름들 · 머리가 없으면 `경로`.""" + heads = col.get("머리") + return _clean(heads) if isinstance(heads, list) else _clean(col.get("경로")) + + +def row_path(row: dict[str, Any]) -> list[str]: + """SS 장부 줄의 경로 — `경로` 가 없던 옛 줄은 종류 · 분류1~3 에서(빈 분류 건너뜀).""" + got = _clean(row.get("경로")) + return got or _clean([row.get(k) for k in _OLD_NAMES]) + + +def name_of(path: list[str]) -> str: + return " ".join(path) + + +def old_fields(path: list[str]) -> dict[str, str | None]: + """옛 칸(종류 · 분류1~3) — 경로 앞 넷(다른 코드가 읽음).""" + padded = [*path, *[None] * MIN_LEVELS][:MIN_LEVELS] + return dict(zip(_OLD_NAMES, padded)) + + +def is_item(col: Any) -> bool: + return isinstance(col, dict) and bool(col.get("id")) and col.get("꼴") == "수" + + +def _items(doc: dict[str, Any]) -> list[dict[str, Any]]: + return [c for c in doc.get("열") or [] if is_item(c)] + + +# ── 집계표 맞춤 ─────────────────────────────────────── + + +def _trimmed(heads: Any) -> list[Any]: + out = list(heads) if isinstance(heads, list) else [] + while out and (out[-1] is None or not str(out[-1]).strip()): + out.pop() + return out + + +def normalize_table(doc: dict[str, Any]) -> dict[str, Any]: + """저장 앞 맞춤 — 항목 열: `경로` = 머리의 빈 칸 건너뛴 이름 · 머리 = 경로를 머리 줄 수만큼 + 채움 · 머리 줄 수(`층`) = 가장 깊은 경로(넷보다 적게는 안 줄임) · + `폴더` = 경로 목록(겹침 · 빈 것 뺌). + 글 열(NO · 측점 · 비고)은 머리 줄 수만 맞춤.""" + out = copy.deepcopy(doc) + cols = [c for c in out.get("열") or [] if isinstance(c, dict)] + depth = max( + [MIN_LEVELS, *(len(path_of(c)) for c in cols if is_item(c))] + + [len(_trimmed(c.get("머리"))) for c in cols if not is_item(c)] + ) + names = [str(n) for n in (out.get("층") or [])][:depth] + names += [_OLD_NAMES[i] if i < MIN_LEVELS else f"분류{i}" for i in range(len(names), depth)] + out["층"] = names + for col in cols: + if is_item(col): + path = path_of(col) + col["경로"] = path + col["머리"] = [*path, *[None] * depth][:depth] + else: + col["머리"] = [*_trimmed(col.get("머리")), *[None] * depth][:depth] + folders: list[list[str]] = [] + for raw in out.get("폴더") or []: + path = _clean(raw) + if path and path not in folders: + folders.append(path) + out["폴더"] = folders + return out + + +def keep_paths(doc: dict[str, Any], stored: dict[str, Any]) -> dict[str, Any]: + """화면 표 [저장](PLAN 26-7e) — 정본은 나무: 있던 항목의 경로(머리) · 층 이름 · 빈 폴더는 + 저장본 그대로 · 브라우저가 보낸 머리는 안 받음. 머리는 저장 맞춤(`normalize_table`)이 경로에서 + 다시 만듦.""" + out = copy.deepcopy(doc) + was = {c["id"]: path_of(c) for c in _items(stored)} + for col in _items(out): + if col["id"] in was: + _set_path(col, was[col["id"]]) + out["층"] = copy.deepcopy(stored.get("층") or []) + out["폴더"] = copy.deepcopy(stored.get("폴더") or []) + return out + + +def folders_of(doc: dict[str, Any]) -> list[list[str]]: + """나무의 폴더 전부 — 적어 둔 빈 폴더 + 항목 경로의 앞마디(차례 = 표 차례).""" + out: list[list[str]] = [] + for col in _items(doc): + path = path_of(col) + for n in range(1, len(path)): + if path[:n] not in out: + out.append(path[:n]) + for path in doc.get("폴더") or []: + path = _clean(path) + if path and path not in out: + out.append(path) + return out + + +# ── 나무 고침 ───────────────────────────────────────── + + +def _name(value: Any, what: str = "이름") -> str: + text = str(value).strip() if isinstance(value, str) else "" + if not text or len(text) > MAX_NAME or _BAD.search(text): + raise TreeError(422, f"「{what}」 — 빈 글 · {MAX_NAME}자 넘음 · 조절 문자는 못 씀") + return text + + +def _path(value: Any, what: str = "경로") -> list[str]: + if value is None: + return [] + if not isinstance(value, list): + raise TreeError(422, f"「{what}」 이 폴더 이름 목록이 아님") + return [_name(v, what) for v in value] + + +def _find(doc: dict[str, Any], key: str | None, ids: dict[str, str]) -> dict[str, Any]: + """항목 키 · 옛 키(또는 열 id)로 항목 열 — 없으면 404.""" + cid = ids.get(str(key), str(key)) if key else None + col = next((c for c in _items(doc) if c["id"] == cid), None) + if col is None: + raise TreeError(404, f"없는 항목 「{key}」") + return col + + +def _item_paths(doc: dict[str, Any], skip: list[dict[str, Any]] | None = None) -> list[list[str]]: + return [path_of(c) for c in _items(doc) if not skip or all(c is not s for s in skip)] + + +def _taken_item(doc: dict[str, Any], path: list[str], skip=None) -> None: + if path in _item_paths(doc, skip): + raise TreeError(409, f"이미 있는 항목 「{name_of(path)}」") + + +def _set_path(col: dict[str, Any], path: list[str]) -> None: + col["경로"] = path + col["머리"] = list(path) # 머리 줄 수는 저장 맞춤(`normalize_table`)이 채움 + + +def _common(a: list[str], b: list[str]) -> int: + n = 0 + while n < min(len(a), len(b)) and a[n] == b[n]: + n += 1 + return n + + +def _place(doc: dict[str, Any], col: dict[str, Any]) -> None: + """열을 가장 가까운 형제 뒤에 끼움(같은 앞마디가 긴 항목 중 마지막) — 제목행 병합이 이어지게. + 항목이 없으면 글 열 가운데 마지막 앞(비고 앞) · 그것도 없으면 끝.""" + cols = doc.setdefault("열", []) + path = path_of(col) + best, at = -1, None + for i, other in enumerate(cols): + if is_item(other): + share = _common(path, path_of(other)) + if share >= best: + best, at = share, i + if at is None: + at = len(cols) - 2 if len(cols) >= 2 else len(cols) - 1 + cols.insert(at + 1, col) + + +def _fresh_id(doc: dict[str, Any]) -> str: + used = {c.get("id") for c in doc.get("열") or [] if isinstance(c, dict)} + n = 1 + while f"item{n}" in used: + n += 1 + return f"item{n}" + + +def _under(path: list[str], folder: list[str]) -> bool: + return path[: len(folder)] == folder + + +def _folder(doc: dict[str, Any], where: list[str], what: str) -> None: + """고칠 폴더가 있는지 — 경로가 비었으면 422 · 없으면 404.""" + if not where: + raise TreeError(422, f"{what} 폴더 「경로」 가 없음") + if where not in folders_of(doc) and not any(_under(p, where) for p in _item_paths(doc)): + raise TreeError(404, f"없는 폴더 「{name_of(where)}」") + + +def apply( + doc: dict[str, Any], body: dict[str, Any], ids: dict[str, str] | None = None +) -> tuple[dict[str, Any], dict[str, Any]]: + """나무 고침 하나 → (새 집계표 문서, 알림). `ids` = SS 키 → 열 id(항목을 키로 부를 때). + 본문 `{op, 경로, 이름?, 새경로?, 키?, 단위?}` — 26-1 약속 모양 · 판은 부르는 쪽이 봄.""" + out = copy.deepcopy(doc) + ids = ids or {} + op = body.get("op") + if op not in OPS: + raise TreeError(422, f"모르는 고침 「{op}」 — {' · '.join(OPS)}") + where = _path(body.get("경로")) + key = body.get("키") + note: dict[str, Any] = {"op": op} + folders = out.setdefault("폴더", []) + + if op == "add-folder": + made = [*where, _name(body.get("이름"))] + if made in folders_of(out) or made in _item_paths(out): + raise TreeError(409, f"이미 있는 폴더 · 항목 「{name_of(made)}」") + folders.append(made) + note["경로"] = made + elif op == "add-item": + made = [*where, _name(body.get("이름"))] + _taken_item(out, made) + col: dict[str, Any] = {"id": _fresh_id(out), "단위": body.get("단위"), "꼴": "수"} + col.update({"일위대가": None, "자재조합": None}) + _set_path(col, made) + _place(out, col) + note.update({"경로": made, "열": col["id"]}) + elif op == "rename": + new = _name(body.get("이름")) + if key: + col = _find(out, key, ids) + path = path_of(col) + moved = [*path[:-1], new] + _taken_item(out, moved, [col]) + _set_path(col, moved) + note["경로"] = moved + else: + _folder(out, where, "바꿀") + moved = [*where[:-1], new] + if moved != where and ( + moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)) + ): + raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」") + _move_folder(out, where, moved) + note["경로"] = moved + elif op == "move": + to = _path(body.get("새경로"), "새경로") + if key: + col = _find(out, key, ids) + moved = [*to, path_of(col)[-1]] + _taken_item(out, moved, [col]) + out["열"] = [c for c in out["열"] if c is not col] + _set_path(col, moved) + _place(out, col) + note["경로"] = moved + else: + _folder(out, where, "옮길") + if _under(to, where): + raise TreeError(422, "폴더를 제 안으로 옮길 수 없음") + moved = [*to, where[-1]] + if moved in folders_of(out) or any(_under(p, moved) for p in _item_paths(out)): + raise TreeError(409, f"이미 있는 폴더 「{name_of(moved)}」") + _move_folder(out, where, moved, replace=True) + note["경로"] = moved + else: # delete + if key: + gone = [_find(out, key, ids)] + else: + _folder(out, where, "지울") + gone = [c for c in _items(out) if _under(path_of(c), where)] + out["폴더"] = [f for f in folders if not _under(_clean(f), where)] + out["열"] = [c for c in out["열"] if all(c is not g for g in gone)] + note["지운항목"] = [g["id"] for g in gone] + return out, note + + +def _move_folder( + doc: dict[str, Any], old: list[str], new: list[str], replace: bool = False +) -> None: + """폴더 `old` 아래 항목 · 빈 폴더의 앞마디를 `new` 로 — 이름 바꾸기는 제자리 · + 옮기기(`replace`)는 새 형제 곁으로 열 차례도 옮김.""" + moving = [c for c in _items(doc) if _under(path_of(c), old)] + if replace: + doc["열"] = [c for c in doc["열"] if all(c is not m for m in moving)] + for col in moving: + _set_path(col, [*new, *path_of(col)[len(old) :]]) + if replace: + _place(doc, col) + doc["폴더"] = [ + [*new, *_clean(f)[len(old) :]] if _under(_clean(f), old) else f + for f in doc.get("폴더") or [] + ] diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css new file mode 100644 index 00000000..7efac906 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.css @@ -0,0 +1,40 @@ +/* 관리 화면 항목 나무 — 줄 모양은 공용 ui-tree · ui-sidebar-row */ +.m02-tree { + padding-top: var(--spacing-8); + display: flex; + flex-direction: column; + gap: var(--spacing-8); + min-width: 0; +} + +.m02-tree__body { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 55vh; + min-height: 4rem; + overflow: auto; + outline: none; +} + +.m02-tree__body .ui-tree__row { + min-width: 0; + overflow-wrap: anywhere; +} + +.m02-tree__hint { + margin: 0; + padding: var(--spacing-8); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-treedlg { + width: min(24rem, calc(100vw - 2 * var(--spacing-16))); +} + +.m02-treedlg .ui-modal__body { + display: flex; + flex-direction: column; + gap: var(--spacing-12); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts new file mode 100644 index 00000000..0d18ec3f --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel.ts @@ -0,0 +1,373 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel.ts + * 관리 화면(마스터 · 회사 · 개인 공용) 좌측 「항목 나무」 — 폴더 → … → 항목(깊이 자유) · 찾기. + * 항목을 누르면 표에서 그 열이 골라짐(카드 넷은 표가 엶) · 표에서 열을 고르면 `select` 로 나무도 그 항목. + * 오른쪽 클릭 · 키보드(F2 · Delete)로 폴더 · 항목 더하기 · 이름 바꾸기 · 옮기기 · 지우기 — + * `POST /api/m02/layers/{층}/tree`(PLAN 26-1). 읽기만 층은 메뉴 · 키 없음. 나무 자료는 `_Model`. + * ========================================================================== */ + +import "./M02_MasterTemplete_TreePanel.css"; +import { createInputField, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { + createTreeRow, + openTreeMenu, + renderTree, + type MakeRow, + type TreeMenuItem, + type TreeNode, +} from "@ui/ui_template_tree"; +import { + StaleError, + editTree, + fetchTree, + type Layer, + type TreeEdit, + type TreeItem, + type TreeNote, + type TreeView, +} from "./M02_MasterTemplete_Api_Fetch"; +import { askFolder, askText } from "./M02_MasterTemplete_TreePanel_Dialogs"; +import { + ancestorsOf, + buildFolders, + countItems, + folderIdOf, + folderOf, + isUnder, + itemIdOf, + leafName, + listFolders, + samePath, + type FolderNode, +} from "./M02_MasterTemplete_TreePanel_Model"; + +export interface TreePanelOptions { + layer: Layer; + /** 이 층을 고칠 수 있나 — 아니면 메뉴 · 키 없음 */ + canEdit: boolean; + /** 항목을 누름 — 표에서 그 열을 고름 */ + onPick: (item: TreeItem) => void; + /** 고치기 전에 저장 안 한 표 고침을 처리 — 취소면 false · 저장했으면 "changed" */ + settle: () => Promise; + /** 나무가 바뀜 — 표를 다시 읽고 그 열(`colId`)을 고름 */ + onChanged: (colId: string | null) => Promise; +} + +export interface TreePanelHandle { + root: HTMLElement; + refresh: () => Promise; + /** 표에서 고른 열을 나무에 표시(null = 지움) */ + select: (colId: string | null) => void; +} + +type Target = { kind: "folder"; path: string[] } | { kind: "item"; item: TreeItem }; + +const why = (error: unknown): string => (error instanceof Error ? error.message : ""); +const hint = (text: string): HTMLElement => el("p", { className: "m02-tree__hint", text }); + +export function createTreePanel(opt: TreePanelOptions): TreePanelHandle { + const layerLabel = L( + opt.layer === "company" + ? "M02_PickerCompany" + : opt.layer === "personal" + ? "M02_PickerPersonal" + : "M02_PickerSystem", + ); + let view: TreeView | null = null; + let text = ""; + let activeCol: string | null = null; + let seq = 0; + let busy = false; + const open = new Set([folderIdOf([])]); + const targets = new Map(); + + const search = createInputField({ placeholder: L("M02_TreeSearch"), compact: true }); + const body = el("div", { className: "m02-tree__body", attrs: { tabindex: "-1" } }); + const root = el("div", { className: "m02-tree", children: [search.root, body] }); + + /* --- 그리기 --- */ + const menuOf = (target: Target): TreeMenuItem[] => { + const isTop = target.kind === "folder" && !target.path.length; + return [ + ...(target.kind === "folder" + ? [ + { label: L("M02_TreeAddFolder"), run: () => void addFolder(target.path) }, + { label: L("M02_TreeAddItem"), run: () => void addItem(target.path) }, + ] + : []), + ...(isTop + ? [] + : [ + { label: L("M02_TreeRename"), run: () => void rename(target) }, + { label: L("M02_TreeMove"), run: () => void move(target) }, + { label: L("M02_Delete"), run: () => void remove(target) }, + ]), + ]; + }; + + function node(target: Target, base: Omit, id: string): TreeNode { + targets.set(id, target); + return { ...base, id, ...(opt.canEdit ? { menu: () => menuOf(target) } : {}) }; + } + + function folderNode(folder: FolderNode, searching: boolean): TreeNode { + const id = folderIdOf(folder.path); + const kids = [ + ...folder.folders.map((one) => folderNode(one, searching)), + ...folder.items.map((item) => + node( + { kind: "item", item }, + { + label: leafName(item), + count: null, + tip: `${item.이름} · ${item.key}`, + run: () => opt.onPick(item), + }, + itemIdOf(item), + ), + ), + ]; + return node( + { kind: "folder", path: folder.path }, + { + label: folder.path.length ? folder.path[folder.path.length - 1] : layerLabel, + count: countItems(folder), + children: kids, + open: searching || open.has(id), + run: () => void (open.has(id) ? open.delete(id) : open.add(id)), + }, + id, + ); + } + + const make: MakeRow = (one, caret, depth, onClick) => { + const button = createTreeRow(one, caret, depth, onClick); + button.dataset.node = one.id; + button.classList.toggle("is-active", !!activeCol && one.id === `i|${activeCol}`); + return button; + }; + + function paint(): void { + const focused = (document.activeElement as HTMLElement | null)?.dataset?.node; + targets.clear(); + if (!view) return body.replaceChildren(hint(L("M02_PickerLoading"))); + const searching = text.trim() !== ""; + const tree = buildFolders(view, text); + const none = countItems(tree) === 0 && (searching || !tree.folders.length); + body.replaceChildren( + ...renderTree([folderNode(tree, searching)], make), + ...(none + ? [ + hint( + L(searching ? "M02_TreeNoHit" : opt.canEdit ? "M02_TreeEmptyEdit" : "M02_TreeEmpty"), + ), + ] + : []), + ); + if (focused) rowOf(focused)?.focus({ preventScroll: true }); + } + + const rowOf = (id: string): HTMLElement | null => + [...body.querySelectorAll("[data-node]")].find((b) => b.dataset.node === id) ?? + null; + + async function refresh(): Promise { + const mine = ++seq; + try { + const got = await fetchTree(opt.layer); + if (mine !== seq) return; + view = got; + } catch (error) { + if (mine !== seq) return; + view = null; + body.replaceChildren(hint(L("M02_PickerLoadFailed").replace("{value}", why(error)))); + return; + } + paint(); + } + + function select(colId: string | null): void { + activeCol = colId; + const need = + view && colId ? ancestorsOf(view, colId).filter((p) => !open.has(folderIdOf(p))) : []; + if (need.length) { + need.forEach((p) => open.add(folderIdOf(p))); + paint(); + } else { + body + .querySelectorAll("[data-node]") + .forEach((b) => + b.classList.toggle("is-active", !!colId && b.dataset.node === `i|${colId}`), + ); + } + if (colId) rowOf(`i|${colId}`)?.scrollIntoView({ block: "nearest" }); + } + + /* --- 고치기 --- */ + /** 저장 안 한 표 고침 처리 → (바뀌었으면 나무 다시 읽음) → 참이면 계속 */ + async function ready(): Promise { + const settled = await opt.settle(); + if (!settled) return false; + if (settled === "changed") await refresh(); + return true; + } + + async function edit(body_: TreeEdit, keepCol: string | null = activeCol): Promise { + if (!view || busy) return; + busy = true; + try { + const got = await editTree(opt.layer, body_, view.판); + view = got; + const note: TreeNote | undefined = got.알림; + // 새 항목은 그 열을 고름 · 지운 항목이면 고름 풀림 · 나머지는 지금 고른 열 그대로 + const gone = note?.지운항목 ?? []; + const col = note?.열 ?? (keepCol && !gone.includes(keepCol) ? keepCol : null); + activeCol = col; + const parent = note?.경로 ? note.경로.slice(0, -1) : []; + parent.forEach((_, i) => open.add(folderIdOf(parent.slice(0, i + 1)))); + if (note?.op === "add-folder" || note?.op === "rename" || note?.op === "move") { + if (note.경로) open.add(folderIdOf(note.경로)); + } + paint(); + showToast(L("M02_TreeDone"), "success"); + await opt.onChanged(col); + } catch (error) { + // 409 = 판 다름(서버 detail 이 객체 → 「HTTP 409」) 또는 같은 이름(글 까닭 — 그대로 보임) + if (error instanceof StaleError && error.message === "HTTP 409") { + await refresh(); + showToast(L("M02_TreeStale"), "error"); + } else { + showToast(L("M02_ActionFailed").replace("{value}", why(error)), "error"); + } + } finally { + busy = false; + } + } + + const nameOf = (target: Target): string => + target.kind === "item" ? leafName(target.item) : target.path[target.path.length - 1]; + + async function addFolder(parent: string[]): Promise { + if (!(await ready())) return; + const got = await askText(L("M02_TreeAddFolder"), [ + { key: "name", label: L("M02_TreeFolderName") }, + ]); + if (got) await edit({ op: "add-folder", 경로: parent, 이름: got.name }); + } + + async function addItem(parent: string[]): Promise { + if (!(await ready())) return; + const got = await askText(L("M02_TreeAddItem"), [ + { key: "name", label: L("M02_TreeItemName") }, + { key: "unit", label: L("M02_TreeUnit"), optional: true }, + ]); + if (got) { + await edit({ + op: "add-item", + 경로: parent, + 이름: got.name, + ...(got.unit ? { 단위: got.unit } : {}), + }); + } + } + + async function rename(target: Target): Promise { + if (!(await ready())) return; + const got = await askText(L("M02_TreeRename"), [ + { + key: "name", + label: L(target.kind === "item" ? "M02_TreeItemName" : "M02_TreeFolderName"), + value: nameOf(target), + }, + ]); + if (!got || got.name === nameOf(target)) return; + await edit( + target.kind === "item" + ? { op: "rename", 경로: folderOf(target.item), 키: target.item.key, 이름: got.name } + : { op: "rename", 경로: target.path, 이름: got.name }, + ); + } + + async function move(target: Target): Promise { + if (!(await ready()) || !view) return; + const from = target.kind === "item" ? folderOf(target.item) : target.path.slice(0, -1); + const to = await askFolder( + L("M02_TreeMove"), + listFolders(buildFolders(view)), + (path) => target.kind === "folder" && isUnder(path, target.path), + from, + ); + if (!to || samePath(to, from)) return; + await edit( + target.kind === "item" + ? { op: "move", 경로: from, 키: target.item.key, 새경로: to } + : { op: "move", 경로: target.path, 새경로: to }, + ); + } + + async function remove(target: Target): Promise { + if (!(await ready()) || !view) return; + const folder = + target.kind === "folder" ? findFolder(buildFolders(view), target.path) : undefined; + const message = + target.kind === "item" + ? L("M02_TreeDeleteItem").replace("{value}", leafName(target.item)) + : L("M02_TreeDeleteFolder") + .replace("{value}", nameOf(target)) + .replace("{n}", String(folder ? countItems(folder) : 0)); + if (!(await showConfirmDialog(message, L("M02_Delete")))) return; + await edit( + target.kind === "item" + ? { op: "delete", 경로: folderOf(target.item), 키: target.item.key } + : { op: "delete", 경로: target.path }, + ); + } + + const findFolder = (from: FolderNode, path: string[]): FolderNode | undefined => + samePath(from.path, path) + ? from + : from.folders.map((one) => findFolder(one, path)).find(Boolean); + + /* --- 이음 --- */ + search.input.addEventListener("input", () => { + text = search.input.value; + paint(); + }); + + // 빈 자리 오른쪽 클릭 = 맨 위 메뉴(폴더 · 항목 더하기) + body.addEventListener("contextmenu", (event) => { + if (!opt.canEdit || (event.target as HTMLElement).closest("[data-node]")) return; + event.preventDefault(); + openTreeMenu(event, menuOf({ kind: "folder", path: [] }).slice(0, 2)); + }); + + body.addEventListener("keydown", (event) => { + const row = (event.target as HTMLElement).closest("[data-node]"); + if (!row) return; + const rows = [...body.querySelectorAll("[data-node]")].filter( + (b) => !b.closest("[hidden]"), + ); + const at = rows.indexOf(row); + const caret = row.querySelector(".ui-tree__caret")?.textContent; + const target = targets.get(row.dataset.node ?? ""); + if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + rows[at + (event.key === "ArrowDown" ? 1 : -1)]?.focus(); + } else if ( + (event.key === "ArrowRight" && caret === "▸") || + (event.key === "ArrowLeft" && caret === "▾") + ) { + event.preventDefault(); + row.click(); + } else if (opt.canEdit && target && event.key === "F2") { + event.preventDefault(); + if (!(target.kind === "folder" && !target.path.length)) void rename(target); + } else if (opt.canEdit && target && event.key === "Delete") { + event.preventDefault(); + if (!(target.kind === "folder" && !target.path.length)) void remove(target); + } + }); + + return { root, refresh, select }; +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts new file mode 100644 index 00000000..962b31c4 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Dialogs.ts @@ -0,0 +1,142 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Dialogs.ts + * 항목 나무 오른쪽 클릭 메뉴가 띄우는 작은 창 — 글 받기 · 옮길 폴더 고르기 · 저장/버림 물음. + * 모두 약속(Promise)으로 답함 — 닫기 · Esc · 바깥 누르기 = 취소(null / "cancel"). + * ========================================================================== */ + +import { + createButton, + createInputField, + createSelectField, + el, + showToast, +} from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; +import { openModal } from "@ui/ui_template_modal"; + +export interface TextField { + key: string; + label: string; + value?: string; + /** 비워도 됨(항목 단위) */ + optional?: boolean; +} + +/** 글 칸 하나 이상을 받음 — 확인이면 칸 이름 → 글(앞뒤 공백 뺌) */ +export function askText( + title: string, + fields: TextField[], + okLabel = L("M02_TreeOk"), +): Promise | null> { + return new Promise((resolve) => { + let done = false; + const finish = (value: Record | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const inputs = fields.map((f) => ({ + f, + field: createInputField({ label: f.label, value: f.value }), + })); + const ok = createButton({ label: okLabel, variant: "filled" }); + const submit = (): void => { + const out: Record = {}; + for (const { f, field } of inputs) { + out[f.key] = field.input.value.trim(); + if (!out[f.key] && !f.optional) return showToast(L("M02_NameNeeded"), "error"); + } + finish(out); + close(); + }; + ok.addEventListener("click", submit); + for (const { field } of inputs) { + field.input.addEventListener("keydown", (event) => { + if (event.key === "Enter" && !event.isComposing) submit(); + }); + } + body.append(...inputs.map(({ field }) => field.root), ok); + inputs[0]?.field.input.select(); + }, + }); + }); +} + +/** 옮길 폴더 고르기 — `folders` 에서 `blocked` 인 것은 뺌 · 맨 위(폴더 밖)는 늘 있음 */ +export function askFolder( + title: string, + folders: string[][], + blocked: (path: string[]) => boolean, + current: string[], +): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: string[] | null): void => { + if (done) return; + done = true; + resolve(value); + }; + const choices = [[], ...folders.filter((path) => !blocked(path))]; + openModal({ + title, + closeLabel: L("M02_Close"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish(null), true), + mount: (body, close) => { + const field = createSelectField({ + label: L("M02_TreeMoveTo"), + options: choices.map((path, i) => ({ + value: String(i), + text: path.length ? path.join(" / ") : L("M02_TreeTop"), + })), + value: String(choices.findIndex((path) => path.join("|") === current.join("|"))), + }); + const ok = createButton({ label: L("M02_TreeOk"), variant: "filled" }); + ok.addEventListener("click", () => { + finish(choices[Number(field.select.value)] ?? []); + close(); + }); + body.append(field.root, ok); + }, + }); + }); +} + +export type Settle = "save" | "discard" | "cancel"; + +/** 저장 안 한 표 고침 — 저장하고 계속 · 버리고 계속 · 취소 */ +export function askSettle(): Promise { + return new Promise((resolve) => { + let done = false; + const finish = (value: Settle): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: L("M02_TreeUnsaved"), + closeLabel: L("M02_TreeCancel"), + dialogClass: "m02-modal m02-treedlg", + beforeClose: () => (finish("cancel"), true), + mount: (body, close) => { + const choose = (value: Settle) => () => { + finish(value); + close(); + }; + const save = createButton({ label: L("M02_TreeSaveGo"), variant: "filled" }); + const discard = createButton({ label: L("M02_TreeDiscardGo"), variant: "danger" }); + const cancel = createButton({ label: L("M02_TreeCancel"), variant: "ghost" }); + save.addEventListener("click", choose("save")); + discard.addEventListener("click", choose("discard")); + cancel.addEventListener("click", choose("cancel")); + body.append(el("div", { className: "m02-side__row", children: [save, discard, cancel] })); + }, + }); + }); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts new file mode 100644 index 00000000..4643cf08 --- /dev/null +++ b/M02_MasterTemplete/M02_MasterTemplete_TreePanel_Model.ts @@ -0,0 +1,67 @@ +/* ============================================================================= + * M02_MasterTemplete_TreePanel_Model.ts + * 관리 화면 항목 나무의 자료 쪽(화면 없음) — 서버 나무(`폴더` + `items`)를 폴더 나무로 · 찾기 · + * 폴더 목록. 항목 경로는 폴더 이름 배열 + 마지막 마디가 항목 이름(PLAN 26-1 · 깊이 자유). + * ========================================================================== */ + +import type { TreeItem, TreeView } from "./M02_MasterTemplete_Api_Fetch"; + +export interface FolderNode { + /** 이 폴더까지의 경로 — 맨 위 = 빈 배열 */ + path: string[]; + folders: FolderNode[]; + items: TreeItem[]; +} + +export const samePath = (a: string[], b: string[]): boolean => + a.length === b.length && a.every((part, i) => part === b[i]); + +/** `path` 가 `folder` 와 같거나 그 안인가 */ +export const isUnder = (path: string[], folder: string[]): boolean => + folder.length <= path.length && folder.every((part, i) => part === path[i]); + +export const folderIdOf = (path: string[]): string => `f|${path.join("|")}`; +export const itemIdOf = (item: TreeItem): string => `i|${item.열}`; +export const leafName = (item: TreeItem): string => item.경로[item.경로.length - 1] ?? item.이름; +export const folderOf = (item: TreeItem): string[] => item.경로.slice(0, -1); + +/** 찾기 — 경로 · 키 · 일위대가 키에 모든 낱말이 들어 있는 항목(빈 글이면 전부) */ +export function matches(item: TreeItem, text: string): boolean { + const words = text.trim().toLowerCase().split(/\s+/).filter(Boolean); + const hay = [...item.경로, item.key, item.일위대가 ?? ""].join(" ").toLowerCase(); + return words.every((word) => hay.includes(word)); +} + +/** 폴더 나무 — 찾는 글이 있으면 맞는 항목과 그 위 폴더만 · 없으면 빈 폴더까지 전부 */ +export function buildFolders(view: TreeView, text = ""): FolderNode { + const searching = text.trim() !== ""; + const root: FolderNode = { path: [], folders: [], items: [] }; + const at = (path: string[]): FolderNode => + path.reduce((node, name, i) => { + let next = node.folders.find((f) => f.path[i] === name); + if (!next) { + next = { path: path.slice(0, i + 1), folders: [], items: [] }; + node.folders.push(next); + } + return next; + }, root); + if (!searching) view.폴더.forEach(at); + for (const item of view.items) if (matches(item, text)) at(folderOf(item)).items.push(item); + return root; +} + +export const countItems = (node: FolderNode): number => + node.items.length + node.folders.reduce((sum, f) => sum + countItems(f), 0); + +/** 모든 폴더 경로(맨 위 제외) — 옮길 곳 고르기 */ +export function listFolders(root: FolderNode): string[][] { + return root.folders.flatMap((f) => [f.path, ...listFolders(f)]); +} + +/** 그 열이 들어 있는 폴더 길 — 나무를 그 항목까지 펼칠 때 */ +export function ancestorsOf(view: TreeView, colId: string): string[][] { + const item = view.items.find((one) => one.열 === colId); + if (!item) return []; + const folder = folderOf(item); + return folder.map((_, i) => folder.slice(0, i + 1)); +} diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts index cad56f18..78b7e7da 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Main.ts @@ -17,6 +17,8 @@ import { type StructureDoc, } from "./M02_MasterTemplete_Api_Fetch"; import { + comboMerge, + comboView, createStructurePanel, pillsFor, STRUCTURE_TABLE, @@ -24,6 +26,7 @@ import { type StructurePanelHandle, } from "./M02_MasterTemplete_Structure"; import { inputPills } from "./M02_MasterTemplete_Inputs"; +import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs"; import { buildComboBar } from "./M02_MasterTemplete_Combo"; import { buildComboPanel, type ComboPanel } from "./M02_MasterTemplete_ComboPanel"; import { @@ -91,6 +94,10 @@ export interface MainHandle { confirmLeave: () => Promise; /** 양식 밖 화면(좌측 자재 조합 상세)을 띄움 — 머리는 제목만 · 고침 표시 손잡이를 돌려줌 */ showView: (label: string, node: HTMLElement) => (dirty: boolean) => void; + /** 구조물집계표를 열고 그 열을 고름 — 이미 표면 다시 안 읽음(`fresh` 면 다시 읽음) */ + openTable: (layer: Layer, colId?: string | null, fresh?: boolean) => Promise; + /** 나무를 고치기 전 — 저장 안 한 고침이 있으면 저장 · 버림 · 취소를 물음 */ + settle: () => Promise; } /** 이 층에서 고칠 수 있나 — `isAdmin` = 이 화면 층의 고칠 권한(페이지가 층별로 정함) */ @@ -98,9 +105,11 @@ export function canEdit(layer: Layer, isAdmin: boolean): boolean { return layer === "project" || isAdmin; } -/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 */ +/** 페이지가 끼우는 것 — `top` = 편집 칸 맨 위 막대(회사용 읽기만 안내) · `onColumn` = 집계표에서 고른 열 · + * `aside` = 「구조물 조합」을 열 때만 편집 칸 왼쪽에 보임(항목 나무 · PLAN 26-7c) */ export interface MainExtras { top?: HTMLElement; + aside?: HTMLElement; onColumn?: (column: string | null) => void; } @@ -120,7 +129,7 @@ export function createMain( const notice = el("div", { className: "m02-main__notice", attrs: { hidden: "" } }); const host = el("div", { className: "m02-main__host" }); const extra = el("div", { className: "m02-main__extra" }); // 표 밖 형제 자리 — 구조물 설계 컨테이너 - const root = el("div", { + const column = el("div", { className: "m02-main", children: [ ...(extras.top ? [extras.top] : []), @@ -130,6 +139,15 @@ export function createMain( extra, ], }); + // 왼쪽 나무(주) · 오른쪽 편집 칸 — 나무는 「구조물 조합」일 때만(`showAside`) + const root = extras.aside + ? el("div", { className: "m02-shell", children: [extras.aside, column] }) + : column; + const isCombo = (at: Selection | null): boolean => + !!at && at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"; + const showAside = (): void => { + if (extras.aside) extras.aside.hidden = !isCombo(sel); + }; let sel: Selection | null = null; let version = ""; @@ -355,9 +373,11 @@ export function createMain( // 그 뒤 .열 을 그대로 읽으면 죽는다(2026-09-28 사용자 보고 — 열 고른 뒤 CAD // 불러오기 오류). getDoc() 뒤에도 옵셔널 체이닝을 이어야 한다. const columns = (): StructureColumn[] => sheet?.getDoc()?.열 ?? []; - editor = create(host, doc, { + // 「구조물 조합」 = 나무를 따라오는 참조표(26-7b · d) — 비고 · 전구간 · 합계 숨김 · 열 · 머리는 나무만 + editor = create(host, struct ? comboView(doc) : doc, { mode: at.layer === "project" ? "project" : "master", readOnly, + lockHead: !!struct, onChange: () => (dirty = !readOnly), ...(struct ? { @@ -377,7 +397,11 @@ export function createMain( } : {}), }); - if (struct) sheet = editor as SheetEditorHandle; + if (!struct) return; + const shown = editor as SheetEditorHandle; + sheet = shown; + // [저장]은 저장본의 숨긴 것을 다시 끼운 문서(서버가 머리를 경로에서 다시 만듦 · 26-7e) + editor = { getDoc: () => comboMerge(loaded, shown.getDoc()), destroy: shown.destroy }; return; } if (at.kind === "area" || at.kind === "material") { @@ -416,11 +440,12 @@ export function createMain( sel = next; onOpened?.(); bar(); + showAside(); if (!next) { title.textContent = L("M02_PickTemplate"); return empty(""); } - title.textContent = next.label ?? next.name; + title.textContent = isCombo(next) ? L("M02_KindTable") : (next.label ?? next.name); // 26-7a try { const got = await readTemplate(next.layer, next.kind, next.name, next.projectId); if (mine !== seq) return; @@ -496,11 +521,34 @@ export function createMain( sel = null; onOpened?.(); // 좌측 양식 · 열 줄 고름 지움 bar(); + showAside(); title.textContent = label; host.replaceChildren(node); return (d) => (dirty = d); } + async function openTable(layer: Layer, colId?: string | null, fresh = false): Promise { + const inTable = sel?.kind === "table" && sel.name === STRUCTURE_TABLE && !fresh; + if (!inTable) { + if (!fresh && !(await confirmLeave())) return; + await open({ layer, projectId: null, kind: "table", name: STRUCTURE_TABLE }); + } + if (colId && sel?.kind === "table") sheet?.selectColumn(colId); + } + + async function settle(): Promise { + if (!dirty) return "clean"; + const choice = await askSettle(); + if (choice === "cancel") return false; + if (choice === "save") { + await doSave(); + return dirty ? false : "changed"; // 저장이 막히면(판 다름 등) 나무도 안 고침 + } + dirty = false; // 버림 — 화면을 서버 그대로 다시 읽음 + if (sel) await open(sel); + return "changed"; + } + save.addEventListener("click", () => void doSave()); back.addEventListener("click", async () => { const column = sel?.name; @@ -517,6 +565,7 @@ export function createMain( if (sel && (await confirmLeave())) void open(sel); }); bar(); + showAside(); empty(""); - return { root, open, current: () => sel, confirmLeave, showView }; + return { root, open, current: () => sel, confirmLeave, showView, openTable, settle }; } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts index eca6beac..07afe8f7 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Page.ts @@ -1,6 +1,7 @@ /* ============================================================================= * M02_MasterTemplete_UI_Page.ts - * 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) + * 마스터 템플릿 · 구조물 관리 화면 — 좌측 도킹 패널(양식 목록) / 우측 편집 칸(표·도면 부품) · + * 「구조물 조합」을 열면 편집 칸 왼쪽에 항목 나무(주 · 고치는 자리) · 오른쪽 참조표(PLAN 26-7c) * * 층 = 주소 인자 `?layer=personal|company`(없으면 시스템) — 같은 화면 한 벌. * 화면은 로그인만 확인 — 고치기는 층별 권한일 때만 켬(서버도 막음 · `M02_MasterTemplete_Layer.ts`). @@ -8,6 +9,7 @@ import "@ui/ui_template_workflow_layout.css"; import { el } from "@ui/ui_template_elements"; +import { t as L } from "@ui/ui_template_locale"; import { createWorkflowOverlays } from "@ui/ui_template_overlay"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; import { setActiveLayer } from "./M02_MasterTemplete_Api_Fetch"; @@ -19,7 +21,8 @@ import { titleOf, } from "./M02_MasterTemplete_Layer"; import { STRUCTURE_TABLE } from "./M02_MasterTemplete_Structure"; -import { createMain, type Selection } from "./M02_MasterTemplete_UI_Main"; +import { createTreePanel } from "./M02_MasterTemplete_TreePanel"; +import { canEdit, createMain, type Selection } from "./M02_MasterTemplete_UI_Main"; import { buildSide } from "./M02_MasterTemplete_UI_Side"; import "./M02_MasterTemplete_UI_Style.css"; @@ -35,11 +38,30 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise const isAdmin = access.canWrite; // 이 층을 고칠 수 있나(하위 부품의 「관리자」 = 같은 뜻) // 회사용을 읽기만 볼 때 — 안내 + [개인용으로 복사] const readOnlyBar = layer === "company" && !isAdmin ? buildReadOnlyBar(user) : null; + // 항목 나무 — 고르기 모달과 같은 폴더 나무(세 층 공용) · 「구조물 조합」 편집 칸 왼쪽 + const tree = createTreePanel({ + layer, + canEdit: canEdit(layer, isAdmin), + onPick: (item) => void main.openTable(layer, item.열), + settle: () => main.settle(), + onChanged: (colId) => main.openTable(layer, colId, true), + }); + const aside = el("aside", { + className: "m02-shell__tree", + children: [el("h3", { className: "m02-shell__title", text: L("M02_TreeTitle") }), tree.root], + }); const main = createMain( isAdmin, - () => void side.refresh(), + () => void Promise.all([side.refresh(), tree.refresh()]), () => side.paint(), - { top: readOnlyBar?.root, onColumn: (column) => readOnlyBar?.setColumn(column) }, + { + top: readOnlyBar?.root, + aside, + onColumn: (column) => { + readOnlyBar?.setColumn(column); + tree.select(column); + }, + }, ); // 첫 화면 · 닫은 뒤(양식 지움 · 자재 조합 상세 닫음) = 구조물집계표 — 빈 화면 없음 const home: Selection = { @@ -74,4 +96,5 @@ export async function renderM02MasterTemplate(root: HTMLElement): Promise root.innerHTML = ""; root.append(layout); void main.open(home); + void tree.refresh(); } diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts index 4597dd7a..0fae8baf 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Side.ts @@ -1,10 +1,12 @@ /* ============================================================================= * M02_MasterTemplete_UI_Side.ts - * 마스터 템플릿 좌측 패널 — 「구조물 집계 양식」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · + * 마스터 템플릿 좌측 패널 — 「구조물 조합」(구조물집계표 줄 하나 + 하위 구조물 도면 · 면적산출 · * 재료산출 · 자재 조합) · 「도면 양식」(새로 · 본떠 · 지우기). 서버 길은 계약 `6_계약.md`. * 구조물 도면 · 면적산출 · 재료산출 줄은 집계표 구조물 열(꼴=수)마다 하나씩 — * 서버가 미리 만들어 둠(도번 SD · 면적번 SA · 재료번 SM + 같은 8자리) · * 줄 글은 번호가 맨 앞(「SD01010100 돌기슭막이 …」). 「자재 조합」 컨테이너는 `M02_MasterTemplete_Matset.ts`. + * 화면 이름 「구조물 조합」(PLAN 26-7a) — 파일 이름 · 양식 키 · API 길은 「구조물집계표」 그대로. + * 항목 나무는 좌측이 아니라 「구조물 조합」 상세 화면 왼쪽(PLAN 26-7c · 페이지가 붙임). * ========================================================================== */ import { @@ -191,7 +193,8 @@ export function buildSide(opt: SideOptions): SideHandle { ? mine.map((info) => { const b = el("button", { className: `m02-side__item ui-sidebar-row${isOpen(info) ? " is-active" : ""}`, - text: info.이름, + // 구조물집계표 = 화면 이름 「구조물 조합」(26-7a) + text: info.이름 === STRUCTURE_TABLE ? L("M02_KindTable") : info.이름, attrs: { type: "button", title: info.수정일 }, }); b.addEventListener("click", () => void select(info)); diff --git a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css index 90f5bc75..a26b107c 100644 --- a/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css +++ b/M02_MasterTemplete/M02_MasterTemplete_UI_Style.css @@ -215,3 +215,53 @@ color: var(--color-text-muted); font-size: 11px; } + +/* 「구조물 조합」(PLAN 26-7c) — 왼쪽 항목 나무(주 · 고치는 자리) · 오른쪽 편집 칸(참조표 · 카드) */ +.m02-shell { + display: flex; + align-items: stretch; + min-width: 0; +} + +.m02-shell > .m02-main { + flex: 1 1 0; +} + +.m02-shell__tree { + display: flex; + flex: 0 0 16rem; + flex-direction: column; + box-sizing: border-box; + max-height: calc(100vh - var(--spacing-64)); + padding: var(--spacing-16) var(--spacing-12); + border-right: 1px solid var(--color-border); + min-width: 0; + overflow: auto; +} + +.m02-shell__tree[hidden] { + display: none; +} + +.m02-shell__title { + margin: 0; + color: var(--color-text); + font-size: var(--text-body); +} + +.m02-shell__tree .m02-tree__body { + max-height: none; +} + +@media (max-width: 720px) { + .m02-shell { + flex-direction: column; + } + + .m02-shell__tree { + flex-basis: auto; + max-height: 40vh; + border-right: 0; + border-bottom: 1px solid var(--color-border); + } +} diff --git a/M02_MasterTemplete/M02_Project_Items_Router.py b/M02_MasterTemplete/M02_Project_Items_Router.py index 4ca0e2f7..6214c696 100644 --- a/M02_MasterTemplete/M02_Project_Items_Router.py +++ b/M02_MasterTemplete/M02_Project_Items_Router.py @@ -1,12 +1,15 @@ -"""프로젝트 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 25-2b · 25-2e). +"""프로젝트 구조물 집계 항목 복사본 길 — `/api/projects/{id}/items…` (PLAN 25-1b · 26-1 · 26-2b). - GET …/items 목록(이름 · 원본 · 가져온날 · 마스터 바뀜) - POST …/items {layer, owner, key} 미리 가져오기(구조물에 안 이은 항목) → {폴더, 만듦, 가져온날} - GET …/items/{폴더}/{종류} {종류, 판, 내용} - PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값 - POST …/items/{폴더}/refresh 새로 가져오기(입력값 · 고른자재는 같은 키면 살림) - POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) -종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재. +폴더 = 구조물 id(`structure_id` · 관 지점은 `pipe@측점`) — 같은 항목을 고른 구조물도 복사본은 따로. + GET …/items 목록(원본 · 가져온날 · 마스터바뀜 · 원본없음) + POST …/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + POST …/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) · 회사 없음 409 + GET …/items/{폴더}/{종류} {종류, 판, 내용} + PUT …/items/{폴더}/{종류} {판, 내용} — 판이 다르면 409 · 면적 · 재료는 서버 계산값 + POST …/items/{폴더}/reapply 재적용(입력값 · 고른자재는 같은 키면 살림 · `덮임` 알림) + POST …/items/{폴더}/save-as {to, 경로, 이름} 개인 층에 새 항목으로(단가 묶음은 안 가져감) + POST …/items/{폴더}/calc 복사본만으로 계산(세 비목 · 계 · 빠진 입력) +종류 = 도면 · 면적 · 재료 · 자재조합 · 입력연결 · 입력값 · 고른자재 · `refresh` = 옛 이름(재적용). ⚠ 등록은 `main.py`(로그인만) — 프로젝트 권한(같은 회사)은 여기서 `_project` 로 봄(403 · 404). """ @@ -23,6 +26,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_summary_item import parse_summary_item from M02_MasterTemplete import M02_Item_Copy as copies from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes +from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete.M02_Item_Source import item_name router = APIRouter(prefix="/api/projects", tags=["M02 Project Items"]) @@ -36,8 +40,9 @@ class WriteBody(BaseModel): class PrefetchBody(BaseModel): - """`{layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음.""" + """`{structure, layer, owner, key}`(이름은 층 SS 줄에서) — 옛 모양 `{summary_item}` 도 받음.""" + structure: str | None = None layer: str | None = None owner: str | None = None key: str | None = None @@ -45,6 +50,28 @@ class PrefetchBody(BaseModel): summary_item: dict[str, Any] | None = None +class SaveAsBody(BaseModel): + to: Literal["personal"] = "personal" + 경로: list[str] = [] + 이름: str | None = None + + +class NewBody(BaseModel): + structure: str + 경로: list[str] = [] + 이름: str | None = None + 단위: str | None = None + + +def _me(session: dict[str, Any]) -> tuple[int, int]: + """개인 층 주인(회사 · 사용자) — 회사 없는 사용자는 409.""" + if session.get("company_id") is None: + raise HTTPException( + status_code=409, detail="회사부터 등록 — 개인 층은 회사 연결이 필요합니다." + ) + return int(session["company_id"]), int(session["user_id"]) + + async def _root(session: dict[str, Any], project_id: str): return (await layer_routes._project(session, project_id))["root"] @@ -52,6 +79,8 @@ async def _root(session: dict[str, Any], project_id: str): async def _run(fn, *args): try: return await asyncio.to_thread(fn, *args) + except store.StoreError as error: + raise HTTPException(status_code=error.status, detail=error.detail) from error except copies.Stale as error: raise HTTPException( status_code=409, detail={"message": str(error), "판": error.current} @@ -73,6 +102,8 @@ async def list_items( async def prefetch_item( project_id: str, body: PrefetchBody, session: dict[str, Any] = Depends(verify_session) ) -> dict[str, Any]: + if not body.structure: + raise HTTPException(status_code=400, detail="미리 가져올 구조물(structure)이 필요합니다.") raw = body.summary_item or {"layer": body.layer, "owner": body.owner, "key": body.key} try: # 모양 먼저(이름은 자리만) — 그다음 층 SS 줄에서 이름 item = parse_summary_item({**raw, "name": raw.get("name") or body.name or "-"}) @@ -83,7 +114,19 @@ async def prefetch_item( item["name"] = item_name(item) except LookupError as error: raise HTTPException(status_code=404, detail=str(error)) from error - return await _run(copies.prefetch, await _root(session, project_id), item) + root = await _root(session, project_id) + return await _run(copies.prefetch, root, body.structure, item) + + +@router.post("/{project_id}/items/new") +async def new_item( + project_id: str, body: NewBody, session: dict[str, Any] = Depends(verify_session) +) -> dict[str, Any]: + root = await _root(session, project_id) + company, user = _me(session) + return await _run( + copies.new_item, root, body.structure, company, user, body.경로, body.이름, body.단위 + ) @router.get("/{project_id}/items/{folder}/{kind}") @@ -112,11 +155,24 @@ async def write_item_file( return await _run(copies.write_file, root, folder, kind, body.판, body.내용, _recalc) +@router.post("/{project_id}/items/{folder}/reapply") @router.post("/{project_id}/items/{folder}/refresh") -async def refresh_item( +async def reapply_item( project_id: str, folder: str, session: dict[str, Any] = Depends(verify_session) ) -> dict[str, Any]: - return await _run(copies.refresh, await _root(session, project_id), folder) + return await _run(copies.reapply, await _root(session, project_id), folder) + + +@router.post("/{project_id}/items/{folder}/save-as") +async def save_item_as( + project_id: str, + folder: str, + body: SaveAsBody, + session: dict[str, Any] = Depends(verify_session), +) -> dict[str, Any]: + root = await _root(session, project_id) + company, user = _me(session) + return await _run(copies.save_as, root, folder, company, user, body.경로, body.이름) @router.post("/{project_id}/items/{folder}/calc") diff --git a/M02_MasterTemplete/M02_Table_Fill.py b/M02_MasterTemplete/M02_Table_Fill.py index 812c84d1..b8f71e67 100644 --- a/M02_MasterTemplete/M02_Table_Fill.py +++ b/M02_MasterTemplete/M02_Table_Fill.py @@ -88,13 +88,15 @@ def collect_items( ) -> list[dict[str, Any]]: """설계 정본 → 구조물 목록. - 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장}`. + 한 건 = `{type_id, placement, chainage_m, start_m, end_m, options, 관연장, summary_item, + 복사본}` · + `복사본` = 그 구조물 집계 항목 복사본 폴더의 구조물 id(관 지점은 `pipe@측점` · 26-2b). """ types = types or structure_type_map() lengths = pipe_lengths or {} items: list[dict[str, Any]] = [] _, structures = load_structures(str(project_root)) - for item in structures: + for index, item in enumerate(structures): definition = types.get(item.type_id) if definition is not None and definition.managed_by: continue # 관 지점 정본이 주인 — 옛 저장분이 남아 있어도 두 번 안 셈 @@ -107,6 +109,7 @@ def collect_items( "end_m": item.end_m, "options": _with_defaults(definition, dict(item.options or {})), "summary_item": item.summary_item, + "복사본": item.structure_id or f"#{index}", } ) for point in read_pipe_points_file(pipe_points_path_in(Path(project_root))): @@ -122,6 +125,7 @@ def collect_items( "options": _with_defaults(types.get(type_id), dict(point.options or {})), "관연장": _nearest(lengths, chainage) if type_id == "pipe" else None, "summary_item": point.summary_item, + "복사본": f"pipe@{round(chainage, 3)}", } ) return items @@ -593,7 +597,8 @@ def assign_items( """지정 항목 모드 준비 — 구조물마다 스냅샷 열 정의로 고른 열을 정함(PLAN 21-0 · 21-3). - 미지정 · 스냅샷 없음 = 표에 안 넣음(자동 맞춤 없음) · 알림 한 줄씩 - - 시스템 층 항목 = 같은 id 열 자리에 스냅샷 열 정의(원본이 바뀌어도 지정한 때 값) + - 열 정의 = 그 구조물 복사본의 것(26-2b · 원본이 바뀌거나 지워져도 지정한 때 값) + - 시스템 층 항목 = 같은 id 열 자리 - 회사 · 개인 층 항목 = `열id@스냅샷폴더` 새 열(시스템 열과 id · 묶음이 안 겹치게) 돌려주는 것 = (열을 맞춘 문서, `열` · `열묶음` 을 단 구조물, 알림). """ @@ -606,7 +611,8 @@ def assign_items( unassigned = missing = 0 for item in items: chosen = item.get("summary_item") - column = (read_snapshot(project_root, chosen) or {}).get("열") if chosen else None + owner = item.get("복사본") + column = (read_snapshot(project_root, owner) or {}).get("열") if chosen and owner else None if not chosen or not isinstance(column, dict) or not column.get("id"): unassigned += not chosen missing += bool(chosen) diff --git a/common_util/common_util_summary_item.py b/common_util/common_util_summary_item.py index 08f58c67..dee3682e 100644 --- a/common_util/common_util_summary_item.py +++ b/common_util/common_util_summary_item.py @@ -3,6 +3,10 @@ summary_item = {layer: "system"|"company"|"personal", owner: null|"{회사}"|"{회사}/{사용자}", key: "SS000004", name: "…"} +항목 키(PLAN 26-4a) — 마스터 `SS000004` · 회사 `SC{회사}-000001` · 개인 `SP{사용자}-000001` +(머리의 번호 = owner 의 회사 · 사용자). 옛 `SS` 키는 어느 층이든 그대로 받음 — 회사 · 개인 층이 새 +모양으로 옮기기 전에 적힌 기록(층 장부의 `옛키` 로 찾음). + 구조물 기록(`structures.json` `StructureInstance`) · 관 지점 기록(`pipe_points.json` 점) 두 곳이 같은 칸을 씀 — 검사는 여기 한 벌. 없으면 null(옛 파일 호환). """ @@ -13,7 +17,11 @@ import re from typing import Any SUMMARY_LAYERS = ("system", "company", "personal") -_KEY = re.compile(r"^SS\d{6}$") +_KEY = re.compile(r"^SS\d{6}$") # 마스터 · 옛 키 +_LAYER_KEY = { + "company": re.compile(r"^SC(\d+)-\d{6}$"), + "personal": re.compile(r"^SP(\d+)-\d{6}$"), +} _OWNER = { "system": None, "company": re.compile(r"^\d+$"), @@ -43,18 +51,32 @@ def parse_summary_item(value: Any) -> dict[str, Any] | None: shape = "{회사}" if layer == "company" else "{회사}/{사용자}" raise ValueError(f"{layer} 층 집계 항목의 owner 는 「{shape}」 숫자 모양입니다.") key = value.get("key") - if not isinstance(key, str) or not _KEY.match(key): - raise ValueError("집계 항목 key 는 SS 와 숫자 6자리입니다(예: SS000004).") + if not isinstance(key, str) or not _key_ok(layer, owner, key): + raise ValueError( + "집계 항목 key 는 마스터 SS000004 · 회사 SC{회사}-000001 · " + "개인 SP{사용자}-000001 모양입니다(옛 SS 키는 어느 층이든 받음)." + ) name = value.get("name") if not isinstance(name, str) or not name.strip() or len(name) > _NAME_MAX: raise ValueError(f"집계 항목 name 은 1~{_NAME_MAX}자 글입니다.") return {"layer": layer, "owner": owner, "key": key, "name": name} +def _key_ok(layer: str, owner: Any, key: str) -> bool: + """옛 · 마스터 SS 키는 어느 층이든 · SC · SP 는 그 층에서 머리 번호가 owner 와 같을 때만.""" + if _KEY.match(key): + return True + rule = _LAYER_KEY.get(layer) + found = rule.match(key) if rule else None + if not found or not isinstance(owner, str): + return False + return found.group(1) == (owner if layer == "company" else owner.split("/")[-1]) + + def snapshot_folder_name(item: dict[str, Any]) -> str: """`{layer}_{owner 슬러그}_{key}` — owner 없음 = `none` · `/` = `-`. - 예: `system_none_SS000004` · `company_3_SS000001` · `personal_3-12_SS000002`. + 예: `system_none_SS000004` · `company_3_SC3-000001` · `personal_3-12_SP12-000002`. """ owner = item.get("owner") slug = str(owner).replace("/", "-") if owner else "none" diff --git a/resources/master_template/area/be.json b/resources/master_template/area/be.json deleted file mode 100644 index f0645118..00000000 --- a/resources/master_template/area/be.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "be", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA14000000" -} diff --git a/resources/master_template/area/bg.json b/resources/master_template/area/bg.json deleted file mode 100644 index 56bd14db..00000000 --- a/resources/master_template/area/bg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bg", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA22000000" -} diff --git a/resources/master_template/area/bm_area.json b/resources/master_template/area/bm_area.json deleted file mode 100644 index 96a58246..00000000 --- a/resources/master_template/area/bm_area.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bm_area", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA12010200" -} diff --git a/resources/master_template/area/bm_len.json b/resources/master_template/area/bm_len.json deleted file mode 100644 index 9e49632f..00000000 --- a/resources/master_template/area/bm_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bm_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA12010100" -} diff --git a/resources/master_template/area/bx_len.json b/resources/master_template/area/bx_len.json deleted file mode 100644 index 9d155c3e..00000000 --- a/resources/master_template/area/bx_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bx_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA05010100" -} diff --git a/resources/master_template/area/bx_wing.json b/resources/master_template/area/bx_wing.json deleted file mode 100644 index f4133058..00000000 --- a/resources/master_template/area/bx_wing.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bx_wing", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA05010200" -} diff --git a/resources/master_template/area/c1.json b/resources/master_template/area/c1.json deleted file mode 100644 index 78777103..00000000 --- a/resources/master_template/area/c1.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c1", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA01010200" -} diff --git a/resources/master_template/area/c2.json b/resources/master_template/area/c2.json deleted file mode 100644 index 8e385b6c..00000000 --- a/resources/master_template/area/c2.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c2", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA15010000" -} diff --git a/resources/master_template/area/c3.json b/resources/master_template/area/c3.json deleted file mode 100644 index 7c136cea..00000000 --- a/resources/master_template/area/c3.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c3", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010600" -} diff --git a/resources/master_template/area/c5.json b/resources/master_template/area/c5.json deleted file mode 100644 index f4ecac7f..00000000 --- a/resources/master_template/area/c5.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c5", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA01020000" -} diff --git a/resources/master_template/area/cs.json b/resources/master_template/area/cs.json deleted file mode 100644 index ad66a540..00000000 --- a/resources/master_template/area/cs.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "cs", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA24000000" -} diff --git a/resources/master_template/area/ec.json b/resources/master_template/area/ec.json deleted file mode 100644 index c2232987..00000000 --- a/resources/master_template/area/ec.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ec", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA13010000" -} diff --git a/resources/master_template/area/etc.json b/resources/master_template/area/etc.json deleted file mode 100644 index 7a32d95f..00000000 --- a/resources/master_template/area/etc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "etc", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA25010000" -} diff --git a/resources/master_template/area/fb.json b/resources/master_template/area/fb.json deleted file mode 100644 index 11ed8d65..00000000 --- a/resources/master_template/area/fb.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fb", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA06010000" -} diff --git a/resources/master_template/area/fp_l.json b/resources/master_template/area/fp_l.json deleted file mode 100644 index 8826095f..00000000 --- a/resources/master_template/area/fp_l.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fp_l", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA07010200" -} diff --git a/resources/master_template/area/fp_w.json b/resources/master_template/area/fp_w.json deleted file mode 100644 index f78fad10..00000000 --- a/resources/master_template/area/fp_w.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fp_w", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA07010100" -} diff --git a/resources/master_template/area/gr_curb.json b/resources/master_template/area/gr_curb.json deleted file mode 100644 index 17d86c40..00000000 --- a/resources/master_template/area/gr_curb.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_curb", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA19000000" -} diff --git a/resources/master_template/area/gr_rail.json b/resources/master_template/area/gr_rail.json deleted file mode 100644 index 10b65920..00000000 --- a/resources/master_template/area/gr_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA18000000" -} diff --git a/resources/master_template/area/gr_sign.json b/resources/master_template/area/gr_sign.json deleted file mode 100644 index 8ad369c8..00000000 --- a/resources/master_template/area/gr_sign.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_sign", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA20000000" -} diff --git a/resources/master_template/area/h_break.json b/resources/master_template/area/h_break.json deleted file mode 100644 index a15a5940..00000000 --- a/resources/master_template/area/h_break.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_break", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA35000000" -} diff --git a/resources/master_template/area/h_ford_basin.json b/resources/master_template/area/h_ford_basin.json deleted file mode 100644 index d59671df..00000000 --- a/resources/master_template/area/h_ford_basin.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_ford_basin", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA33000000" -} diff --git a/resources/master_template/area/h_intake.json b/resources/master_template/area/h_intake.json deleted file mode 100644 index dbf7ffda..00000000 --- a/resources/master_template/area/h_intake.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_intake", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA26000000" -} diff --git a/resources/master_template/area/h_pg_rail.json b/resources/master_template/area/h_pg_rail.json deleted file mode 100644 index d4d9fd17..00000000 --- a/resources/master_template/area/h_pg_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_pg_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA31000000" -} diff --git a/resources/master_template/area/h_pv_rail.json b/resources/master_template/area/h_pv_rail.json deleted file mode 100644 index 3802f96d..00000000 --- a/resources/master_template/area/h_pv_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_pv_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA30000000" -} diff --git a/resources/master_template/area/h_rip_c.json b/resources/master_template/area/h_rip_c.json deleted file mode 100644 index 9df8f76b..00000000 --- a/resources/master_template/area/h_rip_c.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rip_c", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA27010000" -} diff --git a/resources/master_template/area/h_rip_m.json b/resources/master_template/area/h_rip_m.json deleted file mode 100644 index 6ba6f411..00000000 --- a/resources/master_template/area/h_rip_m.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rip_m", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA27020000" -} diff --git a/resources/master_template/area/h_rockfall.json b/resources/master_template/area/h_rockfall.json deleted file mode 100644 index 442eafdc..00000000 --- a/resources/master_template/area/h_rockfall.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rockfall", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA34000000" -} diff --git a/resources/master_template/area/h_soil_ditch.json b/resources/master_template/area/h_soil_ditch.json deleted file mode 100644 index 3e1baa18..00000000 --- a/resources/master_template/area/h_soil_ditch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_soil_ditch", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA32000000" -} diff --git a/resources/master_template/area/h_stone_bed.json b/resources/master_template/area/h_stone_bed.json deleted file mode 100644 index c728639e..00000000 --- a/resources/master_template/area/h_stone_bed.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_stone_bed", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA29000000" -} diff --git a/resources/master_template/area/h_stone_ch.json b/resources/master_template/area/h_stone_ch.json deleted file mode 100644 index a31e6c85..00000000 --- a/resources/master_template/area/h_stone_ch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_stone_ch", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA28000000" -} diff --git a/resources/master_template/area/h_waste.json b/resources/master_template/area/h_waste.json deleted file mode 100644 index daf35b00..00000000 --- a/resources/master_template/area/h_waste.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_waste", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA36000000" -} diff --git a/resources/master_template/area/mr.json b/resources/master_template/area/mr.json deleted file mode 100644 index d6d76327..00000000 --- a/resources/master_template/area/mr.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "mr", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA21000000" -} diff --git a/resources/master_template/area/od_cnt.json b/resources/master_template/area/od_cnt.json deleted file mode 100644 index 56e16d8a..00000000 --- a/resources/master_template/area/od_cnt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "od_cnt", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA08010200" -} diff --git a/resources/master_template/area/od_len.json b/resources/master_template/area/od_len.json deleted file mode 100644 index e5c3362c..00000000 --- a/resources/master_template/area/od_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "od_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA08010100" -} diff --git a/resources/master_template/area/pg_in.json b/resources/master_template/area/pg_in.json deleted file mode 100644 index cccbea3e..00000000 --- a/resources/master_template/area/pg_in.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pg_in", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "계산값": {}, - "면적번": "SA04010100" -} diff --git a/resources/master_template/area/pp_cp.json b/resources/master_template/area/pp_cp.json deleted file mode 100644 index fd7ea3e5..00000000 --- a/resources/master_template/area/pp_cp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pp_cp", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA03010200" -} diff --git a/resources/master_template/area/ps.json b/resources/master_template/area/ps.json deleted file mode 100644 index 13792fe2..00000000 --- a/resources/master_template/area/ps.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ps", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA23000000" -} diff --git a/resources/master_template/area/pv_a.json b/resources/master_template/area/pv_a.json deleted file mode 100644 index a4bcc234..00000000 --- a/resources/master_template/area/pv_a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_a", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010400" -} diff --git a/resources/master_template/area/pv_b.json b/resources/master_template/area/pv_b.json deleted file mode 100644 index 156e750b..00000000 --- a/resources/master_template/area/pv_b.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_b", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010100" -} diff --git a/resources/master_template/area/pv_jt.json b/resources/master_template/area/pv_jt.json deleted file mode 100644 index a2c7c9a7..00000000 --- a/resources/master_template/area/pv_jt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_jt", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010500" -} diff --git a/resources/master_template/area/pv_l.json b/resources/master_template/area/pv_l.json deleted file mode 100644 index 6f9760ad..00000000 --- a/resources/master_template/area/pv_l.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_l", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010200" -} diff --git a/resources/master_template/area/pv_w.json b/resources/master_template/area/pv_w.json deleted file mode 100644 index 421d0309..00000000 --- a/resources/master_template/area/pv_w.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_w", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA02010300" -} diff --git a/resources/master_template/area/rf.json b/resources/master_template/area/rf.json deleted file mode 100644 index 029e40a4..00000000 --- a/resources/master_template/area/rf.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "rf", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA15000000" -} diff --git a/resources/master_template/area/rw.json b/resources/master_template/area/rw.json deleted file mode 100644 index b2f27a4c..00000000 --- a/resources/master_template/area/rw.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "rw", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA09010100" -} diff --git a/resources/master_template/area/sg.json b/resources/master_template/area/sg.json deleted file mode 100644 index 5665034f..00000000 --- a/resources/master_template/area/sg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "sg", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA11010100" -} diff --git a/resources/master_template/area/ta.json b/resources/master_template/area/ta.json deleted file mode 100644 index 0c8c6061..00000000 --- a/resources/master_template/area/ta.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ta", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA17000000" -} diff --git a/resources/master_template/area/wy.json b/resources/master_template/area/wy.json deleted file mode 100644 index 69f7c3bb..00000000 --- a/resources/master_template/area/wy.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "wy", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "면적산출", - "칸": {} - } - ], - "활성": "s1", - "면적번": "SA16000000" -} diff --git a/resources/master_template/material/be.json b/resources/master_template/material/be.json deleted file mode 100644 index df25ecf7..00000000 --- a/resources/master_template/material/be.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "be", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM14000000" -} diff --git a/resources/master_template/material/bg.json b/resources/master_template/material/bg.json deleted file mode 100644 index 00a5f9cc..00000000 --- a/resources/master_template/material/bg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bg", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM22000000" -} diff --git a/resources/master_template/material/bm_area.json b/resources/master_template/material/bm_area.json deleted file mode 100644 index 6a4793f1..00000000 --- a/resources/master_template/material/bm_area.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bm_area", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM12010200" -} diff --git a/resources/master_template/material/bm_len.json b/resources/master_template/material/bm_len.json deleted file mode 100644 index 25b5425f..00000000 --- a/resources/master_template/material/bm_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bm_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM12010100" -} diff --git a/resources/master_template/material/bx_len.json b/resources/master_template/material/bx_len.json deleted file mode 100644 index 40aa7563..00000000 --- a/resources/master_template/material/bx_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bx_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM05010100" -} diff --git a/resources/master_template/material/bx_wing.json b/resources/master_template/material/bx_wing.json deleted file mode 100644 index 22778e84..00000000 --- a/resources/master_template/material/bx_wing.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "bx_wing", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM05010200" -} diff --git a/resources/master_template/material/c1.json b/resources/master_template/material/c1.json deleted file mode 100644 index 4fa69ae5..00000000 --- a/resources/master_template/material/c1.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c1", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM01010200" -} diff --git a/resources/master_template/material/c2.json b/resources/master_template/material/c2.json deleted file mode 100644 index f9fd0617..00000000 --- a/resources/master_template/material/c2.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c2", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM15010000" -} diff --git a/resources/master_template/material/c3.json b/resources/master_template/material/c3.json deleted file mode 100644 index f4930d6b..00000000 --- a/resources/master_template/material/c3.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c3", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010600" -} diff --git a/resources/master_template/material/c5.json b/resources/master_template/material/c5.json deleted file mode 100644 index de6f1ca5..00000000 --- a/resources/master_template/material/c5.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "c5", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM01020000" -} diff --git a/resources/master_template/material/cs.json b/resources/master_template/material/cs.json deleted file mode 100644 index d2ce8b8e..00000000 --- a/resources/master_template/material/cs.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "cs", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM24000000" -} diff --git a/resources/master_template/material/ec.json b/resources/master_template/material/ec.json deleted file mode 100644 index c8d3cc42..00000000 --- a/resources/master_template/material/ec.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ec", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM13010000" -} diff --git a/resources/master_template/material/etc.json b/resources/master_template/material/etc.json deleted file mode 100644 index d7723dee..00000000 --- a/resources/master_template/material/etc.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "etc", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM25010000" -} diff --git a/resources/master_template/material/fb.json b/resources/master_template/material/fb.json deleted file mode 100644 index e3858056..00000000 --- a/resources/master_template/material/fb.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fb", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM06010000" -} diff --git a/resources/master_template/material/fp_l.json b/resources/master_template/material/fp_l.json deleted file mode 100644 index 3e2f5fd2..00000000 --- a/resources/master_template/material/fp_l.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fp_l", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM07010200" -} diff --git a/resources/master_template/material/fp_w.json b/resources/master_template/material/fp_w.json deleted file mode 100644 index ff956999..00000000 --- a/resources/master_template/material/fp_w.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "fp_w", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM07010100" -} diff --git a/resources/master_template/material/gr_curb.json b/resources/master_template/material/gr_curb.json deleted file mode 100644 index 3984f870..00000000 --- a/resources/master_template/material/gr_curb.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_curb", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM19000000" -} diff --git a/resources/master_template/material/gr_rail.json b/resources/master_template/material/gr_rail.json deleted file mode 100644 index d81ab597..00000000 --- a/resources/master_template/material/gr_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM18000000" -} diff --git a/resources/master_template/material/gr_sign.json b/resources/master_template/material/gr_sign.json deleted file mode 100644 index ae925b79..00000000 --- a/resources/master_template/material/gr_sign.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "gr_sign", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM20000000" -} diff --git a/resources/master_template/material/h_break.json b/resources/master_template/material/h_break.json deleted file mode 100644 index 9433a15c..00000000 --- a/resources/master_template/material/h_break.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_break", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM35000000" -} diff --git a/resources/master_template/material/h_ford_basin.json b/resources/master_template/material/h_ford_basin.json deleted file mode 100644 index ef3c8965..00000000 --- a/resources/master_template/material/h_ford_basin.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_ford_basin", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM33000000" -} diff --git a/resources/master_template/material/h_intake.json b/resources/master_template/material/h_intake.json deleted file mode 100644 index e3bf1ddf..00000000 --- a/resources/master_template/material/h_intake.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_intake", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM26000000" -} diff --git a/resources/master_template/material/h_pg_rail.json b/resources/master_template/material/h_pg_rail.json deleted file mode 100644 index 191bdf94..00000000 --- a/resources/master_template/material/h_pg_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_pg_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM31000000" -} diff --git a/resources/master_template/material/h_pv_rail.json b/resources/master_template/material/h_pv_rail.json deleted file mode 100644 index 597b9868..00000000 --- a/resources/master_template/material/h_pv_rail.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_pv_rail", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM30000000" -} diff --git a/resources/master_template/material/h_rip_c.json b/resources/master_template/material/h_rip_c.json deleted file mode 100644 index 5f7f6cf5..00000000 --- a/resources/master_template/material/h_rip_c.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rip_c", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM27010000" -} diff --git a/resources/master_template/material/h_rip_m.json b/resources/master_template/material/h_rip_m.json deleted file mode 100644 index 9bd01612..00000000 --- a/resources/master_template/material/h_rip_m.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rip_m", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM27020000" -} diff --git a/resources/master_template/material/h_rockfall.json b/resources/master_template/material/h_rockfall.json deleted file mode 100644 index cbeed72a..00000000 --- a/resources/master_template/material/h_rockfall.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_rockfall", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM34000000" -} diff --git a/resources/master_template/material/h_soil_ditch.json b/resources/master_template/material/h_soil_ditch.json deleted file mode 100644 index 56fd98ff..00000000 --- a/resources/master_template/material/h_soil_ditch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_soil_ditch", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM32000000" -} diff --git a/resources/master_template/material/h_stone_bed.json b/resources/master_template/material/h_stone_bed.json deleted file mode 100644 index 1f9e4661..00000000 --- a/resources/master_template/material/h_stone_bed.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_stone_bed", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM29000000" -} diff --git a/resources/master_template/material/h_stone_ch.json b/resources/master_template/material/h_stone_ch.json deleted file mode 100644 index 60b74430..00000000 --- a/resources/master_template/material/h_stone_ch.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_stone_ch", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM28000000" -} diff --git a/resources/master_template/material/h_waste.json b/resources/master_template/material/h_waste.json deleted file mode 100644 index 8385f64f..00000000 --- a/resources/master_template/material/h_waste.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "h_waste", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM36000000" -} diff --git a/resources/master_template/material/mr.json b/resources/master_template/material/mr.json deleted file mode 100644 index 16896cf1..00000000 --- a/resources/master_template/material/mr.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "mr", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM21000000" -} diff --git a/resources/master_template/material/od_cnt.json b/resources/master_template/material/od_cnt.json deleted file mode 100644 index 08883426..00000000 --- a/resources/master_template/material/od_cnt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "od_cnt", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM08010200" -} diff --git a/resources/master_template/material/od_len.json b/resources/master_template/material/od_len.json deleted file mode 100644 index 6f5ad0ce..00000000 --- a/resources/master_template/material/od_len.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "od_len", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM08010100" -} diff --git a/resources/master_template/material/pg_in.json b/resources/master_template/material/pg_in.json deleted file mode 100644 index 3e255921..00000000 --- a/resources/master_template/material/pg_in.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pg_in", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM04010100" -} diff --git a/resources/master_template/material/pp_cp.json b/resources/master_template/material/pp_cp.json deleted file mode 100644 index bb59702a..00000000 --- a/resources/master_template/material/pp_cp.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pp_cp", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM03010200" -} diff --git a/resources/master_template/material/ps.json b/resources/master_template/material/ps.json deleted file mode 100644 index 770e93d1..00000000 --- a/resources/master_template/material/ps.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ps", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM23000000" -} diff --git a/resources/master_template/material/pv_a.json b/resources/master_template/material/pv_a.json deleted file mode 100644 index 8d10c10b..00000000 --- a/resources/master_template/material/pv_a.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_a", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010400" -} diff --git a/resources/master_template/material/pv_b.json b/resources/master_template/material/pv_b.json deleted file mode 100644 index 426bd0de..00000000 --- a/resources/master_template/material/pv_b.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_b", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010100" -} diff --git a/resources/master_template/material/pv_jt.json b/resources/master_template/material/pv_jt.json deleted file mode 100644 index 68199baf..00000000 --- a/resources/master_template/material/pv_jt.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_jt", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010500" -} diff --git a/resources/master_template/material/pv_l.json b/resources/master_template/material/pv_l.json deleted file mode 100644 index 80a4f3b7..00000000 --- a/resources/master_template/material/pv_l.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_l", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010200" -} diff --git a/resources/master_template/material/pv_w.json b/resources/master_template/material/pv_w.json deleted file mode 100644 index 28ae6384..00000000 --- a/resources/master_template/material/pv_w.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "pv_w", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM02010300" -} diff --git a/resources/master_template/material/rf.json b/resources/master_template/material/rf.json deleted file mode 100644 index fed34d35..00000000 --- a/resources/master_template/material/rf.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "rf", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM15000000" -} diff --git a/resources/master_template/material/rw.json b/resources/master_template/material/rw.json deleted file mode 100644 index b82e55ca..00000000 --- a/resources/master_template/material/rw.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "rw", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM09010100" -} diff --git a/resources/master_template/material/sg.json b/resources/master_template/material/sg.json deleted file mode 100644 index 8e0d103e..00000000 --- a/resources/master_template/material/sg.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "sg", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM11010100" -} diff --git a/resources/master_template/material/ta.json b/resources/master_template/material/ta.json deleted file mode 100644 index 47cd074f..00000000 --- a/resources/master_template/material/ta.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "ta", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM17000000" -} diff --git a/resources/master_template/material/wy.json b/resources/master_template/material/wy.json deleted file mode 100644 index 49fa89f9..00000000 --- a/resources/master_template/material/wy.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "종류": "통합문서", - "판": 1, - "열": "wy", - "서식": [ - {} - ], - "시트": [ - { - "id": "s1", - "이름": "재료산출", - "칸": {} - } - ], - "활성": "s1", - "재료번": "SM16000000" -} diff --git a/resources/master_template/structure/be.json b/resources/master_template/structure/be.json deleted file mode 100644 index f6a47681..00000000 --- a/resources/master_template/structure/be.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "be", - "도번": "SD14000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/bg.json b/resources/master_template/structure/bg.json deleted file mode 100644 index c0b4a331..00000000 --- a/resources/master_template/structure/bg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "bg", - "도번": "SD22000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/bm_area.json b/resources/master_template/structure/bm_area.json deleted file mode 100644 index cdc120a0..00000000 --- a/resources/master_template/structure/bm_area.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "bm_area", - "도번": "SD12010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/bm_len.json b/resources/master_template/structure/bm_len.json deleted file mode 100644 index ff243ada..00000000 --- a/resources/master_template/structure/bm_len.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "bm_len", - "도번": "SD12010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/bx_len.json b/resources/master_template/structure/bx_len.json deleted file mode 100644 index f9daac64..00000000 --- a/resources/master_template/structure/bx_len.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "bx_len", - "도번": "SD05010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/bx_wing.json b/resources/master_template/structure/bx_wing.json deleted file mode 100644 index bc90cd8a..00000000 --- a/resources/master_template/structure/bx_wing.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "bx_wing", - "도번": "SD05010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/c1.json b/resources/master_template/structure/c1.json deleted file mode 100644 index 8ecd69fa..00000000 --- a/resources/master_template/structure/c1.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "c1", - "도번": "SD01010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/c2.json b/resources/master_template/structure/c2.json deleted file mode 100644 index e6635882..00000000 --- a/resources/master_template/structure/c2.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "c2", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - }, - "도번": "SD15010000" -} diff --git a/resources/master_template/structure/c3.json b/resources/master_template/structure/c3.json deleted file mode 100644 index ff1fc685..00000000 --- a/resources/master_template/structure/c3.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "c3", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - }, - "도번": "SD02010600" -} diff --git a/resources/master_template/structure/c5.json b/resources/master_template/structure/c5.json deleted file mode 100644 index 30288930..00000000 --- a/resources/master_template/structure/c5.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "c5", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - }, - "도번": "SD01020000" -} diff --git a/resources/master_template/structure/cs.json b/resources/master_template/structure/cs.json deleted file mode 100644 index a9d3278b..00000000 --- a/resources/master_template/structure/cs.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "cs", - "도번": "SD24000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/ec.json b/resources/master_template/structure/ec.json deleted file mode 100644 index 4711b287..00000000 --- a/resources/master_template/structure/ec.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "ec", - "도번": "SD13010000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/etc.json b/resources/master_template/structure/etc.json deleted file mode 100644 index ab833bcc..00000000 --- a/resources/master_template/structure/etc.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "etc", - "도번": "SD25010000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/fb.json b/resources/master_template/structure/fb.json deleted file mode 100644 index 43c0f089..00000000 --- a/resources/master_template/structure/fb.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "fb", - "도번": "SD06010000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/fp_l.json b/resources/master_template/structure/fp_l.json deleted file mode 100644 index c07285da..00000000 --- a/resources/master_template/structure/fp_l.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "fp_l", - "도번": "SD07010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/fp_w.json b/resources/master_template/structure/fp_w.json deleted file mode 100644 index c66c6c24..00000000 --- a/resources/master_template/structure/fp_w.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "fp_w", - "도번": "SD07010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/gr_curb.json b/resources/master_template/structure/gr_curb.json deleted file mode 100644 index 85e8179c..00000000 --- a/resources/master_template/structure/gr_curb.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "gr_curb", - "도번": "SD19000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/gr_rail.json b/resources/master_template/structure/gr_rail.json deleted file mode 100644 index dedda075..00000000 --- a/resources/master_template/structure/gr_rail.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "gr_rail", - "도번": "SD18000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/gr_sign.json b/resources/master_template/structure/gr_sign.json deleted file mode 100644 index e9127130..00000000 --- a/resources/master_template/structure/gr_sign.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "gr_sign", - "도번": "SD20000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_break.json b/resources/master_template/structure/h_break.json deleted file mode 100644 index 9dc97923..00000000 --- a/resources/master_template/structure/h_break.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_break", - "도번": "SD35000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_ford_basin.json b/resources/master_template/structure/h_ford_basin.json deleted file mode 100644 index 39a965a3..00000000 --- a/resources/master_template/structure/h_ford_basin.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_ford_basin", - "도번": "SD33000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_intake.json b/resources/master_template/structure/h_intake.json deleted file mode 100644 index ae614748..00000000 --- a/resources/master_template/structure/h_intake.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_intake", - "도번": "SD26000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_pg_rail.json b/resources/master_template/structure/h_pg_rail.json deleted file mode 100644 index 58769402..00000000 --- a/resources/master_template/structure/h_pg_rail.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_pg_rail", - "도번": "SD31000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_pv_rail.json b/resources/master_template/structure/h_pv_rail.json deleted file mode 100644 index e05d7700..00000000 --- a/resources/master_template/structure/h_pv_rail.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_pv_rail", - "도번": "SD30000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_rip_c.json b/resources/master_template/structure/h_rip_c.json deleted file mode 100644 index 761a230d..00000000 --- a/resources/master_template/structure/h_rip_c.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_rip_c", - "도번": "SD27010000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_rip_m.json b/resources/master_template/structure/h_rip_m.json deleted file mode 100644 index f85a0856..00000000 --- a/resources/master_template/structure/h_rip_m.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_rip_m", - "도번": "SD27020000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_rockfall.json b/resources/master_template/structure/h_rockfall.json deleted file mode 100644 index e6b6c639..00000000 --- a/resources/master_template/structure/h_rockfall.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_rockfall", - "도번": "SD34000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_soil_ditch.json b/resources/master_template/structure/h_soil_ditch.json deleted file mode 100644 index 1c738722..00000000 --- a/resources/master_template/structure/h_soil_ditch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_soil_ditch", - "도번": "SD32000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_stone_bed.json b/resources/master_template/structure/h_stone_bed.json deleted file mode 100644 index 4457364a..00000000 --- a/resources/master_template/structure/h_stone_bed.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_stone_bed", - "도번": "SD29000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_stone_ch.json b/resources/master_template/structure/h_stone_ch.json deleted file mode 100644 index 8d25f5e4..00000000 --- a/resources/master_template/structure/h_stone_ch.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_stone_ch", - "도번": "SD28000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/h_waste.json b/resources/master_template/structure/h_waste.json deleted file mode 100644 index aae8bfe5..00000000 --- a/resources/master_template/structure/h_waste.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "h_waste", - "도번": "SD36000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/mr.json b/resources/master_template/structure/mr.json deleted file mode 100644 index e91b5e6e..00000000 --- a/resources/master_template/structure/mr.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "mr", - "도번": "SD21000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/od_cnt.json b/resources/master_template/structure/od_cnt.json deleted file mode 100644 index 0d4db232..00000000 --- a/resources/master_template/structure/od_cnt.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "od_cnt", - "도번": "SD08010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/od_len.json b/resources/master_template/structure/od_len.json deleted file mode 100644 index 50cac0ad..00000000 --- a/resources/master_template/structure/od_len.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "od_len", - "도번": "SD08010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pg_in.json b/resources/master_template/structure/pg_in.json deleted file mode 100644 index 5353795b..00000000 --- a/resources/master_template/structure/pg_in.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pg_in", - "도번": "SD04010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pp_cp.json b/resources/master_template/structure/pp_cp.json deleted file mode 100644 index 9130fff7..00000000 --- a/resources/master_template/structure/pp_cp.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pp_cp", - "도번": "SD03010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/ps.json b/resources/master_template/structure/ps.json deleted file mode 100644 index 2b263baa..00000000 --- a/resources/master_template/structure/ps.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "ps", - "도번": "SD23000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pv_a.json b/resources/master_template/structure/pv_a.json deleted file mode 100644 index 3c39c9b3..00000000 --- a/resources/master_template/structure/pv_a.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pv_a", - "도번": "SD02010400", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pv_b.json b/resources/master_template/structure/pv_b.json deleted file mode 100644 index 0fff4180..00000000 --- a/resources/master_template/structure/pv_b.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pv_b", - "도번": "SD02010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pv_jt.json b/resources/master_template/structure/pv_jt.json deleted file mode 100644 index cd9014fd..00000000 --- a/resources/master_template/structure/pv_jt.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pv_jt", - "도번": "SD02010500", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pv_l.json b/resources/master_template/structure/pv_l.json deleted file mode 100644 index 569202c5..00000000 --- a/resources/master_template/structure/pv_l.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pv_l", - "도번": "SD02010200", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/pv_w.json b/resources/master_template/structure/pv_w.json deleted file mode 100644 index 37a5f2f2..00000000 --- a/resources/master_template/structure/pv_w.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "pv_w", - "도번": "SD02010300", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/rf.json b/resources/master_template/structure/rf.json deleted file mode 100644 index 92ada00a..00000000 --- a/resources/master_template/structure/rf.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "rf", - "도번": "SD15000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/rv.json b/resources/master_template/structure/rv.json deleted file mode 100644 index 5309d172..00000000 --- a/resources/master_template/structure/rv.json +++ /dev/null @@ -1,8607 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "rv", - "도번": "SD01010100", - "도면": { - "format": 6, - "entities": [ - { - "id": "6f01250e-97dd-4e69-8743-8ae8968b7cea", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4255.194526991341, - "y": 5857.308300811681 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "408cfd22-a8e3-46da-ae11-d2b50fc97b79", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "1865b0b5-c1f2-45ce-8999-05a5deb4bc90", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 6842.438965520298, - "y": 5357.308300811681 - } - } - }, - { - "id": "932f3805-3e58-4fae-9da6-a93363a89247", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8429.683404049312, - "y": 5857.308300811681 - }, - "endPoint": { - "x": 7342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "62193954-c151-4a21-9ca6-4ce107436498", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 7342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "0b7d3991-82a1-4b9d-9c75-e916d1a31b40", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "65794e9d-5b53-464f-a1df-3a4de45dd75f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 5591.823079775961, - "y": 5424.866589938145 - } - } - }, - { - "id": "11329597-4879-4dfd-aca3-70328906ac3f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5591.823079775961, - "y": 5424.866589938145 - }, - "endPoint": { - "x": 5466.777693556971, - "y": 5459.159273368976 - } - } - }, - { - "id": "e6e6ca5d-bf7f-485f-9d19-e2a9b12f17d6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5466.777693556971, - "y": 5459.159273368976 - }, - "endPoint": { - "x": 5342.047375321272, - "y": 5494.068203440722 - } - } - }, - { - "id": "d7acba10-0df3-460c-b081-43e0cb5d2980", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.047375321272, - "y": 5494.068203440722 - }, - "endPoint": { - "x": 5217.737147730368, - "y": 5529.798795700539 - } - } - }, - { - "id": "a36e1d4f-5de8-4330-b99d-5da4355b5d8d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5217.737147730368, - "y": 5529.798795700539 - }, - "endPoint": { - "x": 5093.952033445414, - "y": 5566.556465695379 - } - } - }, - { - "id": "0b0b2916-2141-426f-a5fa-728c435510a9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5093.952033445414, - "y": 5566.556465695379 - }, - "endPoint": { - "x": 4970.797055127507, - "y": 5604.546628972224 - } - } - }, - { - "id": "6348f941-11ac-4363-a67e-1651465b714a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4970.797055127507, - "y": 5604.546628972224 - }, - "endPoint": { - "x": 4909.488686537836, - "y": 5624.068087949825 - } - } - }, - { - "id": "66954b3f-da4d-4279-ae6e-35dc58525b75", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4909.488686537836, - "y": 5624.068087949825 - }, - "endPoint": { - "x": 4848.37723543786, - "y": 5643.974701078056 - } - } - }, - { - "id": "3b547c5e-5ef8-48d4-b5ca-2909a32a2a5c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4848.37723543786, - "y": 5643.974701078056 - }, - "endPoint": { - "x": 4735.789354965789, - "y": 5681.869331932277 - } - } - }, - { - "id": "fc65637b-275a-42dc-978d-c309cbf1a6ae", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4735.789354965789, - "y": 5681.869331932277 - }, - "endPoint": { - "x": 4623.708044749044, - "y": 5721.0299459485395 - } - } - }, - { - "id": "75bbaf08-13d4-49e9-9877-6a2f68a6ac8c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4623.708044749044, - "y": 5721.0299459485395 - }, - "endPoint": { - "x": 4511.92040639068, - "y": 5761.315305741504 - } - } - }, - { - "id": "a3437c82-bf95-43d5-8136-e78b52f39e24", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4511.92040639068, - "y": 5761.315305741504 - }, - "endPoint": { - "x": 4400.213541493809, - "y": 5802.584173926152 - } - } - }, - { - "id": "34d55f2e-7f7e-4e53-a281-f8ef485e8e03", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4400.213541493809, - "y": 5802.584173926152 - }, - "endPoint": { - "x": 4288.374551661662, - "y": 5844.695313117409 - } - } - }, - { - "id": "f0629236-ea65-41c5-bcb6-c8f20840f9d4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4288.374551661662, - "y": 5844.695313117409 - }, - "endPoint": { - "x": 4176.190538497409, - "y": 5887.507485930051 - } - } - }, - { - "id": "b644421e-ff37-4352-a39d-a4219c575a16", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4176.190538497409, - "y": 5887.507485930051 - }, - "endPoint": { - "x": 3949.9358485848643, - "y": 5974.66998287887 - } - } - }, - { - "id": "52929190-4537-4338-ade5-d998b357e28d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3949.9358485848643, - "y": 5974.66998287887 - }, - "endPoint": { - "x": 3853.7918536433717, - "y": 6011.631169061206 - } - } - }, - { - "id": "a00e4521-837f-408b-9dbe-0c2a48e5d45a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3853.7918536433717, - "y": 6011.631169061206 - }, - "endPoint": { - "x": 3757.015221629583, - "y": 6048.277403224143 - } - } - }, - { - "id": "945b05d2-7321-4366-912a-517fa86dc0ea", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3757.015221629583, - "y": 6048.277403224143 - }, - "endPoint": { - "x": 3659.6684827833087, - "y": 6084.0977577585145 - } - } - }, - { - "id": "27b00e87-c4d3-4d51-a29c-4d3b2c569ec7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3659.6684827833087, - "y": 6084.0977577585145 - }, - "endPoint": { - "x": 3610.800863997778, - "y": 6101.53856528725 - } - } - }, - { - "id": "f610f547-7944-4c90-8f23-c440dfdbbc36", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3610.800863997778, - "y": 6101.53856528725 - }, - "endPoint": { - "x": 3561.814167343662, - "y": 6118.581305055239 - } - } - }, - { - "id": "fab9d022-03b3-4ee1-96ca-7f605c0584c7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3561.814167343662, - "y": 6118.581305055239 - }, - "endPoint": { - "x": 3512.716209101025, - "y": 6135.162111111422 - } - } - }, - { - "id": "b3250a34-a092-41a3-b0e1-72a33ad6799f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3512.716209101025, - "y": 6135.162111111422 - }, - "endPoint": { - "x": 3463.514805549872, - "y": 6151.217117504799 - } - } - }, - { - "id": "8a432e1b-2529-4071-913f-11ab2056c5a3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3463.514805549872, - "y": 6151.217117504799 - }, - "endPoint": { - "x": 3414.2177729698597, - "y": 6166.682458284049 - } - } - }, - { - "id": "5fe28196-212b-44ce-b88e-40dd86b2f3ee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3414.2177729698597, - "y": 6166.682458284049 - }, - "endPoint": { - "x": 3364.832927641226, - "y": 6181.494267498114 - } - } - }, - { - "id": "9854a0a2-49ed-43ca-be56-b077094d511c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3364.832927641226, - "y": 6181.494267498114 - }, - "endPoint": { - "x": 3315.3680858436273, - "y": 6195.588679195731 - } - } - }, - { - "id": "f25559da-32ef-40a7-90e5-c0047234e82d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3315.3680858436273, - "y": 6195.588679195731 - }, - "endPoint": { - "x": 3265.8310638570692, - "y": 6208.901827425812 - } - } - }, - { - "id": "3da510d7-87c3-4773-95ee-a966a0f68c40", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3265.8310638570692, - "y": 6208.901827425812 - }, - "endPoint": { - "x": 3216.2296779614408, - "y": 6221.369846237241 - } - } - }, - { - "id": "e6b6051d-6e85-4cf9-8583-056be3b16d54", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3216.2296779614408, - "y": 6221.369846237241 - }, - "endPoint": { - "x": 3166.5717444367474, - "y": 6232.928869678784 - } - } - } - ] - }, - { - "id": "6a39c14b-b853-40e2-b131-bda6d54c17c5", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "8df34d07-ed1a-4530-8eb9-fd53ee933c90", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3166.5717444367474, - "y": 6232.9288696788135 - }, - "endPoint": { - "x": 3091.6470452183858, - "y": 6252.517181223113 - } - } - }, - { - "id": "c407486d-97a3-41aa-bb17-96f0a1ea3da8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3091.6470452183858, - "y": 6252.517181223113 - }, - "endPoint": { - "x": 3016.7527688441915, - "y": 6272.509237524238 - } - } - }, - { - "id": "d73908fe-0ec2-477e-9ace-882decf6c29d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3016.7527688441915, - "y": 6272.509237524238 - }, - "endPoint": { - "x": 2941.9193381582736, - "y": 6293.308783338842 - } - } - }, - { - "id": "70e4d2d0-7c04-4783-b9cd-d5d0fe388666", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2941.9193381582736, - "y": 6293.308783338842 - }, - "endPoint": { - "x": 2867.177176004916, - "y": 6315.31956342404 - } - } - }, - { - "id": "2d4daef7-9ac0-47e8-a5a3-b9b0765bee78", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2867.177176004916, - "y": 6315.31956342404 - }, - "endPoint": { - "x": 2803.685612837202, - "y": 6335.209821868193 - } - } - }, - { - "id": "2fd210ce-5e00-4a8e-bd77-9ab8367b6929", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2803.685612837202, - "y": 6335.209821868193 - }, - "endPoint": { - "x": 2740.257035291812, - "y": 6355.986764791596 - } - } - }, - { - "id": "4ba6fe3a-21e7-4d61-a30c-10b9cf64397d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2740.257035291812, - "y": 6355.986764791596 - }, - "endPoint": { - "x": 2613.488559634716, - "y": 6399.073094457388 - } - } - }, - { - "id": "e22d3b02-a0cb-40c2-a3d1-638dd1b2fa7e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2613.488559634716, - "y": 6399.073094457388 - }, - "endPoint": { - "x": 2486.6711941683316, - "y": 6442.323333184671 - } - } - }, - { - "id": "6cc9d8d6-c6cd-4cc6-875a-502592481fd2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2486.6711941683316, - "y": 6442.323333184671 - }, - "endPoint": { - "x": 2423.1815043610404, - "y": 6463.305162434786 - } - } - }, - { - "id": "af9f404a-a3e4-48b8-ae1e-9a1e374145ca", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2423.1815043610404, - "y": 6463.305162434786 - }, - "endPoint": { - "x": 2359.604384026723, - "y": 6483.482261736353 - } - } - }, - { - "id": "f7dcdecc-d893-452d-bb44-bca9f8366cf8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2359.604384026723, - "y": 6483.482261736353 - }, - "endPoint": { - "x": 2295.914763807319, - "y": 6502.5727286848705 - } - } - }, - { - "id": "c82f2edd-1321-46df-b8e2-65bc234e0ba3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2295.914763807319, - "y": 6502.5727286848705 - }, - "endPoint": { - "x": 2232.0875743444776, - "y": 6520.294660875632 - } - } - }, - { - "id": "cf1c50fb-7e0b-4e50-9910-e15bd04e1ba0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2232.0875743444776, - "y": 6520.294660875632 - }, - "endPoint": { - "x": 2200.114556972403, - "y": 6528.554331935418 - } - } - }, - { - "id": "94a2641f-32d3-4435-9c8f-c6d3db7718e3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2200.114556972403, - "y": 6528.554331935418 - }, - "endPoint": { - "x": 2168.0977462801384, - "y": 6536.3661559040775 - } - } - }, - { - "id": "36c63965-56fa-498e-95de-2ce3de4b259d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2168.0977462801384, - "y": 6536.3661559040775 - }, - "endPoint": { - "x": 2136.034008597955, - "y": 6543.694894980959 - } - } - }, - { - "id": "d1f00357-a2a9-4903-a612-1db711d3172b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2136.034008597955, - "y": 6543.694894980959 - }, - "endPoint": { - "x": 2103.920210256125, - "y": 6550.505311365676 - } - } - }, - { - "id": "b667f287-668d-4be4-8019-24e6ad0858f8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2103.920210256125, - "y": 6550.505311365676 - }, - "endPoint": { - "x": 2071.7532175846864, - "y": 6556.762167257373 - } - } - }, - { - "id": "9e423955-01e3-4594-8509-de674e2cc4e1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2071.7532175846864, - "y": 6556.762167257373 - }, - "endPoint": { - "x": 2039.5298969139694, - "y": 6562.430224855605 - } - } - }, - { - "id": "216070ad-ac12-4b75-9342-c2c716441ab1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2039.5298969139694, - "y": 6562.430224855605 - }, - "endPoint": { - "x": 2007.2471145743039, - "y": 6567.474246359809 - } - } - }, - { - "id": "10ab7817-0f83-4d45-81eb-074500c024be", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2007.2471145743039, - "y": 6567.474246359809 - }, - "endPoint": { - "x": 1974.9017368958448, - "y": 6571.85899396945 - } - } - }, - { - "id": "8c3ddf57-96e7-450d-8d1a-e01020a8320e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1974.9017368958448, - "y": 6571.85899396945 - }, - "endPoint": { - "x": 1942.490630208631, - "y": 6575.5492298837635 - } - } - }, - { - "id": "30e4baae-de4e-481c-b373-90190b27fb6e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1942.490630208631, - "y": 6575.5492298837635 - }, - "endPoint": { - "x": 1910.0106608432252, - "y": 6578.5097163024475 - } - } - }, - { - "id": "6f60f9bf-ed86-4a33-908b-af70d54bbed6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1910.0106608432252, - "y": 6578.5097163024475 - }, - "endPoint": { - "x": 1877.4586951296078, - "y": 6580.705215424678 - } - } - }, - { - "id": "ef7484de-a786-4265-bb54-114d36f26c86", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1877.4586951296078, - "y": 6580.705215424678 - }, - "endPoint": { - "x": 1844.8315993981087, - "y": 6582.100489450037 - } - } - }, - { - "id": "67f84fd1-64b7-48be-a4f3-1b5f51e8bbe0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1844.8315993981087, - "y": 6582.100489450037 - }, - "endPoint": { - "x": 1799.8553089759662, - "y": 6582.6958551862335 - } - } - }, - { - "id": "81ff0ea7-abbf-429e-a772-e861dd7add44", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1799.8553089759662, - "y": 6582.6958551862335 - }, - "endPoint": { - "x": 1754.7496043867432, - "y": 6581.909727944818 - } - } - }, - { - "id": "62cd247f-2225-44db-b4c4-71e5d9eee978", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1754.7496043867432, - "y": 6581.909727944818 - }, - "endPoint": { - "x": 1709.5329733688268, - "y": 6579.93946386539 - } - } - }, - { - "id": "dacc28df-d679-42d9-a49c-6ee291fbd878", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1709.5329733688268, - "y": 6579.93946386539 - }, - "endPoint": { - "x": 1664.2239036601386, - "y": 6576.982419087581 - } - } - }, - { - "id": "ab3cddd0-ea9c-43e1-90de-5066f43a02aa", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1664.2239036601386, - "y": 6576.982419087581 - }, - "endPoint": { - "x": 1618.8408829987748, - "y": 6573.235949751106 - } - } - }, - { - "id": "a4f84b97-ec0c-4835-ba94-dfb72c451034", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1618.8408829987748, - "y": 6573.235949751106 - }, - "endPoint": { - "x": 1573.4023991230642, - "y": 6568.897411995655 - } - } - }, - { - "id": "6683364e-60bd-4205-b542-4ae783407797", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1573.4023991230642, - "y": 6568.897411995655 - }, - "endPoint": { - "x": 1482.4329926809296, - "y": 6559.23355578637 - } - } - } - ] - }, - { - "id": "1ea6e70d-7768-4722-9b75-7b22199347a1", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "c1985971-c3a2-4f7c-a9ff-b0d70c2615c6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9518.306186604023, - "y": 6232.928869678872 - }, - "endPoint": { - "x": 9593.230885822384, - "y": 6252.517181223142 - } - } - }, - { - "id": "74329f49-075e-4e05-8a09-90b2e3fec9c6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9593.230885822384, - "y": 6252.517181223142 - }, - "endPoint": { - "x": 9668.125162196578, - "y": 6272.509237524151 - } - } - }, - { - "id": "a3b0f661-d6fd-402e-8608-ee4727711be3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9668.125162196578, - "y": 6272.509237524151 - }, - "endPoint": { - "x": 9742.958592882438, - "y": 6293.3087833389 - } - } - }, - { - "id": "131d95f5-51e9-4180-93c8-fc77d8ca2fe6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9742.958592882438, - "y": 6293.3087833389 - }, - "endPoint": { - "x": 9817.700755035796, - "y": 6315.319563424011 - } - } - }, - { - "id": "e559b71e-924c-4fe9-86c6-762d62b8d25d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9817.700755035796, - "y": 6315.319563424011 - }, - "endPoint": { - "x": 9881.192318203452, - "y": 6335.209821868106 - } - } - }, - { - "id": "89a198ea-c619-4e7b-8253-2a371353e685", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9881.192318203452, - "y": 6335.209821868106 - }, - "endPoint": { - "x": 9944.620895749016, - "y": 6355.986764791596 - } - } - }, - { - "id": "a6aa8515-4f9e-4c2b-afe6-d6a9d9a4d800", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9944.620895749016, - "y": 6355.986764791596 - }, - "endPoint": { - "x": 10071.389371406054, - "y": 6399.073094457388 - } - } - }, - { - "id": "859ca37a-3835-4ea9-b6b7-c3aade3af25c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10071.389371406054, - "y": 6399.073094457388 - }, - "endPoint": { - "x": 10198.206736872497, - "y": 6442.323333184642 - } - } - }, - { - "id": "2a536408-5c91-4ff5-b0d1-abc2215ae35a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10198.206736872497, - "y": 6442.323333184642 - }, - "endPoint": { - "x": 10261.696426679788, - "y": 6463.305162434728 - } - } - }, - { - "id": "deab5431-4493-4e4e-97d9-24844f426a21", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10261.696426679788, - "y": 6463.305162434728 - }, - "endPoint": { - "x": 10325.273547014047, - "y": 6483.482261736324 - } - } - }, - { - "id": "666029a4-3936-4e2e-9466-9300db75a8c5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10325.273547014047, - "y": 6483.482261736324 - }, - "endPoint": { - "x": 10388.96316723345, - "y": 6502.572728684812 - } - } - }, - { - "id": "481c8bb7-d67c-4608-97e5-107f3aa936f7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10388.96316723345, - "y": 6502.572728684812 - }, - "endPoint": { - "x": 10452.790356696234, - "y": 6520.294660875603 - } - } - }, - { - "id": "5ef1b1e2-5a21-4d19-9977-431187028c33", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10452.790356696234, - "y": 6520.294660875603 - }, - "endPoint": { - "x": 10484.763374068367, - "y": 6528.554331935447 - } - } - }, - { - "id": "bcad4077-c8ca-46c4-a8f5-87f062f02370", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10484.763374068367, - "y": 6528.554331935447 - }, - "endPoint": { - "x": 10516.780184760515, - "y": 6536.366155904019 - } - } - }, - { - "id": "fa99aaed-f2cf-4220-bbd1-c9e69cde17d1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10516.780184760515, - "y": 6536.366155904019 - }, - "endPoint": { - "x": 10548.843922442757, - "y": 6543.694894981018 - } - } - }, - { - "id": "0a96c15a-15c4-4f86-ad1d-28187872fc11", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10548.843922442757, - "y": 6543.694894981018 - }, - "endPoint": { - "x": 10580.957720784703, - "y": 6550.505311365647 - } - } - }, - { - "id": "b8161913-3e51-4554-a4d8-7122ac714ad5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10580.957720784703, - "y": 6550.505311365647 - }, - "endPoint": { - "x": 10613.124713456084, - "y": 6556.762167257344 - } - } - }, - { - "id": "233a4bf7-1af2-4598-aa9a-6547890c68ee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10613.124713456084, - "y": 6556.762167257344 - }, - "endPoint": { - "x": 10645.3480341268, - "y": 6562.430224855605 - } - } - }, - { - "id": "4ec211ef-ec24-4d31-b2ef-3377e7628bcd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10645.3480341268, - "y": 6562.430224855605 - }, - "endPoint": { - "x": 10677.630816466524, - "y": 6567.474246359838 - } - } - }, - { - "id": "200fc3c6-fc8c-494c-8eec-237001d76cdf", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10677.630816466524, - "y": 6567.474246359838 - }, - "endPoint": { - "x": 10709.976194144983, - "y": 6571.858993969392 - } - } - }, - { - "id": "95c8ee1f-6406-4105-b1e0-5c882c0d95f6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10709.976194144983, - "y": 6571.858993969392 - }, - "endPoint": { - "x": 10742.38730083208, - "y": 6575.549229883822 - } - } - }, - { - "id": "2991ce71-828a-44a7-8cf1-e06ad200c61f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10742.38730083208, - "y": 6575.549229883822 - }, - "endPoint": { - "x": 10774.867270197603, - "y": 6578.509716302506 - } - } - }, - { - "id": "09184757-9f51-4514-9d73-b3d5a4ab48e7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10774.867270197603, - "y": 6578.509716302506 - }, - "endPoint": { - "x": 10807.419235911162, - "y": 6580.705215424707 - } - } - }, - { - "id": "53c9fe12-290f-4aff-8f1e-1708dc46f16b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10807.419235911162, - "y": 6580.705215424707 - }, - "endPoint": { - "x": 10840.04633164272, - "y": 6582.100489450095 - } - } - }, - { - "id": "540feaa0-d127-4309-8c66-3fefd7db9b1e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10840.04633164272, - "y": 6582.100489450095 - }, - "endPoint": { - "x": 10885.022622064804, - "y": 6582.69585518635 - } - } - }, - { - "id": "5b045a71-bee4-4729-b414-0895eece7250", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10885.022622064804, - "y": 6582.69585518635 - }, - "endPoint": { - "x": 10930.128326653969, - "y": 6581.909727944934 - } - } - }, - { - "id": "d3fb8299-040e-4e01-9efd-ba324fc1b374", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10930.128326653969, - "y": 6581.909727944934 - }, - "endPoint": { - "x": 10975.344957671885, - "y": 6579.939463865419 - } - } - }, - { - "id": "b0f49bc0-c851-4ff5-ab2c-0627f3e6953f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10975.344957671885, - "y": 6579.939463865419 - }, - "endPoint": { - "x": 11020.654027380631, - "y": 6576.982419087668 - } - } - }, - { - "id": "494721fd-c673-44c8-9f9b-e0ec86128289", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11020.654027380631, - "y": 6576.982419087668 - }, - "endPoint": { - "x": 11066.037048041937, - "y": 6573.235949751193 - } - } - }, - { - "id": "01deddec-8f1f-4774-b103-bfd78fd6d417", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11066.037048041937, - "y": 6573.235949751193 - }, - "endPoint": { - "x": 11111.475531917647, - "y": 6568.897411995713 - } - } - }, - { - "id": "95f1b9e2-0e75-47f1-92a4-00d666624f47", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11111.475531917647, - "y": 6568.897411995713 - }, - "endPoint": { - "x": 11202.44493835984, - "y": 6559.233555786399 - } - } - } - ] - }, - { - "id": "891f1d77-5414-49cf-9f51-ee4105f1d8d6", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "f7eafcd5-0452-4734-956e-3d611ea34f7a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 7093.054851264518, - "y": 5424.866589938145 - } - } - }, - { - "id": "6b44bc5d-c730-4235-a35e-33fd6d2fd4e3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7093.054851264518, - "y": 5424.866589938145 - }, - "endPoint": { - "x": 7218.100237483799, - "y": 5459.159273368976 - } - } - }, - { - "id": "660faf16-b815-451b-954a-56ad0d907063", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7218.100237483799, - "y": 5459.159273368976 - }, - "endPoint": { - "x": 7342.8305557193235, - "y": 5494.068203440722 - } - } - }, - { - "id": "dcb49652-94f0-43b3-9165-192409675069", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7342.8305557193235, - "y": 5494.068203440722 - }, - "endPoint": { - "x": 7467.140783310228, - "y": 5529.798795700539 - } - } - }, - { - "id": "55d35119-7e74-40b3-9882-222e6f915b6e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7467.140783310228, - "y": 5529.798795700539 - }, - "endPoint": { - "x": 7590.92589759524, - "y": 5566.556465695379 - } - } - }, - { - "id": "63c6d7be-b2fc-4bc0-ba89-e64787e9647a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7590.92589759524, - "y": 5566.556465695379 - }, - "endPoint": { - "x": 7714.080875913147, - "y": 5604.546628972224 - } - } - }, - { - "id": "7aec1cce-aca1-46c4-bbfa-7f23bcbc063c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7714.080875913147, - "y": 5604.546628972224 - }, - "endPoint": { - "x": 7775.389244502818, - "y": 5624.068087949825 - } - } - }, - { - "id": "a9dbe888-8aa2-4426-8cfb-b905205c0beb", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7775.389244502818, - "y": 5624.068087949825 - }, - "endPoint": { - "x": 7836.500695602677, - "y": 5643.974701078056 - } - } - }, - { - "id": "82a002ff-2cfb-49b6-a97f-2b86815c4f9e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7836.500695602677, - "y": 5643.974701078056 - }, - "endPoint": { - "x": 7949.088576074748, - "y": 5681.869331932277 - } - } - }, - { - "id": "dad2626d-8ce9-4f4e-849b-0094a923e45a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7949.088576074748, - "y": 5681.869331932277 - }, - "endPoint": { - "x": 8061.169886291551, - "y": 5721.0299459485395 - } - } - }, - { - "id": "410b251e-782e-4de0-a63e-238ed0da6295", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8061.169886291551, - "y": 5721.0299459485395 - }, - "endPoint": { - "x": 8172.957524649915, - "y": 5761.315305741504 - } - } - }, - { - "id": "43859008-17cf-4f6b-9ce5-f26b21d64c55", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8172.957524649915, - "y": 5761.315305741504 - }, - "endPoint": { - "x": 8284.664389546728, - "y": 5802.584173926152 - } - } - }, - { - "id": "d0bd27d7-6e09-49c5-a988-230a9bdbeec3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8284.664389546728, - "y": 5802.584173926152 - }, - "endPoint": { - "x": 8396.503379378817, - "y": 5844.695313117409 - } - } - }, - { - "id": "38da250b-dd6b-47fc-a9d6-e8daa67c6bd1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8396.503379378817, - "y": 5844.695313117409 - }, - "endPoint": { - "x": 8508.687392543245, - "y": 5887.507485930051 - } - } - }, - { - "id": "c515b3fe-dcde-4bcb-a33f-31aae9c12275", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8508.687392543245, - "y": 5887.507485930051 - }, - "endPoint": { - "x": 8734.94208245579, - "y": 5974.66998287887 - } - } - }, - { - "id": "2fc9fe55-a771-432b-8988-32bc5320fecd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8734.94208245579, - "y": 5974.66998287887 - }, - "endPoint": { - "x": 8831.086077397456, - "y": 6011.631169061206 - } - } - }, - { - "id": "d4fac64f-c8aa-4cd6-8fa6-934ac7385c8c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8831.086077397456, - "y": 6011.631169061206 - }, - "endPoint": { - "x": 8927.862709410954, - "y": 6048.277403224143 - } - } - }, - { - "id": "f600f7e8-3902-4123-9378-c984e7838bfd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8927.862709410954, - "y": 6048.277403224143 - }, - "endPoint": { - "x": 9025.209448257228, - "y": 6084.0977577585145 - } - } - }, - { - "id": "418751a6-927c-474d-b9b8-fce27667fc87", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9025.209448257228, - "y": 6084.0977577585145 - }, - "endPoint": { - "x": 9074.077067042992, - "y": 6101.53856528725 - } - } - }, - { - "id": "41fe7d3a-0873-4696-abfa-777228e23679", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9074.077067042992, - "y": 6101.53856528725 - }, - "endPoint": { - "x": 9123.063763696991, - "y": 6118.581305055239 - } - } - }, - { - "id": "a1278d9b-bc51-4f0f-aa7c-acd53f128ebc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9123.063763696991, - "y": 6118.581305055239 - }, - "endPoint": { - "x": 9172.16172193957, - "y": 6135.162111111422 - } - } - }, - { - "id": "4661a342-4c65-433a-ab83-a89bc2c93946", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9172.16172193957, - "y": 6135.162111111422 - }, - "endPoint": { - "x": 9221.363125490781, - "y": 6151.217117504799 - } - } - }, - { - "id": "7c25d6ec-a730-43e9-b6a6-c5b803123cf0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9221.363125490781, - "y": 6151.217117504799 - }, - "endPoint": { - "x": 9270.660158070736, - "y": 6166.682458284049 - } - } - }, - { - "id": "e091e203-3955-429f-bf93-9b1ae6cb7770", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9270.660158070736, - "y": 6166.682458284049 - }, - "endPoint": { - "x": 9320.045003399428, - "y": 6181.494267498114 - } - } - }, - { - "id": "63b20fed-6604-41e0-b59b-e8334c6d8e76", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9320.045003399428, - "y": 6181.494267498114 - }, - "endPoint": { - "x": 9369.509845196968, - "y": 6195.588679195731 - } - } - }, - { - "id": "9b43bdee-2b8a-461f-9ecc-e14a4731ae15", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9369.509845196968, - "y": 6195.588679195731 - }, - "endPoint": { - "x": 9419.046867183526, - "y": 6208.901827425812 - } - } - }, - { - "id": "977562eb-b2b7-481f-83c7-e41891736e5f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9419.046867183526, - "y": 6208.901827425812 - }, - "endPoint": { - "x": 9468.648253079155, - "y": 6221.369846237241 - } - } - }, - { - "id": "a86e37a2-be85-4d1f-9af8-f23c614917eb", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9468.648253079155, - "y": 6221.369846237241 - }, - "endPoint": { - "x": 9518.306186603906, - "y": 6232.928869678784 - } - } - } - ] - }, - { - "id": "2db3a5d3-3411-44f5-baee-80d88ff6625a", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "e4fef742-369d-4ce8-adb1-7e79e7bcca95", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3166.5717444367474, - "y": 6032.928869678726 - }, - "endPoint": { - "x": 3091.6470452183858, - "y": 6052.517181223026 - } - } - }, - { - "id": "205391cc-c7e3-44be-976c-a78ed686c835", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3091.6470452183858, - "y": 6052.517181223026 - }, - "endPoint": { - "x": 3016.7527688441915, - "y": 6072.50923752418 - } - } - }, - { - "id": "9c03c9f0-6d50-44e0-927f-f103ed150601", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3016.7527688441915, - "y": 6072.50923752418 - }, - "endPoint": { - "x": 2941.9193381582736, - "y": 6093.308783338813 - } - } - }, - { - "id": "a2f65234-507d-4c5d-9957-5a4920b2dccf", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2941.9193381582736, - "y": 6093.308783338813 - }, - "endPoint": { - "x": 2867.177176004916, - "y": 6115.319563423982 - } - } - }, - { - "id": "b62abcea-f6c1-4a8c-ba36-faac9c104d0a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2867.177176004916, - "y": 6115.319563423982 - }, - "endPoint": { - "x": 2803.685612837202, - "y": 6135.209821868077 - } - } - }, - { - "id": "7621badd-3c51-4460-a231-d86dd04ae73a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2803.685612837202, - "y": 6135.209821868077 - }, - "endPoint": { - "x": 2740.257035291812, - "y": 6155.98676479148 - } - } - }, - { - "id": "82fd3e94-fb1d-433e-9149-bdffbf4533ac", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2740.257035291812, - "y": 6155.98676479148 - }, - "endPoint": { - "x": 2613.488559634716, - "y": 6199.073094457359 - } - } - }, - { - "id": "a9f2d56a-d4b2-48d2-a349-2ba0a77128f9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2613.488559634716, - "y": 6199.073094457359 - }, - "endPoint": { - "x": 2486.6711941683316, - "y": 6242.323333184613 - } - } - }, - { - "id": "99e71570-4007-40ad-bc79-385d9f7f5eaa", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2486.6711941683316, - "y": 6242.323333184613 - }, - "endPoint": { - "x": 2423.1815043610404, - "y": 6263.305162434728 - } - } - }, - { - "id": "2565ae06-fce6-476f-b9e4-03c65ba3df57", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2423.1815043610404, - "y": 6263.305162434728 - }, - "endPoint": { - "x": 2359.604384026723, - "y": 6283.482261736324 - } - } - }, - { - "id": "eb27c4f2-c5f8-4348-bc89-a5232cf5efe7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2359.604384026723, - "y": 6283.482261736324 - }, - "endPoint": { - "x": 2295.914763807319, - "y": 6302.572728684783 - } - } - }, - { - "id": "0009828d-def1-45b4-940a-9cd9eeaf3e39", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2295.914763807319, - "y": 6302.572728684783 - }, - "endPoint": { - "x": 2232.0875743444776, - "y": 6320.294660875574 - } - } - }, - { - "id": "db03dd23-3905-42db-bf92-874d0a6dcf98", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2232.0875743444776, - "y": 6320.294660875574 - }, - "endPoint": { - "x": 2200.114556972403, - "y": 6328.554331935389 - } - } - }, - { - "id": "cc73a647-d026-4899-b03f-abd89308c926", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2200.114556972403, - "y": 6328.554331935389 - }, - "endPoint": { - "x": 2168.0977462801384, - "y": 6336.366155904019 - } - } - }, - { - "id": "9cb98170-7218-4c96-94b1-84fbd9afa7fb", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2168.0977462801384, - "y": 6336.366155904019 - }, - "endPoint": { - "x": 2136.034008597955, - "y": 6343.694894980901 - } - } - }, - { - "id": "605854d8-a774-4ffa-8ed0-be379ae31592", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2136.034008597955, - "y": 6343.694894980901 - }, - "endPoint": { - "x": 2103.920210256125, - "y": 6350.505311365589 - } - } - }, - { - "id": "a426fc74-1407-4da4-b0fc-d92ba9f9ae91", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2103.920210256125, - "y": 6350.505311365589 - }, - "endPoint": { - "x": 2071.7532175846864, - "y": 6356.762167257286 - } - } - }, - { - "id": "9f019def-22b3-49b2-90cd-18704efa4f01", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2071.7532175846864, - "y": 6356.762167257286 - }, - "endPoint": { - "x": 2039.5298969139694, - "y": 6362.430224855547 - } - } - }, - { - "id": "bd8805f7-9cac-4b2d-9911-34eacc7ae102", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2039.5298969139694, - "y": 6362.430224855547 - }, - "endPoint": { - "x": 2007.2471145743039, - "y": 6367.474246359721 - } - } - }, - { - "id": "310b7537-ca21-4154-874e-b0f6d7dcb896", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 2007.2471145743039, - "y": 6367.474246359721 - }, - "endPoint": { - "x": 1974.9017368958448, - "y": 6371.858993969334 - } - } - }, - { - "id": "45561b37-8b18-49fa-9cf7-5bf73252d318", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1974.9017368958448, - "y": 6371.858993969334 - }, - "endPoint": { - "x": 1942.490630208631, - "y": 6375.549229883705 - } - } - }, - { - "id": "09fabc27-4463-4d4a-ac3c-e318584ed14e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1942.490630208631, - "y": 6375.549229883705 - }, - "endPoint": { - "x": 1910.0106608432252, - "y": 6378.50971630236 - } - } - }, - { - "id": "661f58e3-7d5a-431d-a6ad-37ae1f1e42df", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1910.0106608432252, - "y": 6378.50971630236 - }, - "endPoint": { - "x": 1877.4586951296078, - "y": 6380.70521542459 - } - } - }, - { - "id": "84d0f81f-cc61-41a2-9bde-e7e95b09e061", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1877.4586951296078, - "y": 6380.70521542459 - }, - "endPoint": { - "x": 1844.8315993981087, - "y": 6382.100489449949 - } - } - }, - { - "id": "80b82fe5-c8ce-45a6-944b-3904a5f2893f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1844.8315993981087, - "y": 6382.100489449949 - }, - "endPoint": { - "x": 1799.8553089759662, - "y": 6382.695855186175 - } - } - }, - { - "id": "1e866fed-7d50-44e3-afc0-9bcf5e01b487", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1799.8553089759662, - "y": 6382.695855186175 - }, - "endPoint": { - "x": 1754.7496043867432, - "y": 6381.909727944731 - } - } - }, - { - "id": "9cb10094-3052-4dcb-8331-5ee007c07523", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1754.7496043867432, - "y": 6381.909727944731 - }, - "endPoint": { - "x": 1709.5329733688268, - "y": 6379.939463865332 - } - } - }, - { - "id": "79a93ae7-2449-47f6-ac9e-62bc9bb2980f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1709.5329733688268, - "y": 6379.939463865332 - }, - "endPoint": { - "x": 1664.2239036601386, - "y": 6376.982419087522 - } - } - }, - { - "id": "6c95801e-1136-45f2-b832-64c378d84b98", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1664.2239036601386, - "y": 6376.982419087522 - }, - "endPoint": { - "x": 1618.8408829987748, - "y": 6373.235949751106 - } - } - }, - { - "id": "3914d873-5259-44b2-b1bb-b237884f8824", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1618.8408829987748, - "y": 6373.235949751106 - }, - "endPoint": { - "x": 1573.4023991230642, - "y": 6368.897411995655 - } - } - }, - { - "id": "db51a264-36fe-431a-8aa4-052e1601ba3c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 1573.4023991230642, - "y": 6368.897411995655 - }, - "endPoint": { - "x": 1482.4329926809296, - "y": 6359.23355578637 - } - } - } - ] - }, - { - "id": "0493c8d2-72be-49c0-80e6-b49be130880d", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "61fb5f35-1177-4322-a8ca-dc3d37e1674e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 5157.308300811681 - }, - "endPoint": { - "x": 5591.823079775961, - "y": 5224.866589938174 - } - } - }, - { - "id": "a6df22e8-e6b3-4fb6-a582-ba0b01139c11", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5591.823079775961, - "y": 5224.866589938174 - }, - "endPoint": { - "x": 5466.777693556971, - "y": 5259.159273369005 - } - } - }, - { - "id": "18297b86-18dd-4df0-9187-335a7d3ec6d4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5466.777693556971, - "y": 5259.159273369005 - }, - "endPoint": { - "x": 5342.047375321272, - "y": 5294.068203440751 - } - } - }, - { - "id": "6ec540ca-1a33-4da5-bec4-a8cec18ff015", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.047375321272, - "y": 5294.068203440751 - }, - "endPoint": { - "x": 5217.737147730368, - "y": 5329.798795700539 - } - } - }, - { - "id": "ca827cd2-e7c8-49d4-8ddb-2ef08a05af40", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5217.737147730368, - "y": 5329.798795700539 - }, - "endPoint": { - "x": 5093.952033445414, - "y": 5366.556465695379 - } - } - }, - { - "id": "a7d5682a-9982-4b7a-b8c2-5cb09a8cf567", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5093.952033445414, - "y": 5366.556465695379 - }, - "endPoint": { - "x": 4970.797055127507, - "y": 5404.546628972224 - } - } - }, - { - "id": "4dcc9947-dc59-459d-9f70-d2ae42f74d4f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4970.797055127507, - "y": 5404.546628972224 - }, - "endPoint": { - "x": 4909.488686537836, - "y": 5424.068087949825 - } - } - }, - { - "id": "75209cb4-34d2-41b8-ac0c-d634093dd4ee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4909.488686537836, - "y": 5424.068087949825 - }, - "endPoint": { - "x": 4848.37723543786, - "y": 5443.974701078027 - } - } - }, - { - "id": "47243ec6-be74-4600-8425-b22362dfb3c3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4848.37723543786, - "y": 5443.974701078027 - }, - "endPoint": { - "x": 4735.789354965789, - "y": 5481.869331932248 - } - } - }, - { - "id": "501dbee6-6edc-432b-aa1c-d56f589804a9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4735.789354965789, - "y": 5481.869331932248 - }, - "endPoint": { - "x": 4623.708044749044, - "y": 5521.029945948481 - } - } - }, - { - "id": "18d7c357-9f14-4ec3-ad23-cd1243e7fce6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4623.708044749044, - "y": 5521.029945948481 - }, - "endPoint": { - "x": 4511.92040639068, - "y": 5561.315305741475 - } - } - }, - { - "id": "ccd6e4c8-cdb5-4328-aaae-0ceacebdda46", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4511.92040639068, - "y": 5561.315305741475 - }, - "endPoint": { - "x": 4400.213541493809, - "y": 5602.5841739262105 - } - } - }, - { - "id": "c96ce8a6-04fe-4a52-aa56-755a0dd4c5ec", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4400.213541493809, - "y": 5602.5841739262105 - }, - "endPoint": { - "x": 4288.374551661662, - "y": 5644.695313117438 - } - } - }, - { - "id": "8c093d67-f1cc-4e3c-a3ad-ca7511f717a5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4288.374551661662, - "y": 5644.695313117438 - }, - "endPoint": { - "x": 4176.190538497409, - "y": 5687.507485930051 - } - } - }, - { - "id": "591b01e2-4671-4c05-a9c3-50f67b49bac1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4176.190538497409, - "y": 5687.507485930051 - }, - "endPoint": { - "x": 3949.9358485848643, - "y": 5774.66998287887 - } - } - }, - { - "id": "0a9f251a-c60e-4d22-b925-0fa9c12312bc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3949.9358485848643, - "y": 5774.66998287887 - }, - "endPoint": { - "x": 3853.7918536433717, - "y": 5811.631169061206 - } - } - }, - { - "id": "cb41ba6c-dd1a-4ebb-baf1-21ad6311cdfc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3853.7918536433717, - "y": 5811.631169061206 - }, - "endPoint": { - "x": 3757.015221629583, - "y": 5848.277403224143 - } - } - }, - { - "id": "e73238c8-ad25-4830-bd86-8d4025f33966", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3757.015221629583, - "y": 5848.277403224143 - }, - "endPoint": { - "x": 3659.6684827833087, - "y": 5884.097757758544 - } - } - }, - { - "id": "72fadaaf-4ffa-41f5-b498-360c891d74cc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3659.6684827833087, - "y": 5884.097757758544 - }, - "endPoint": { - "x": 3610.800863997778, - "y": 5901.53856528725 - } - } - }, - { - "id": "8d4aead1-9544-43d8-9ff4-b406cc41d4e4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3610.800863997778, - "y": 5901.53856528725 - }, - "endPoint": { - "x": 3561.814167343662, - "y": 5918.581305055239 - } - } - }, - { - "id": "498dda01-04a7-4a5a-abf8-5d6183c12c5a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3561.814167343662, - "y": 5918.581305055239 - }, - "endPoint": { - "x": 3512.716209101025, - "y": 5935.162111111451 - } - } - }, - { - "id": "4df3800a-502a-436d-a397-9359fba8211e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3512.716209101025, - "y": 5935.162111111451 - }, - "endPoint": { - "x": 3463.514805549872, - "y": 5951.217117504799 - } - } - }, - { - "id": "4b63f389-6810-43d1-a4b0-b926a3b4a0a2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3463.514805549872, - "y": 5951.217117504799 - }, - "endPoint": { - "x": 3414.2177729698597, - "y": 5966.68245828402 - } - } - }, - { - "id": "85049377-6820-4fbd-b39f-190e41e0b88d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3414.2177729698597, - "y": 5966.68245828402 - }, - "endPoint": { - "x": 3364.832927641226, - "y": 5981.494267498114 - } - } - }, - { - "id": "99c00506-1403-4543-b56d-90ef4bea3e5d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3364.832927641226, - "y": 5981.494267498114 - }, - "endPoint": { - "x": 3315.3680858436273, - "y": 5995.588679195731 - } - } - }, - { - "id": "cc514f5f-0902-426f-8e21-e9b1af2e8ef5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3315.3680858436273, - "y": 5995.588679195731 - }, - "endPoint": { - "x": 3265.8310638570692, - "y": 6008.901827425812 - } - } - }, - { - "id": "24133d55-3675-4adb-b5d1-b4fff8cc3177", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3265.8310638570692, - "y": 6008.901827425812 - }, - "endPoint": { - "x": 3216.2296779614408, - "y": 6021.369846237241 - } - } - }, - { - "id": "2d979ed0-ac82-46aa-8e79-49bacfeebf06", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3216.2296779614408, - "y": 6021.369846237241 - }, - "endPoint": { - "x": 3166.5717444367474, - "y": 6032.928869678784 - } - } - } - ] - }, - { - "id": "6613e650-bd6b-4423-9eec-e75a0ae8d573", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "2cfaba01-2f65-4603-8024-22881fd231ce", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520298, - "y": 5157.308300811681 - }, - "endPoint": { - "x": 7093.054851264518, - "y": 5224.866589938174 - } - } - }, - { - "id": "e60fa548-2cee-4d2b-820f-9a151cf8f9ad", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7093.054851264518, - "y": 5224.866589938174 - }, - "endPoint": { - "x": 7218.100237483799, - "y": 5259.159273369005 - } - } - }, - { - "id": "74856566-50cb-49d6-afa7-36241e3e9d44", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7218.100237483799, - "y": 5259.159273369005 - }, - "endPoint": { - "x": 7342.8305557193235, - "y": 5294.068203440751 - } - } - }, - { - "id": "603d5e3b-0f85-445e-8c18-87486f8186a1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7342.8305557193235, - "y": 5294.068203440751 - }, - "endPoint": { - "x": 7467.140783310228, - "y": 5329.798795700539 - } - } - }, - { - "id": "b56646b8-e4b4-4827-b89d-5f49ba2914d4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7467.140783310228, - "y": 5329.798795700539 - }, - "endPoint": { - "x": 7590.92589759524, - "y": 5366.556465695379 - } - } - }, - { - "id": "873b38a0-9a72-46c9-aa93-8894bc41d207", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7590.92589759524, - "y": 5366.556465695379 - }, - "endPoint": { - "x": 7714.080875913147, - "y": 5404.546628972224 - } - } - }, - { - "id": "dcd77925-72c9-491c-a50e-a3dadeea5793", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7714.080875913147, - "y": 5404.546628972224 - }, - "endPoint": { - "x": 7775.389244502818, - "y": 5424.068087949825 - } - } - }, - { - "id": "4fbd718c-9a9d-4739-a9c9-19d7a74cd1c9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7775.389244502818, - "y": 5424.068087949825 - }, - "endPoint": { - "x": 7836.500695602677, - "y": 5443.974701078027 - } - } - }, - { - "id": "d25bc27e-1082-46bc-9f2d-e4fbf6018dcd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7836.500695602677, - "y": 5443.974701078027 - }, - "endPoint": { - "x": 7949.088576074748, - "y": 5481.869331932248 - } - } - }, - { - "id": "8ed746c5-b4ab-4b72-8fae-b3f650f3fdd8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7949.088576074748, - "y": 5481.869331932248 - }, - "endPoint": { - "x": 8061.169886291551, - "y": 5521.029945948481 - } - } - }, - { - "id": "95ef8545-e4d8-400b-8ad5-3d1ed10b3726", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8061.169886291551, - "y": 5521.029945948481 - }, - "endPoint": { - "x": 8172.957524649915, - "y": 5561.315305741475 - } - } - }, - { - "id": "865c7ebf-81ac-4060-b520-b4565f584bd1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8172.957524649915, - "y": 5561.315305741475 - }, - "endPoint": { - "x": 8284.664389546728, - "y": 5602.5841739262105 - } - } - }, - { - "id": "4fc353ea-15cb-40df-9618-85e5b158b007", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8284.664389546728, - "y": 5602.5841739262105 - }, - "endPoint": { - "x": 8396.503379378817, - "y": 5644.695313117438 - } - } - }, - { - "id": "d9d05f71-e1d9-424f-8e8a-5df3f2f7216f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8396.503379378817, - "y": 5644.695313117438 - }, - "endPoint": { - "x": 8508.687392543245, - "y": 5687.507485930051 - } - } - }, - { - "id": "af8bdf53-54a0-4b14-a0d7-459e63142e60", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8508.687392543245, - "y": 5687.507485930051 - }, - "endPoint": { - "x": 8734.94208245579, - "y": 5774.66998287887 - } - } - }, - { - "id": "1451f219-72f7-433a-a4a0-f9c0ed2ea43c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8734.94208245579, - "y": 5774.66998287887 - }, - "endPoint": { - "x": 8831.086077397456, - "y": 5811.631169061206 - } - } - }, - { - "id": "a6715785-ad41-413f-a4f9-b59508609781", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8831.086077397456, - "y": 5811.631169061206 - }, - "endPoint": { - "x": 8927.862709410954, - "y": 5848.277403224143 - } - } - }, - { - "id": "f72f8fa4-76bc-45d2-b307-7b07b3159a84", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8927.862709410954, - "y": 5848.277403224143 - }, - "endPoint": { - "x": 9025.209448257228, - "y": 5884.097757758544 - } - } - }, - { - "id": "17ea9727-b06c-42d0-a8c0-ecc0e9a87e75", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9025.209448257228, - "y": 5884.097757758544 - }, - "endPoint": { - "x": 9074.077067042992, - "y": 5901.53856528725 - } - } - }, - { - "id": "1ed071b8-dea4-4cab-a341-3df068f852ab", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9074.077067042992, - "y": 5901.53856528725 - }, - "endPoint": { - "x": 9123.063763696991, - "y": 5918.581305055239 - } - } - }, - { - "id": "d743f25c-bc8d-43e2-a0d2-ff55ae6810be", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9123.063763696991, - "y": 5918.581305055239 - }, - "endPoint": { - "x": 9172.16172193957, - "y": 5935.162111111451 - } - } - }, - { - "id": "16fe2e99-bf74-487d-a775-e751cf261033", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9172.16172193957, - "y": 5935.162111111451 - }, - "endPoint": { - "x": 9221.363125490781, - "y": 5951.217117504799 - } - } - }, - { - "id": "0bab9fb9-6d5c-4344-b943-59a2bdad00f1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9221.363125490781, - "y": 5951.217117504799 - }, - "endPoint": { - "x": 9270.660158070736, - "y": 5966.68245828402 - } - } - }, - { - "id": "07a41a23-2997-4f55-b517-38a8ecb68f5d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9270.660158070736, - "y": 5966.68245828402 - }, - "endPoint": { - "x": 9320.045003399428, - "y": 5981.494267498114 - } - } - }, - { - "id": "e899f368-8a3b-4ea4-9ac1-d29bf0d5e0e1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9320.045003399428, - "y": 5981.494267498114 - }, - "endPoint": { - "x": 9369.509845196968, - "y": 5995.588679195731 - } - } - }, - { - "id": "754802f2-81dd-4172-9a35-3c68e988a5cc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9369.509845196968, - "y": 5995.588679195731 - }, - "endPoint": { - "x": 9419.046867183526, - "y": 6008.901827425812 - } - } - }, - { - "id": "7c6fc6d1-d66d-4c42-892d-c8f4dabbff7d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9419.046867183526, - "y": 6008.901827425812 - }, - "endPoint": { - "x": 9468.648253079155, - "y": 6021.369846237241 - } - } - }, - { - "id": "a3c055e2-a75e-4c49-a25e-66ba049075dc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9468.648253079155, - "y": 6021.369846237241 - }, - "endPoint": { - "x": 9518.306186603906, - "y": 6032.928869678784 - } - } - } - ] - }, - { - "id": "51e13f38-8fdf-417d-bf7a-956e7ee8cecf", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "a0beae9a-e064-4668-8695-ec038019c48b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9518.306186604023, - "y": 6032.9288696788135 - }, - "endPoint": { - "x": 9593.230885822384, - "y": 6052.517181223084 - } - } - }, - { - "id": "b230dd44-7a43-4b3f-a0e0-60324da08b47", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9593.230885822384, - "y": 6052.517181223084 - }, - "endPoint": { - "x": 9668.125162196578, - "y": 6072.50923752418 - } - } - }, - { - "id": "43da10e4-c72a-47fd-9d82-80e1123ab319", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9668.125162196578, - "y": 6072.50923752418 - }, - "endPoint": { - "x": 9742.958592882438, - "y": 6093.308783338842 - } - } - }, - { - "id": "506727f7-1d12-4168-947f-0a8140065450", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9742.958592882438, - "y": 6093.308783338842 - }, - "endPoint": { - "x": 9817.700755035796, - "y": 6115.319563423982 - } - } - }, - { - "id": "b0bfe535-5ef8-4d2c-b78b-848872572106", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9817.700755035796, - "y": 6115.319563423982 - }, - "endPoint": { - "x": 9881.192318203452, - "y": 6135.209821868077 - } - } - }, - { - "id": "270bc327-cf76-4c88-b8ca-bff292123aa5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9881.192318203452, - "y": 6135.209821868077 - }, - "endPoint": { - "x": 9944.620895749016, - "y": 6155.986764791567 - } - } - }, - { - "id": "a851b004-1dd7-4197-afc2-eb5660588417", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9944.620895749016, - "y": 6155.986764791567 - }, - "endPoint": { - "x": 10071.389371406054, - "y": 6199.073094457388 - } - } - }, - { - "id": "e6cd9078-3eb4-420a-9697-2655879469e8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10071.389371406054, - "y": 6199.073094457388 - }, - "endPoint": { - "x": 10198.206736872497, - "y": 6242.323333184555 - } - } - }, - { - "id": "ca797213-c81a-4283-bc8c-96e53bffb56f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10198.206736872497, - "y": 6242.323333184555 - }, - "endPoint": { - "x": 10261.696426679788, - "y": 6263.305162434641 - } - } - }, - { - "id": "ed8b31a2-c491-46c4-8b7d-a10bb49d19df", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10261.696426679788, - "y": 6263.305162434641 - }, - "endPoint": { - "x": 10325.273547014047, - "y": 6283.482261736266 - } - } - }, - { - "id": "54c7f546-91d7-41e8-b67d-6e13f341aab0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10325.273547014047, - "y": 6283.482261736266 - }, - "endPoint": { - "x": 10388.96316723345, - "y": 6302.572728684754 - } - } - }, - { - "id": "147d28cc-734e-494a-9824-d504caaf2b84", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10388.96316723345, - "y": 6302.572728684754 - }, - "endPoint": { - "x": 10452.790356696234, - "y": 6320.294660875516 - } - } - }, - { - "id": "b4121ae1-d894-48f1-bb38-43ffc8cbef70", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10452.790356696234, - "y": 6320.294660875516 - }, - "endPoint": { - "x": 10484.763374068367, - "y": 6328.55433193536 - } - } - }, - { - "id": "d85d8c9f-2633-4e27-b43f-8220b34e7ee8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10484.763374068367, - "y": 6328.55433193536 - }, - "endPoint": { - "x": 10516.780184760515, - "y": 6336.366155903932 - } - } - }, - { - "id": "634abf90-5fd4-40ba-abe3-ca09d24cfcca", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10516.780184760515, - "y": 6336.366155903932 - }, - "endPoint": { - "x": 10548.843922442757, - "y": 6343.694894980872 - } - } - }, - { - "id": "b3aae130-a692-4ae7-a88b-cfb311f7bb95", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10548.843922442757, - "y": 6343.694894980872 - }, - "endPoint": { - "x": 10580.957720784703, - "y": 6350.50531136553 - } - } - }, - { - "id": "7411508e-d24a-405e-a07c-a100eaca389a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10580.957720784703, - "y": 6350.50531136553 - }, - "endPoint": { - "x": 10613.124713456084, - "y": 6356.762167257199 - } - } - }, - { - "id": "7b4c5492-7104-4dc8-a5e7-a0c87edb1b76", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10613.124713456084, - "y": 6356.762167257199 - }, - "endPoint": { - "x": 10645.3480341268, - "y": 6362.430224855489 - } - } - }, - { - "id": "49277d76-e3f3-44d9-a77f-c905756efc9d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10645.3480341268, - "y": 6362.430224855489 - }, - "endPoint": { - "x": 10677.630816466524, - "y": 6367.47424635975 - } - } - }, - { - "id": "cdd70da2-090d-4f93-834e-ede9f0b6ffd5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10677.630816466524, - "y": 6367.47424635975 - }, - "endPoint": { - "x": 10709.976194144983, - "y": 6371.858993969276 - } - } - }, - { - "id": "dfb27082-fe2f-4fba-9eba-844d8e6cc2de", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10709.976194144983, - "y": 6371.858993969276 - }, - "endPoint": { - "x": 10742.38730083208, - "y": 6375.549229883705 - } - } - }, - { - "id": "d3b91b07-6cb9-49c5-afa7-00cb48dd353a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10742.38730083208, - "y": 6375.549229883705 - }, - "endPoint": { - "x": 10774.867270197603, - "y": 6378.509716302389 - } - } - }, - { - "id": "32f8adc2-a513-43da-98bb-28e07fd1d5e2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10774.867270197603, - "y": 6378.509716302389 - }, - "endPoint": { - "x": 10807.419235911162, - "y": 6380.705215424619 - } - } - }, - { - "id": "256b2f8c-dd7a-4ad3-a660-ed28be2a40de", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10807.419235911162, - "y": 6380.705215424619 - }, - "endPoint": { - "x": 10840.04633164272, - "y": 6382.100489449978 - } - } - }, - { - "id": "abbf9d59-1a6b-40e0-9341-ef3f7b7da2f3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10840.04633164272, - "y": 6382.100489449978 - }, - "endPoint": { - "x": 10885.022622064804, - "y": 6382.6958551862335 - } - } - }, - { - "id": "e9968625-270a-4013-a38b-0b79d2dff7b2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10885.022622064804, - "y": 6382.6958551862335 - }, - "endPoint": { - "x": 10930.128326653969, - "y": 6381.909727944847 - } - } - }, - { - "id": "ae7dfb70-aeef-4fae-86af-93e51d7b6018", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10930.128326653969, - "y": 6381.909727944847 - }, - "endPoint": { - "x": 10975.344957671885, - "y": 6379.939463865361 - } - } - }, - { - "id": "a2cfd6f0-1584-445d-9d46-dbade8c184ab", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 10975.344957671885, - "y": 6379.939463865361 - }, - "endPoint": { - "x": 11020.654027380631, - "y": 6376.98241908761 - } - } - }, - { - "id": "05ca586d-f782-4915-a21a-1235126b6150", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11020.654027380631, - "y": 6376.98241908761 - }, - "endPoint": { - "x": 11066.037048041937, - "y": 6373.235949751164 - } - } - }, - { - "id": "30c264ec-b4ad-4cd2-809e-6e16481b29ad", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11066.037048041937, - "y": 6373.235949751164 - }, - "endPoint": { - "x": 11111.475531917647, - "y": 6368.897411995684 - } - } - }, - { - "id": "004c8a30-b84a-4769-b323-e52da1049013", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 11111.475531917647, - "y": 6368.897411995684 - }, - "endPoint": { - "x": 11202.44493835984, - "y": 6359.233555786399 - } - } - } - ] - }, - { - "id": "c7cbf289-395d-423d-9ae5-4af01b6b6c38", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "139fc5ed-de83-4fe2-a439-c59c46821de0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520356, - "y": 5157.30830081171 - }, - "endPoint": { - "x": 6728.038622369175, - "y": 5147.741626025032 - } - } - }, - { - "id": "5d0b88be-85e8-4480-8da3-b1e5620d8da8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6728.038622369175, - "y": 5147.741626025032 - }, - "endPoint": { - "x": 6670.836869562278, - "y": 5143.2973554337805 - } - } - }, - { - "id": "8bd9d7db-3461-48e8-9293-dbfa5a8bb137", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6670.836869562278, - "y": 5143.2973554337805 - }, - "endPoint": { - "x": 6613.633219278185, - "y": 5139.259965005243 - } - } - }, - { - "id": "9746a22f-211b-4779-8e5c-45f03600f875", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6613.633219278185, - "y": 5139.259965005243 - }, - "endPoint": { - "x": 6556.427039024071, - "y": 5135.765081459947 - } - } - }, - { - "id": "3d5b22d8-88ec-4484-8701-1ecac2752336", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6556.427039024071, - "y": 5135.765081459947 - }, - "endPoint": { - "x": 6499.217696307576, - "y": 5132.948331519088 - } - } - }, - { - "id": "4a73405a-f38e-4310-82a5-e9d691c0550e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6499.217696307576, - "y": 5132.948331519088 - }, - "endPoint": { - "x": 6442.004558636225, - "y": 5130.945341903309 - } - } - }, - { - "id": "1e96e9df-b385-4f56-ba41-39ac9add0f4e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6442.004558636225, - "y": 5130.945341903309 - }, - "endPoint": { - "x": 6384.786993517657, - "y": 5129.891739333543 - } - } - }, - { - "id": "aae51d4e-a672-4025-9fb0-fb4726f4c3c7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6384.786993517657, - "y": 5129.891739333543 - }, - "endPoint": { - "x": 6317.00901858724, - "y": 5129.988985007134 - } - } - }, - { - "id": "08e20bea-6c65-408f-8bce-4b6ac1e91316", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6317.00901858724, - "y": 5129.988985007134 - }, - "endPoint": { - "x": 6249.2248322292, - "y": 5131.418160485249 - } - } - }, - { - "id": "6c6bf01f-a1f7-4e86-aad1-c83a3f112f91", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6249.2248322292, - "y": 5131.418160485249 - }, - "endPoint": { - "x": 6181.435321790283, - "y": 5133.988990081474 - } - } - }, - { - "id": "f88da8ad-9490-48f4-8bcb-bbc2915b9458", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6181.435321790283, - "y": 5133.988990081474 - }, - "endPoint": { - "x": 6113.641374617233, - "y": 5137.511198109423 - } - } - }, - { - "id": "37834561-2ea7-4c64-8cd9-56ec4b26cd0d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6113.641374617233, - "y": 5137.511198109423 - }, - "endPoint": { - "x": 6045.843878056854, - "y": 5141.794508882769 - } - } - }, - { - "id": "d86164fc-4502-4a8b-8e2e-452d4554ab13", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6045.843878056854, - "y": 5141.794508882769 - }, - "endPoint": { - "x": 5978.043719456065, - "y": 5146.648646715155 - } - } - }, - { - "id": "68fd336d-ce1d-4cbd-8474-bf38f285e361", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5978.043719456065, - "y": 5146.648646715155 - }, - "endPoint": { - "x": 5842.438965520298, - "y": 5157.308300811681 - } - } - } - ] - }, - { - "id": "f7d82ee3-2712-442e-9f71-4ac58e9756f9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.438965520298, - "y": 5557.308300811681 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 5293.957258093491 - } - } - }, - { - "id": "37026bd2-b4a5-4bac-95a7-8b2e30bc8fbf", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4842.438965520298, - "y": 5557.308300811681 - }, - "endPoint": { - "x": 4842.438965520298, - "y": 5445.9341573001875 - } - } - }, - { - "id": "a096e1c7-5b92-4049-a18a-f8483382f4ee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4842.438965520298, - "y": 5757.308300811681 - }, - "endPoint": { - "x": 4842.438965520298, - "y": 5557.308300811681 - } - } - }, - { - "id": "7ded1276-3c88-4355-aa68-bfa857a42485", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.438965520298, - "y": 5605.331401604984 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 5405.331401604984 - } - } - }, - { - "id": "d35d6df0-44a1-4493-8afa-3103924436de", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7842.438965520298, - "y": 5557.308300811681 - }, - "endPoint": { - "x": 7842.438965520298, - "y": 5445.9341573001875 - } - } - }, - { - "id": "6e832d06-e25e-4c00-a2db-6e095eec0f75", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7842.438965520298, - "y": 5757.308300811681 - }, - "endPoint": { - "x": 7842.438965520298, - "y": 5557.308300811681 - } - } - }, - { - "id": "e08f3508-b28d-4924-864b-687f6dad6438", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7342.438965520298, - "y": 5605.331401604984 - }, - "endPoint": { - "x": 7342.438965520298, - "y": 5405.331401604984 - } - } - }, - { - "id": "8989c81d-17a5-4b13-af3f-a3a5436b37ac", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7342.438965520298, - "y": 5557.308300811681 - }, - "endPoint": { - "x": 7342.438965520298, - "y": 5293.957258093491 - } - } - }, - { - "id": "31cff061-3223-4335-a080-21a2552e8934", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3474.241326984018, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 4092.4389655202976, - "y": 8754.00813196419 - } - } - }, - { - "id": "5754d987-11bd-4fc3-9e5e-35db32a4dba0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "6804a708-86f9-47a7-9dbe-d6cacfac5380", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 5842.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "3e2e2226-be09-412c-838e-f6cc5d21ff3d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 6842.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "f0bfe80e-4ae9-4a85-956a-29492c2d0f14", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7342.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 7342.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "d3a8a579-8691-406d-a82c-c4ee5bc6effe", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4092.4389655202976, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 4092.4389655202976, - "y": 8954.00813196419 - } - } - }, - { - "id": "9c0e8667-c34a-4796-a36e-a41da1697cec", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 8592.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "537e46d0-7ef5-46d2-bb79-503cec1ebd67", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 8592.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "e0d0de1b-07f5-48e8-809e-72bda27b41a8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 8954.00813196419 - }, - "endPoint": { - "x": 6842.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "1bd4395b-b46b-4749-a3e1-248b98aa453e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 9533.763931827678 - }, - "endPoint": { - "x": 3474.241326984018, - "y": 9533.763931827678 - } - } - }, - { - "id": "17a9089b-a2a7-4cc2-9010-7177a5797025", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 9533.763931827678 - }, - "endPoint": { - "x": 9700.645622687414, - "y": 9533.763931827678 - } - } - }, - { - "id": "0ac6e825-a0eb-4752-89c5-2b38081e98f8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 8954.00813196419 - }, - "endPoint": { - "x": 4092.4389655202976, - "y": 8954.00813196419 - } - } - }, - { - "id": "87e976b9-5227-46ac-933d-53d5967a7ed3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5088.536087974906, - "y": 5679.5696338159905 - }, - "endPoint": { - "x": 5088.536087974906, - "y": 5479.5696338159905 - } - } - }, - { - "id": "4678d072-c6d8-40c2-8a40-46319156f876", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5088.536087974906, - "y": 5631.546533022687 - }, - "endPoint": { - "x": 5088.536087974906, - "y": 5368.195490304468 - } - } - }, - { - "id": "cc288b9b-d076-4843-821c-af8fa8f9bf74", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5542.316786611744, - "y": 5549.748401477991 - }, - "endPoint": { - "x": 5542.316786611744, - "y": 5349.748401477991 - } - } - }, - { - "id": "aa3b0387-f24a-4d69-9e52-64c5f0d6144b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5542.316786611744, - "y": 5501.725300684688 - }, - "endPoint": { - "x": 5542.316786611744, - "y": 5238.374257966469 - } - } - }, - { - "id": "2cbb136e-f2ca-4b84-8842-e637e8541ba8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4612.845350768417, - "y": 5836.268278345466 - }, - "endPoint": { - "x": 4612.845350768417, - "y": 5636.268278345466 - } - } - }, - { - "id": "7e20c6ca-d38e-4785-8ce3-febb239dba51", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4612.845350768417, - "y": 5788.245177552191 - }, - "endPoint": { - "x": 4612.845350768417, - "y": 5524.894134833972 - } - } - }, - { - "id": "5dacca6e-bed8-412f-9644-cdbd320c6cc0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7114.0738457448315, - "y": 5541.965920789691 - }, - "endPoint": { - "x": 7114.0738457448315, - "y": 5341.965920789691 - } - } - }, - { - "id": "353f28eb-5706-471b-bb1f-5aa0b614a66a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7114.0738457448315, - "y": 5493.942819996388 - }, - "endPoint": { - "x": 7114.0738457448315, - "y": 5230.591777278285 - } - } - }, - { - "id": "2d43e87e-14da-48d0-99e0-83e6fae65e2e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7588.367422565352, - "y": 5677.157292607473 - }, - "endPoint": { - "x": 7588.367422565352, - "y": 5477.157292607473 - } - } - }, - { - "id": "f03c078b-ea26-4106-8730-41c92affaf9a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7588.367422565352, - "y": 5629.13419181417 - }, - "endPoint": { - "x": 7588.367422565352, - "y": 5365.783149096067 - } - } - }, - { - "id": "db35f7c0-608f-466c-993e-c0b100f588e9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8110.076243293239, - "y": 5849.890115191374 - }, - "endPoint": { - "x": 8110.076243293239, - "y": 5649.890115191374 - } - } - }, - { - "id": "4dcfacee-d534-4cbd-9a98-e3afdd10e4de", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8110.076243293239, - "y": 5801.867014398071 - }, - "endPoint": { - "x": 8110.076243293239, - "y": 5538.515971679881 - } - } - }, - { - "id": "c113c6e1-57e4-4027-b272-f7a26c8e63bd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 1636.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 1636.3660789713904 - } - } - }, - { - "id": "db0cd893-fc4b-400f-b2e3-a5ddcdedff7b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 1886.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 1886.3660789713904 - } - } - }, - { - "id": "9c3b9461-c4f6-4108-a986-159d36a792ac", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 2136.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 2136.3660789713904 - } - } - }, - { - "id": "d3dac5a3-526d-4342-844d-8f770b71567b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 2386.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 2386.3660789713904 - } - } - }, - { - "id": "fb7dbf5a-3782-425c-8a69-37f0b5036d7c", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "공 종", - "basePoint": { - "x": 5117.667635826743, - "y": 2474.317084736278 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "c3d2156c-e003-4901-b34b-5881ce42f16d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6495.3265131632215, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 6495.3265131632215, - "y": 1636.3660789713904 - } - } - }, - { - "id": "9446eccf-2b17-4cb6-9e62-37dd160365a4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5838.931579108001, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 5838.931579108001, - "y": 1636.3660789713904 - } - } - }, - { - "id": "faf76293-9a78-4fe9-82bf-dcde54937e65", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "거 푸 집", - "basePoint": { - "x": 5026.788807597652, - "y": 1723.1852605018648 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "a1900dab-4160-4dc8-876f-d0b32dbb82ab", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "철 근", - "basePoint": { - "x": 5049.558448077005, - "y": 1972.4529036443855 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "08a6795a-deaa-47b7-af70-af636a1fd2d1", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "콘크리트", - "basePoint": { - "x": 5004.019167118298, - "y": 2223.185260501865 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "c6733f8d-c384-44ac-98cf-ef86bd1fcc72", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8406.580789551721, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 1636.3660789713904 - } - } - }, - { - "id": "811b0cc2-5b4c-40de-87a3-19ec864842e9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 7006.580789551721, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 7006.580789551721, - "y": 1636.3660789713904 - } - } - }, - { - "id": "c37d8952-9ccf-4b53-bafc-66f5de74d6e7", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "재 료 표", - "basePoint": { - "x": 6171.278157050139, - "y": 2926.246386233892 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 162.5, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "eeaa718a-a156-4116-9d08-f620228da1b1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 8406.580789551721, - "y": 2636.3660789713904 - } - } - }, - { - "id": "9c94ab33-5042-4366-876f-21a739ebecd3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4724.560845806904, - "y": 2636.3660789713904 - }, - "endPoint": { - "x": 4724.560845806904, - "y": 1636.3660789713904 - } - } - }, - { - "id": "e4cd4d28-be79-47f9-8bab-5f3fff015bf9", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "수 량", - "basePoint": { - "x": 6008.584931695135, - "y": 2480.5939133914653 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "47f1ad4b-dca7-4ffe-a428-c03f610578c9", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "0.32", - "basePoint": { - "x": 6027.426476302324, - "y": 2230.5939133914653 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "953ba152-2cab-44ab-89c1-11701a8a6247", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "7.800", - "basePoint": { - "x": 5988.27867337293, - "y": 1980.6604912875919 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "4bca5198-fdaf-4c0f-bcf0-199307fa03b2", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "3.20", - "basePoint": { - "x": 6027.426476302324, - "y": 1730.5939133914653 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "edf98049-5526-4d8d-8136-1422ad80ed45", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "단 위", - "basePoint": { - "x": 6605.460551981407, - "y": 2473.185260501865 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "8b3cd74d-e7f2-4436-9c14-88bcafcef44b", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "m2", - "basePoint": { - "x": 6666.645638532704, - "y": 1723.1852605018648 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "31e05f58-1de2-46fe-9b3a-010b446f4910", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "m3", - "basePoint": { - "x": 6666.645638532704, - "y": 2223.185260501865 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "97bda905-de40-4054-81a3-77809aaa9dcd", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "kg", - "basePoint": { - "x": 6689.148967427551, - "y": 1981.5740754153812 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "0ff01c50-2823-46ef-9932-24588d0aa138", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "비 고", - "basePoint": { - "x": 7508.109824139217, - "y": 2473.185260501865 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "31dc275b-5667-4a4b-b2ff-bcc4acce74e2", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "D16mm", - "basePoint": { - "x": 7422.224338120606, - "y": 1973.1852605018648 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "fb2fd154-0baf-48ba-a1e8-2d6a4f045a41", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "유로폼", - "basePoint": { - "x": 7462.637121076637, - "y": 1723.9176173593733 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "cbd29065-2971-4e04-858a-24aeb34a5248", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "(개소당)", - "basePoint": { - "x": 7860.603470677044, - "y": 2730.8090505170694 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "aff62f9c-5c4a-46ee-a627-6f1a8e614977", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "물 넘 이", - "basePoint": { - "x": 5856.929112473503, - "y": 10268.319695265178 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 162.5, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "dfea6352-a41c-4c97-bd6c-54929d9cdf6b", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "평 면 도", - "basePoint": { - "x": 6087.681379433197, - "y": 8235.512656280887 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 120, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "7c5ba9c5-44eb-4532-973b-9db7a4a56aa5", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "노 면", - "basePoint": { - "x": 6150.8795131608495, - "y": 9116.345420375466 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 120, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "26f9fa1f-8565-46d9-888d-16c5cc29ce9e", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "(A)집수부", - "basePoint": { - "x": 7722.742018343939, - "y": 5011.347616029292 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 120, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "3aecbf4e-6c28-433f-963e-2f425861ec9c", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "(A)", - "basePoint": { - "x": 7371.320427476312, - "y": 5726.645132854988 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "250e9c87-1ac2-4597-a53f-5ae473890d3a", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "(A)", - "basePoint": { - "x": 5179.4078267693985, - "y": 5690.370184635685 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 100, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "cc3c80df-e353-4833-a3e2-a3cead4a49fc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8592.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 9700.645622687414, - "y": 8754.00813196419 - } - } - }, - { - "id": "bb87b27b-6253-4277-b3d1-e0376cf7c18c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6842.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 8592.438965520298, - "y": 8754.00813196419 - } - } - }, - { - "id": "dc3a5ec6-9a89-4412-b0ce-1d4ff1fcbd49", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 6842.438965520298, - "y": 8754.00813196419 - } - } - }, - { - "id": "6fba818d-5fd9-4d05-ae96-efe80e31f9ae", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4092.4389655202976, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 5842.438965520298, - "y": 8754.00813196419 - } - } - }, - { - "id": "1e52041c-c8ba-4345-8f82-b9cb2ce19e02", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8662.438965520298, - "y": 8754.00813196419 - }, - "endPoint": { - "x": 8876.726452197647, - "y": 8754.00813196419 - } - } - }, - { - "id": "f02c50f5-b68d-475c-83d7-17e542ff0969", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8662.438965520298, - "y": 8954.00813196419 - }, - "endPoint": { - "x": 8876.726452197647, - "y": 8954.00813196419 - } - } - }, - { - "id": "8a6dd846-6c3f-4f49-abaf-c3865cffc1ce", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8846.726452197647, - "y": 8774.00813196419 - }, - "endPoint": { - "x": 8846.726452197647, - "y": 8934.00813196419 - } - } - }, - { - "id": "1e6ec113-0623-4058-879a-19551642653d", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "200", - "basePoint": { - "x": 8806.274324538012, - "y": 8854.008131964278 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "2feb2105-f883-4be5-8f30-be39c0eac0ad", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8592.438965520298, - "y": 8754.00813196419 - } - } - }, - { - "id": "52fad309-37ba-41a0-a260-a7f5dea5fe79", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8592.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "91df594b-5a99-4797-aff0-f3201d40f6f2", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8846.726452197647, - "y": 8954.00813196419 - } - } - }, - { - "id": "1d3282bc-b0c7-4751-9ac8-8763835aaf09", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8662.438965520298, - "y": 8954.00813196419 - }, - "endPoint": { - "x": 8876.726452197647, - "y": 8954.00813196419 - } - } - }, - { - "id": "8f0e2d53-cd33-4437-835b-ab7754460eda", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8662.438965520298, - "y": 9533.763931827678 - }, - "endPoint": { - "x": 8876.726452197647, - "y": 9533.763931827678 - } - } - }, - { - "id": "7db482de-3692-41cc-83c2-1f9b0cd0e56c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 8846.726452197647, - "y": 8974.00813196419 - }, - "endPoint": { - "x": 8846.726452197647, - "y": 9513.763931827678 - } - } - }, - { - "id": "b6e9fa33-ca65-40dd-8893-1b1ca1b51b86", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "3,800", - "basePoint": { - "x": 8803.774324538012, - "y": 9243.886031895992 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "ba7667f3-cf46-4974-8bd7-021abf199950", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8592.438965520298, - "y": 8954.00813196419 - } - } - }, - { - "id": "ec04ba9f-46dc-48d2-8dfd-380378c3a992", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8592.438965520298, - "y": 9533.763931827678 - } - } - }, - { - "id": "71d4559b-a2c5-43b8-ae15-827e856a5e58", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 8846.726452197647, - "y": 9533.763931827678 - } - } - }, - { - "id": "3d99c44b-ed81-41c6-9e64-2d5d4ccfa399", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4255.194526991341, - "y": 5927.308300811681 - }, - "endPoint": { - "x": 4255.194526991341, - "y": 6110.138199321285 - } - } - }, - { - "id": "b78474b5-347c-489c-96ed-1a4c0679ae2c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.438965520298, - "y": 5927.308300811681 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 6110.138199321285 - } - } - }, - { - "id": "dcc90945-d1fd-4708-aad3-b789f3791206", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4275.194526991341, - "y": 6080.138199321285 - }, - "endPoint": { - "x": 5322.438965520298, - "y": 6080.138199321285 - } - } - }, - { - "id": "f239a321-23a6-4657-8449-41859495e0fa", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "4,000", - "basePoint": { - "x": 4798.816746255849, - "y": 6123.116922725574 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "ffa9c182-dbb6-4c49-82eb-e0d0e4c43a43", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 4255.194526991341, - "y": 5857.308300811681 - } - } - }, - { - "id": "2b7e524e-2d05-42fb-9881-ac74c5ad3076", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "da769cb9-c006-44b4-99b9-f2049c2a173f", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5342.438965520298, - "y": 6080.138199321285 - } - } - }, - { - "id": "7540006c-0d87-4ebe-9377-fd5be0360b6e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5342.438965520298, - "y": 5927.308300811681 - }, - "endPoint": { - "x": 5342.438965520298, - "y": 6110.138199321285 - } - } - }, - { - "id": "d7f3506b-3e50-4724-a53d-e0cef95220ce", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5842.438965520298, - "y": 5927.308300811681 - }, - "endPoint": { - "x": 5842.438965520298, - "y": 6110.138199321285 - } - } - }, - { - "id": "1468ccda-2232-432f-8c29-1bc592d62ccc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5362.438965520298, - "y": 6080.138199321285 - }, - "endPoint": { - "x": 5822.438965520298, - "y": 6080.138199321285 - } - } - }, - { - "id": "c948238f-642a-42c7-821b-583ef6d0889e", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "400", - "basePoint": { - "x": 5592.438965520298, - "y": 6120.6435184701695 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "3104c15f-b432-4109-ae2d-6b5beae0a061", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5342.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "5b4c4077-644d-479b-b4be-f21b5a80071c", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5842.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "4b06a9a4-263b-4944-a96f-6d5a3f103a28", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5842.438965520298, - "y": 6080.138199321285 - } - } - }, - { - "id": "fb9e1666-5097-4472-93ba-ffd66dd79e88", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5912.438965520298, - "y": 5357.308300811681 - }, - "endPoint": { - "x": 6100.861424783943, - "y": 5357.308300811681 - } - } - }, - { - "id": "80ffa0f2-26ee-4903-a840-0058aa703da0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5912.438965520298, - "y": 5857.308300811681 - }, - "endPoint": { - "x": 6100.861424783943, - "y": 5857.308300811681 - } - } - }, - { - "id": "c0f05f9c-b686-4a40-aed2-87b84d82105e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 6070.861424783943, - "y": 5377.308300811681 - }, - "endPoint": { - "x": 6070.861424783943, - "y": 5837.308300811681 - } - } - }, - { - "id": "a2a5d699-ab49-45b0-8485-67fbb2016d91", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "400", - "basePoint": { - "x": 6030.356105634943, - "y": 5607.308300811768 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "46fe4c4c-06b7-4df0-b5bc-4995292f805b", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5842.438965520298, - "y": 5357.308300811681 - } - } - }, - { - "id": "7bc4a312-d87c-4a87-8618-8fc6dc3620bd", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 5842.438965520298, - "y": 5857.308300811681 - } - } - }, - { - "id": "d77bde7f-1bf7-453e-aacb-79b2e8e9f333", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 6070.861424783943, - "y": 5857.308300811681 - } - } - }, - { - "id": "743941df-fe64-49dc-82ec-d113debc1be6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9252.367720227514, - "y": 5938.54050465257 - }, - "endPoint": { - "x": 9402.127321101609, - "y": 5938.54050465257 - } - } - }, - { - "id": "bfc341d0-b718-4ea3-b500-3146494bdd99", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9252.367720227514, - "y": 6138.54050465257 - }, - "endPoint": { - "x": 9402.127321101609, - "y": 6138.54050465257 - } - } - }, - { - "id": "92426a75-2e7b-4ea3-9125-15a1ddc2ca32", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 9372.127321101609, - "y": 5958.54050465257 - }, - "endPoint": { - "x": 9372.127321101609, - "y": 6118.54050465257 - } - } - }, - { - "id": "619a7ed5-4b4d-4bbc-8f90-0495bc6e5c21", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "200", - "basePoint": { - "x": 9331.675193442032, - "y": 6038.54050465257 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 40, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "741f5bda-9fa0-4fcf-b63d-f28c221094af", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 9182.367720227514, - "y": 5938.54050465257 - } - } - }, - { - "id": "3df283d1-a669-41c3-9450-fe0a2e38e831", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 9182.367720227514, - "y": 6138.54050465257 - } - } - }, - { - "id": "e438ddda-61fa-42b5-a45b-0eff0f9650da", - "type": "Point", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "point": { - "x": 9372.127321101609, - "y": 6138.54050465257 - } - } - }, - { - "id": "373c4819-617e-40da-a16a-576c82fb42da", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "07719b10-be3a-42db-ad88-e78f4dab0e9a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5718.21419414843, - "y": 8174.614427795983 - }, - "endPoint": { - "x": 5717.41927127511, - "y": 8188.262726441695 - } - } - }, - { - "id": "9db0253c-4cc1-47b6-939a-060a807f5cee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5717.41927127511, - "y": 8188.262726441695 - }, - "endPoint": { - "x": 5715.045252623153, - "y": 8201.72645526164 - } - } - }, - { - "id": "4349ede0-1b24-431a-bb61-94cbdba0fb87", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5715.045252623153, - "y": 8201.72645526164 - }, - "endPoint": { - "x": 5711.124242722406, - "y": 8214.823540420359 - } - } - }, - { - "id": "2a9feb97-bee0-4ce0-a3a7-387c738e9eee", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5711.124242722406, - "y": 8214.823540420359 - }, - "endPoint": { - "x": 5705.709266505262, - "y": 8227.376866308943 - } - } - }, - { - "id": "21d42064-5a46-4454-b01b-35ffe01c4f39", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5705.709266505262, - "y": 8227.376866308943 - }, - "endPoint": { - "x": 5698.873552235775, - "y": 8239.216670729744 - } - } - }, - { - "id": "f00623d8-1c90-4b54-b9b2-f75961522cf8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5698.873552235775, - "y": 8239.216670729744 - }, - "endPoint": { - "x": 5690.709541222837, - "y": 8250.182840638969 - } - } - }, - { - "id": "17f1363b-8022-4e6b-bda6-0a8a8894535d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5690.709541222837, - "y": 8250.182840638969 - }, - "endPoint": { - "x": 5681.327637710259, - "y": 8260.127077400888 - } - } - }, - { - "id": "6e4257b4-7f8d-424a-9a5e-718e9e39e532", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5681.327637710259, - "y": 8260.127077400888 - }, - "endPoint": { - "x": 5670.854715848458, - "y": 8268.91490227246 - } - } - }, - { - "id": "a4422251-c77d-423f-9c34-d765499b6110", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5670.854715848458, - "y": 8268.91490227246 - }, - "endPoint": { - "x": 5659.432403939543, - "y": 8276.427474997705 - } - } - }, - { - "id": "89e37ed9-3857-4662-aafb-be90f40b919d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5659.432403939543, - "y": 8276.427474997705 - }, - "endPoint": { - "x": 5647.215169157251, - "y": 8282.563200918172 - } - } - }, - { - "id": "ad43d390-addf-44fd-9c14-d41fa5f98825", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5647.215169157251, - "y": 8282.563200918172 - }, - "endPoint": { - "x": 5634.3682286435505, - "y": 8287.239104866545 - } - } - }, - { - "id": "2249d846-9bf8-4b6a-a35e-2abbfbd55c1b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5634.3682286435505, - "y": 8287.239104866545 - }, - "endPoint": { - "x": 5621.0653152301675, - "y": 8290.391953263344 - } - } - }, - { - "id": "f96989a9-09ee-4f1b-bc02-5bf921cfcad3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5621.0653152301675, - "y": 8290.391953263344 - }, - "endPoint": { - "x": 5607.486328000086, - "y": 8291.979109242704 - } - } - }, - { - "id": "0dae4487-9eb9-4e01-8023-3bce0458f6e9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5607.486328000086, - "y": 8291.979109242704 - }, - "endPoint": { - "x": 5593.814899461111, - "y": 8291.979109242704 - } - } - }, - { - "id": "11d03889-3a31-487b-9dc5-10c2d941dc24", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5593.814899461111, - "y": 8291.979109242704 - }, - "endPoint": { - "x": 5580.23591223103, - "y": 8290.391953263344 - } - } - }, - { - "id": "125dcece-9102-4e17-bad2-dbda66beaf3b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5580.23591223103, - "y": 8290.391953263344 - }, - "endPoint": { - "x": 5566.932998817647, - "y": 8287.239104866545 - } - } - }, - { - "id": "59bfef36-cc47-47a7-be2c-9125d8161470", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5566.932998817647, - "y": 8287.239104866545 - }, - "endPoint": { - "x": 5554.086058303947, - "y": 8282.563200918172 - } - } - }, - { - "id": "e9b248d8-3498-493c-a94a-81e14120d57e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5554.086058303947, - "y": 8282.563200918172 - }, - "endPoint": { - "x": 5541.868823521654, - "y": 8276.427474997705 - } - } - }, - { - "id": "cbc9d45d-6015-42a2-af2d-7fcda9c1565d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5541.868823521654, - "y": 8276.427474997705 - }, - "endPoint": { - "x": 5530.446511612739, - "y": 8268.91490227246 - } - } - }, - { - "id": "b116c195-ab71-4416-a107-3a67c367c164", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5530.446511612739, - "y": 8268.91490227246 - }, - "endPoint": { - "x": 5519.973589750938, - "y": 8260.127077400888 - } - } - }, - { - "id": "a983e606-f5aa-4970-9ac8-b25aa7df43ea", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5519.973589750938, - "y": 8260.127077400888 - }, - "endPoint": { - "x": 5510.59168623836, - "y": 8250.182840638969 - } - } - }, - { - "id": "f3b6099b-1f3d-4206-8988-bebeb3122acd", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5510.59168623836, - "y": 8250.182840638969 - }, - "endPoint": { - "x": 5502.427675225423, - "y": 8239.216670729744 - } - } - }, - { - "id": "d9d17ce6-f122-446c-ac72-8f91b12cc19a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5502.427675225423, - "y": 8239.216670729744 - }, - "endPoint": { - "x": 5495.591960955935, - "y": 8227.376866308943 - } - } - }, - { - "id": "f44116f6-50ee-455a-ba3e-3a8e83952ef1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5495.591960955935, - "y": 8227.376866308943 - }, - "endPoint": { - "x": 5490.176984738791, - "y": 8214.823540420359 - } - } - }, - { - "id": "22732ae6-0143-4f9a-b1d1-46515f2df501", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5490.176984738791, - "y": 8214.823540420359 - }, - "endPoint": { - "x": 5486.2559748380445, - "y": 8201.72645526164 - } - } - }, - { - "id": "be97c265-7f3f-4a6c-91b3-98221e92ccc4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5486.2559748380445, - "y": 8201.72645526164 - }, - "endPoint": { - "x": 5483.881956186087, - "y": 8188.262726441695 - } - } - }, - { - "id": "f9646599-2ba3-4ea9-8e0a-7337001898e4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.881956186087, - "y": 8188.262726441695 - }, - "endPoint": { - "x": 5483.087033312768, - "y": 8174.614427795983 - } - } - }, - { - "id": "293cec21-af0d-4604-b905-3ff9d772248a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.087033312768, - "y": 8174.614427795983 - }, - "endPoint": { - "x": 5483.881956186087, - "y": 8160.966129150271 - } - } - }, - { - "id": "48e0907e-660a-47dd-9f9a-ed7efdcefd3c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.881956186087, - "y": 8160.966129150271 - }, - "endPoint": { - "x": 5486.2559748380445, - "y": 8147.502400330326 - } - } - }, - { - "id": "b65058dd-b032-4675-a61b-8dac6d6bc4c0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5486.2559748380445, - "y": 8147.502400330326 - }, - "endPoint": { - "x": 5490.176984738791, - "y": 8134.405315171607 - } - } - }, - { - "id": "c507d0ef-0ac5-4ccb-8748-e59f5d1ba204", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5490.176984738791, - "y": 8134.405315171607 - }, - "endPoint": { - "x": 5495.591960955935, - "y": 8121.851989283023 - } - } - }, - { - "id": "d40a0a52-46a6-4bdc-8028-9cbfcb1dbc16", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5495.591960955935, - "y": 8121.851989283023 - }, - "endPoint": { - "x": 5502.427675225423, - "y": 8110.012184862222 - } - } - }, - { - "id": "05afe05e-b9dc-438a-9365-00498dfb507a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5502.427675225423, - "y": 8110.012184862222 - }, - "endPoint": { - "x": 5510.59168623836, - "y": 8099.046014952997 - } - } - }, - { - "id": "c1580259-ddaa-4a60-83fc-c141bc9d3aa8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5510.59168623836, - "y": 8099.046014952997 - }, - "endPoint": { - "x": 5519.973589750938, - "y": 8089.101778191078 - } - } - }, - { - "id": "6030fbc9-e72e-4e2a-85b4-f79816944c15", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5519.973589750938, - "y": 8089.101778191078 - }, - "endPoint": { - "x": 5530.446511612739, - "y": 8080.313953319506 - } - } - }, - { - "id": "bb6a97de-9733-4395-9557-93b33ff74986", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5530.446511612739, - "y": 8080.313953319506 - }, - "endPoint": { - "x": 5541.868823521654, - "y": 8072.801380594261 - } - } - }, - { - "id": "a15744df-082b-4584-9de1-db2b34718dea", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5541.868823521654, - "y": 8072.801380594261 - }, - "endPoint": { - "x": 5554.086058303947, - "y": 8066.665654673794 - } - } - }, - { - "id": "b9c821f8-e24b-4588-8fe8-a162241fdfdf", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5554.086058303947, - "y": 8066.665654673794 - }, - "endPoint": { - "x": 5566.932998817647, - "y": 8061.989750725421 - } - } - }, - { - "id": "c39b3026-f95d-4da0-92d8-958f0b318ba1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5566.932998817647, - "y": 8061.989750725421 - }, - "endPoint": { - "x": 5580.23591223103, - "y": 8058.836902328621 - } - } - }, - { - "id": "988732f5-66d2-41d1-9112-e90a64405878", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5580.23591223103, - "y": 8058.836902328621 - }, - "endPoint": { - "x": 5593.814899461111, - "y": 8057.249746349262 - } - } - }, - { - "id": "9fe422af-be4f-4ffb-96ed-a9b11e7b570b", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5593.814899461111, - "y": 8057.249746349262 - }, - "endPoint": { - "x": 5607.486328000086, - "y": 8057.249746349262 - } - } - }, - { - "id": "eafcb8fb-5c6f-4e51-9a96-3af120a5c45c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5607.486328000086, - "y": 8057.249746349262 - }, - "endPoint": { - "x": 5621.0653152301675, - "y": 8058.836902328621 - } - } - }, - { - "id": "e82cd8c4-8c42-4fe5-a924-4c20c9083215", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5621.0653152301675, - "y": 8058.836902328621 - }, - "endPoint": { - "x": 5634.3682286435505, - "y": 8061.989750725421 - } - } - }, - { - "id": "04b63c66-d5e0-47bd-be79-f89b27cfd473", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5634.3682286435505, - "y": 8061.989750725421 - }, - "endPoint": { - "x": 5647.215169157251, - "y": 8066.665654673794 - } - } - }, - { - "id": "3a2d09d8-0461-49f2-b169-1604cfc961c8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5647.215169157251, - "y": 8066.665654673794 - }, - "endPoint": { - "x": 5659.432403939543, - "y": 8072.801380594261 - } - } - }, - { - "id": "9c4be3bc-54b9-4751-9b0f-1f334eba27b6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5659.432403939543, - "y": 8072.801380594261 - }, - "endPoint": { - "x": 5670.854715848458, - "y": 8080.313953319506 - } - } - }, - { - "id": "f85acb82-00e9-4129-8b70-87801cd33ed4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5670.854715848458, - "y": 8080.313953319506 - }, - "endPoint": { - "x": 5681.327637710259, - "y": 8089.101778191078 - } - } - }, - { - "id": "1b60ea3b-5193-4f86-9303-55289cb2b664", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5681.327637710259, - "y": 8089.101778191078 - }, - "endPoint": { - "x": 5690.709541222837, - "y": 8099.046014952997 - } - } - }, - { - "id": "1c699a96-6283-4430-88f4-8ac4a3d46b36", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5690.709541222837, - "y": 8099.046014952997 - }, - "endPoint": { - "x": 5698.873552235775, - "y": 8110.012184862222 - } - } - }, - { - "id": "37679986-fcb3-4ec7-b75e-82bd302480c1", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5698.873552235775, - "y": 8110.012184862222 - }, - "endPoint": { - "x": 5705.709266505262, - "y": 8121.851989283023 - } - } - }, - { - "id": "8f4c3f20-4837-46e7-8725-b2747e5b30e6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5705.709266505262, - "y": 8121.851989283023 - }, - "endPoint": { - "x": 5711.124242722406, - "y": 8134.405315171607 - } - } - }, - { - "id": "60891be9-d076-4120-97fa-ff7d61046449", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5711.124242722406, - "y": 8134.405315171607 - }, - "endPoint": { - "x": 5715.045252623153, - "y": 8147.502400330326 - } - } - }, - { - "id": "b3dc1aa5-ff81-4b0b-9746-9daef1f030a9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5715.045252623153, - "y": 8147.502400330326 - }, - "endPoint": { - "x": 5717.41927127511, - "y": 8160.966129150271 - } - } - }, - { - "id": "7c14d3bd-cefe-4136-9e62-606d9da12c58", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5717.41927127511, - "y": 8160.966129150271 - }, - "endPoint": { - "x": 5718.21419414843, - "y": 8174.614427795983 - } - } - }, - { - "id": "16a2025d-b843-4e9d-b55c-045175b62f11", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5718.21419414843, - "y": 8174.614427795983 - }, - "endPoint": { - "x": 5718.21419414843, - "y": 8174.614427795983 - } - } - } - ] - }, - { - "id": "7b07fac3-331d-41f0-baa6-ad715b9d201d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5433.0870333127095, - "y": 8174.614427795983 - }, - "endPoint": { - "x": 7309.487524201104, - "y": 8174.614427795983 - } - } - }, - { - "id": "22af3408-ba2e-4796-8b80-c7302d81da12", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5600.650613730599, - "y": 8174.614427795983 - }, - "endPoint": { - "x": 5600.650613730599, - "y": 8007.050847378181 - } - } - }, - { - "id": "f644cb04-c954-4ed3-abda-e11074ab5664", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5433.0870333127095, - "y": 3730.2742730681784 - }, - "endPoint": { - "x": 7309.487524201104, - "y": 3730.2742730681784 - } - } - }, - { - "id": "6cc5ee6c-c73d-4d80-9a68-1592fd26a8a3", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5600.650613730599, - "y": 3730.2742730681784 - }, - "endPoint": { - "x": 5600.650613730599, - "y": 3562.7106926503766 - } - } - }, - { - "id": "e1b4b339-7d9a-4d80-b43a-2677fb8efc56", - "type": "PolyLine", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": null, - "children": [ - { - "id": "60756684-46d7-4cb1-a60f-0b38a4488799", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5718.21419414843, - "y": 3730.2742730681784 - }, - "endPoint": { - "x": 5717.41927127511, - "y": 3743.9225717138906 - } - } - }, - { - "id": "0a4342bd-88ae-4fa7-8858-18e03a562a56", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5717.41927127511, - "y": 3743.9225717138906 - }, - "endPoint": { - "x": 5715.045252623153, - "y": 3757.3863005338353 - } - } - }, - { - "id": "6b253cc5-8a39-46f5-906f-2f3ce21034a8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5715.045252623153, - "y": 3757.3863005338353 - }, - "endPoint": { - "x": 5711.124242722406, - "y": 3770.4833856925543 - } - } - }, - { - "id": "95d38aae-02ac-4679-a8e9-6fbd67434b57", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5711.124242722406, - "y": 3770.4833856925543 - }, - "endPoint": { - "x": 5705.709266505262, - "y": 3783.0367115811387 - } - } - }, - { - "id": "e8350021-be8e-42a9-8a8a-837ba5c864ca", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5705.709266505262, - "y": 3783.0367115811387 - }, - "endPoint": { - "x": 5698.873552235775, - "y": 3794.8765160019393 - } - } - }, - { - "id": "cb720ada-86d1-4692-bf8e-b1479871972e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5698.873552235775, - "y": 3794.8765160019393 - }, - "endPoint": { - "x": 5690.709541222837, - "y": 3805.842685911164 - } - } - }, - { - "id": "c21fcfd2-b6da-4aeb-8b63-b275076619fc", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5690.709541222837, - "y": 3805.842685911164 - }, - "endPoint": { - "x": 5681.327637710259, - "y": 3815.7869226730836 - } - } - }, - { - "id": "3c9efdad-77d8-4ad3-a2a0-03ecbdd27cd8", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5681.327637710259, - "y": 3815.7869226730836 - }, - "endPoint": { - "x": 5670.854715848458, - "y": 3824.574747544655 - } - } - }, - { - "id": "bdaf109d-bc68-4549-8232-5b9827660fa6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5670.854715848458, - "y": 3824.574747544655 - }, - "endPoint": { - "x": 5659.432403939543, - "y": 3832.0873202699004 - } - } - }, - { - "id": "42bfbaef-20c6-404d-92d4-9042a2adbf40", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5659.432403939543, - "y": 3832.0873202699004 - }, - "endPoint": { - "x": 5647.215169157251, - "y": 3838.2230461903673 - } - } - }, - { - "id": "05fd30c8-852f-430f-984b-0ddeee30a0ca", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5647.215169157251, - "y": 3838.2230461903673 - }, - "endPoint": { - "x": 5634.3682286435505, - "y": 3842.8989501387405 - } - } - }, - { - "id": "cd8ed016-f8ee-49d0-8774-40d76eab47f4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5634.3682286435505, - "y": 3842.8989501387405 - }, - "endPoint": { - "x": 5621.0653152301675, - "y": 3846.05179853554 - } - } - }, - { - "id": "cf3d1b54-66f0-4aad-8614-86f38311955f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5621.0653152301675, - "y": 3846.05179853554 - }, - "endPoint": { - "x": 5607.486328000086, - "y": 3847.6389545148995 - } - } - }, - { - "id": "b2bc6cab-b3af-4911-ab1b-72549e9c2489", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5607.486328000086, - "y": 3847.6389545148995 - }, - "endPoint": { - "x": 5593.814899461111, - "y": 3847.6389545148995 - } - } - }, - { - "id": "fcf4f082-d1ed-4060-8037-fd94e927d721", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5593.814899461111, - "y": 3847.6389545148995 - }, - "endPoint": { - "x": 5580.23591223103, - "y": 3846.05179853554 - } - } - }, - { - "id": "1d5d1f5f-21ad-487a-ae6a-b728f9a81b1f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5580.23591223103, - "y": 3846.05179853554 - }, - "endPoint": { - "x": 5566.932998817647, - "y": 3842.8989501387405 - } - } - }, - { - "id": "b3b721ec-8835-4432-99cf-994bf63ac0b2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5566.932998817647, - "y": 3842.8989501387405 - }, - "endPoint": { - "x": 5554.086058303947, - "y": 3838.2230461903673 - } - } - }, - { - "id": "523bc5b4-9a87-4249-829c-7d6cddb17a61", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5554.086058303947, - "y": 3838.2230461903673 - }, - "endPoint": { - "x": 5541.868823521654, - "y": 3832.0873202699004 - } - } - }, - { - "id": "8829cce7-66f2-4a55-859f-44353645f1e5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5541.868823521654, - "y": 3832.0873202699004 - }, - "endPoint": { - "x": 5530.446511612739, - "y": 3824.574747544655 - } - } - }, - { - "id": "df72ee9e-c881-40e3-b8da-af4a7ab07ef5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5530.446511612739, - "y": 3824.574747544655 - }, - "endPoint": { - "x": 5519.973589750938, - "y": 3815.7869226730836 - } - } - }, - { - "id": "3785b43e-8d52-4657-ba48-5d5360fc1181", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5519.973589750938, - "y": 3815.7869226730836 - }, - "endPoint": { - "x": 5510.59168623836, - "y": 3805.842685911164 - } - } - }, - { - "id": "3d423c96-91af-4b47-9740-a903b40924a6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5510.59168623836, - "y": 3805.842685911164 - }, - "endPoint": { - "x": 5502.427675225423, - "y": 3794.8765160019393 - } - } - }, - { - "id": "2069985d-bd19-4c4d-8c63-a25198d4832c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5502.427675225423, - "y": 3794.8765160019393 - }, - "endPoint": { - "x": 5495.591960955935, - "y": 3783.0367115811387 - } - } - }, - { - "id": "6c750d17-c017-447f-8169-9cc0ca3b1891", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5495.591960955935, - "y": 3783.0367115811387 - }, - "endPoint": { - "x": 5490.176984738791, - "y": 3770.4833856925543 - } - } - }, - { - "id": "3ce5f5d4-7a69-49db-a868-64b658f1dd45", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5490.176984738791, - "y": 3770.4833856925543 - }, - "endPoint": { - "x": 5486.2559748380445, - "y": 3757.3863005338353 - } - } - }, - { - "id": "92de1f4a-62a6-49c7-a4cc-abe0e252e63c", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5486.2559748380445, - "y": 3757.3863005338353 - }, - "endPoint": { - "x": 5483.881956186087, - "y": 3743.9225717138906 - } - } - }, - { - "id": "f89b6861-ac96-4042-be58-a1a427e69af2", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.881956186087, - "y": 3743.9225717138906 - }, - "endPoint": { - "x": 5483.087033312768, - "y": 3730.2742730681784 - } - } - }, - { - "id": "234656f0-54ca-4131-8f7a-30b84f6b8e44", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.087033312768, - "y": 3730.2742730681784 - }, - "endPoint": { - "x": 5483.881956186087, - "y": 3716.6259744224662 - } - } - }, - { - "id": "65e9b18a-c19a-48a3-9030-97db51773f50", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5483.881956186087, - "y": 3716.6259744224662 - }, - "endPoint": { - "x": 5486.2559748380445, - "y": 3703.1622456025216 - } - } - }, - { - "id": "c039b73c-1394-484d-a6c2-740cb4d7868a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5486.2559748380445, - "y": 3703.1622456025216 - }, - "endPoint": { - "x": 5490.176984738791, - "y": 3690.0651604438026 - } - } - }, - { - "id": "e6f3edd6-727a-4afc-9548-2513a38df948", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5490.176984738791, - "y": 3690.0651604438026 - }, - "endPoint": { - "x": 5495.591960955935, - "y": 3677.5118345552182 - } - } - }, - { - "id": "6c0505b6-c152-4dc2-b7a2-5ce13e038a09", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5495.591960955935, - "y": 3677.5118345552182 - }, - "endPoint": { - "x": 5502.427675225423, - "y": 3665.6720301344176 - } - } - }, - { - "id": "156d5acb-e84c-4e2d-8da6-ea8759e0633d", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5502.427675225423, - "y": 3665.6720301344176 - }, - "endPoint": { - "x": 5510.59168623836, - "y": 3654.7058602251927 - } - } - }, - { - "id": "26b42158-092a-4772-9c8f-221c5037efa0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5510.59168623836, - "y": 3654.7058602251927 - }, - "endPoint": { - "x": 5519.973589750938, - "y": 3644.7616234632733 - } - } - }, - { - "id": "4e1338e2-8116-45b2-b3b3-27841ebf09d6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5519.973589750938, - "y": 3644.7616234632733 - }, - "endPoint": { - "x": 5530.446511612739, - "y": 3635.973798591702 - } - } - }, - { - "id": "fdcf9d52-31a7-4f85-9922-e4d0a4d26e39", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5530.446511612739, - "y": 3635.973798591702 - }, - "endPoint": { - "x": 5541.868823521654, - "y": 3628.4612258664565 - } - } - }, - { - "id": "36ba6380-dccc-4a90-a7ef-9438c4d0d34a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5541.868823521654, - "y": 3628.4612258664565 - }, - "endPoint": { - "x": 5554.086058303947, - "y": 3622.3254999459896 - } - } - }, - { - "id": "baeda2ba-4526-44cc-93e2-b00a716681d6", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5554.086058303947, - "y": 3622.3254999459896 - }, - "endPoint": { - "x": 5566.932998817647, - "y": 3617.6495959976164 - } - } - }, - { - "id": "fff5e652-3d04-4236-97ad-33177e5f34b7", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5566.932998817647, - "y": 3617.6495959976164 - }, - "endPoint": { - "x": 5580.23591223103, - "y": 3614.496747600817 - } - } - }, - { - "id": "5748a4b6-b168-4707-9323-5a308f4865f0", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5580.23591223103, - "y": 3614.496747600817 - }, - "endPoint": { - "x": 5593.814899461111, - "y": 3612.9095916214574 - } - } - }, - { - "id": "f5f46f90-e807-4172-9a3a-10affd7273f9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5593.814899461111, - "y": 3612.9095916214574 - }, - "endPoint": { - "x": 5607.486328000086, - "y": 3612.9095916214574 - } - } - }, - { - "id": "54e287ac-0ace-4f65-83c2-cd1270238e60", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5607.486328000086, - "y": 3612.9095916214574 - }, - "endPoint": { - "x": 5621.0653152301675, - "y": 3614.496747600817 - } - } - }, - { - "id": "fbe0d6dc-89e6-4d7a-ace7-ab026acf0c1a", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5621.0653152301675, - "y": 3614.496747600817 - }, - "endPoint": { - "x": 5634.3682286435505, - "y": 3617.6495959976164 - } - } - }, - { - "id": "51dc0feb-b03b-44bd-82fd-2ef0abcd8563", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5634.3682286435505, - "y": 3617.6495959976164 - }, - "endPoint": { - "x": 5647.215169157251, - "y": 3622.3254999459896 - } - } - }, - { - "id": "cb5d7056-d456-4487-bfe3-a2ea3c572658", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5647.215169157251, - "y": 3622.3254999459896 - }, - "endPoint": { - "x": 5659.432403939543, - "y": 3628.4612258664565 - } - } - }, - { - "id": "1f53f231-e517-4c09-8ffc-a5f0b6be2ed5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5659.432403939543, - "y": 3628.4612258664565 - }, - "endPoint": { - "x": 5670.854715848458, - "y": 3635.973798591702 - } - } - }, - { - "id": "6078b620-3bd9-49a0-bd58-c61f0ee149ac", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5670.854715848458, - "y": 3635.973798591702 - }, - "endPoint": { - "x": 5681.327637710259, - "y": 3644.7616234632733 - } - } - }, - { - "id": "9d0f7a7c-cd4b-4029-9423-65b00776a4c5", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5681.327637710259, - "y": 3644.7616234632733 - }, - "endPoint": { - "x": 5690.709541222837, - "y": 3654.7058602251927 - } - } - }, - { - "id": "8c080d18-479b-470d-a893-e9beef8b5907", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5690.709541222837, - "y": 3654.7058602251927 - }, - "endPoint": { - "x": 5698.873552235775, - "y": 3665.6720301344176 - } - } - }, - { - "id": "6dd73348-337f-432b-8d81-ace9357e99c4", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5698.873552235775, - "y": 3665.6720301344176 - }, - "endPoint": { - "x": 5705.709266505262, - "y": 3677.5118345552182 - } - } - }, - { - "id": "d59560a1-7f4b-4b5e-94bc-58ace611c9d9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5705.709266505262, - "y": 3677.5118345552182 - }, - "endPoint": { - "x": 5711.124242722406, - "y": 3690.0651604438026 - } - } - }, - { - "id": "5b3dcc5d-5b74-4fd7-bbc9-f9ca58a19847", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5711.124242722406, - "y": 3690.0651604438026 - }, - "endPoint": { - "x": 5715.045252623153, - "y": 3703.1622456025216 - } - } - }, - { - "id": "7cabdff4-3779-4658-a9e5-b76a654159c9", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5715.045252623153, - "y": 3703.1622456025216 - }, - "endPoint": { - "x": 5717.41927127511, - "y": 3716.6259744224662 - } - } - }, - { - "id": "62f7b705-f02f-4b85-89c5-c8d66adefb5e", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5717.41927127511, - "y": 3716.6259744224662 - }, - "endPoint": { - "x": 5718.21419414843, - "y": 3730.2742730681784 - } - } - }, - { - "id": "a43dd6fd-7233-47a1-b2a9-848f9497c355", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 5718.21419414843, - "y": 3730.2742730681784 - }, - "endPoint": { - "x": 5718.21419414843, - "y": 3730.2742730681784 - } - } - } - ] - }, - { - "id": "fa680b3a-59c5-4d90-9dfe-cc01d6c3880e", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "정 면 도", - "basePoint": { - "x": 6087.681379433197, - "y": 3791.172501553083 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 120, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "cf4a602a-a552-4b05-ac6b-afcb949fdf7c", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "T=20cm", - "basePoint": { - "x": 4784.830087936833, - "y": 5776.79392950749 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 60, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "14d6d66a-9613-4c58-90c0-01942361e39d", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "T=20cm", - "basePoint": { - "x": 7705.7294778356445, - "y": 5776.79392950749 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#f5f7fa", - "fontSize": 60, - "fontFamily": "sans-serif" - } - } - }, - { - "id": "595c29ce-64ff-46b1-bbb7-a92d69292452", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4822.498914133117, - "y": 5527.7554642618925 - }, - "endPoint": { - "x": 4454.511725482822, - "y": 5114.109492340183 - } - } - }, - { - "id": "eeed58bc-c0ce-4a20-9e8e-214cf6f8e98f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 4454.511725482822, - "y": 5114.109492340183 - }, - "endPoint": { - "x": 3488.5719559921417, - "y": 5114.109492340183 - } - } - }, - { - "id": "df7c9ee4-869b-4fea-b428-e754f2b1156f", - "type": "Line", - "lineColor": "#fff", - "lineWidth": 1, - "layerId": "b08-frame", - "shapeData": { - "startPoint": { - "x": 3488.5719559921417, - "y": 5114.109492340183 - }, - "endPoint": { - "x": 3585.429944080126, - "y": 5114.109492340183 - } - } - }, - { - "id": "9a4dd1b9-e95c-4369-9673-a4b33674efbc", - "type": "Text", - "lineColor": "#fff", - "lineWidth": 1, - "lineDash": [], - "layerId": "b08-frame", - "shapeData": { - "label": "D16mm, L=500mm", - "basePoint": { - "x": 4272.321739232517, - "y": 5134.109492340183 - }, - "options": { - "textDirection": { - "x": 1, - "y": 0 - }, - "textAlign": "left", - "textColor": "#ffffff", - "fontSize": 70, - "fontFamily": "sans-serif" - } - } - } - ], - "layers": [ - { - "id": "b08-frame", - "name": "도각", - "isVisible": true, - "isLocked": false - } - ], - "drawing_area": [ - 42, - 47, - 812, - 567 - ] - } -} diff --git a/resources/master_template/structure/rw.json b/resources/master_template/structure/rw.json deleted file mode 100644 index 992e0bf6..00000000 --- a/resources/master_template/structure/rw.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "rw", - "도번": "SD09010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/sg.json b/resources/master_template/structure/sg.json deleted file mode 100644 index 325b7db6..00000000 --- a/resources/master_template/structure/sg.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "sg", - "도번": "SD11010100", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/ta.json b/resources/master_template/structure/ta.json deleted file mode 100644 index edb845cc..00000000 --- a/resources/master_template/structure/ta.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "ta", - "도번": "SD17000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/structure/wy.json b/resources/master_template/structure/wy.json deleted file mode 100644 index fd15a926..00000000 --- a/resources/master_template/structure/wy.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "양식": "구조물도면", - "종류": "structure", - "판": 1, - "열": "wy", - "도번": "SD16000000", - "도면": { - "format": 6, - "entities": [], - "layers": [ - { - "id": "기본", - "name": "기본", - "isVisible": true, - "isLocked": false - } - ] - } -} diff --git a/resources/master_template/table/구조물집계표.json b/resources/master_template/table/구조물집계표.json index b229bef3..8336b373 100644 --- a/resources/master_template/table/구조물집계표.json +++ b/resources/master_template/table/구조물집계표.json @@ -43,1260 +43,6 @@ "꼴": "글", "설명": "점 NO.x+y · 구간 NO.a~NO.b · 맨 위 전구간" }, - { - "id": "rv", - "머리": [ - "돌기슭막이", - "메쌓기", - "1.2m", - "기초유" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 기슭막이 · 관 유입·유출 기슭막이 길이 · 형태 → 높이마다 열", - "일위대가": "UA000004", - "바인딩": { - "종류": "revetment", - "펼침": [ - "form", - "height_m" - ], - "값": "length_m", - "묶음": "rv", - "머리틀": [ - "돌기슭막이", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형태 미입력", - "높이 미입력" - ], - "이름": { - "돌쌓기(찰)": "찰쌓기", - "돌쌓기(메)": "메쌓기" - }, - "더함": [ - { - "종류": "pipe", - "펼침": [ - "inlet_revet_form", - "inlet_revet_height_m" - ], - "값": "inlet_revet_length_m", - "조건": { - "inlet_type": "기슭막이" - } - }, - { - "종류": "pipe", - "펼침": [ - "outlet_revet_form", - "outlet_revet_height_m" - ], - "값": "outlet_revet_length_m", - "조건": { - "outlet_type": "기슭막이" - } - } - ] - } - }, - { - "id": "c4", - "머리": [ - "돌기슭막이", - "메쌓기", - "1.2m", - "기초무, 소단" - ], - "단위": "m", - "꼴": "수", - "일위대가": "UA000004" - }, - { - "id": "c1", - "머리": [ - "돌기슭막이", - "메쌓기", - "1.5m", - "기초유" - ], - "단위": "m", - "꼴": "수", - "일위대가": "UA000004" - }, - { - "id": "c5", - "머리": [ - "돌기슭막이", - "찰쌓기", - "", - "" - ], - "단위": "m", - "꼴": "수", - "일위대가": "UA000004" - }, - { - "id": "pv_b", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "B", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 포장 폭", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "width_m", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "B" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "pv_l", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "L", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 포장 길이", - "일위대가": "UA000007", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "길이", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "L" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "pv_w", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "확폭", - "" - ], - "단위": "㎡", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 확폭 면적", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "widening_area_m2", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "확폭" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "pv_a", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "A", - "" - ], - "단위": "㎡", - "꼴": "수", - "펼침": true, - "끝수": { - "자리": 2, - "방법": "반올림" - }, - "설명": "계산 — B × L + 확폭", - "일위대가": "UA000006", - "펼침틀": { - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "A" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "pv_jt", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "수축줄눈", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "끝수": { - "자리": 2, - "방법": "반올림" - }, - "설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)", - "펼침틀": { - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "수축줄눈" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "c3", - "머리": [ - "콘크리트포장", - "T=(두께마다)", - "새 분류2", - "" - ], - "단위": "m", - "꼴": "수" - }, - { - "id": "pp_len", - "머리": [ - "관공", - "(관종·관경마다)", - "관매설", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔", - "일위대가": "UA000036", - "일위대가후보": [ - "UA000036", - "UA000027" - ], - "바인딩": { - "종류": "pipe", - "펼침": [ - "pipe_kind", - "pipe_diameter_mm" - ], - "값": "관연장", - "묶음": "pp", - "머리틀": [ - "관공", - "{0} {1}", - "관매설" - ], - "값꼴": [ - "{}", - "Φ{}" - ], - "빈값": [ - "관종 미입력", - "관경 미입력" - ] - } - }, - { - "id": "pp_cp", - "머리": [ - "관공", - "(관종·관경마다)", - "커플링밴드", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)", - "펼침틀": { - "묶음": "pp", - "머리틀": [ - "관공", - "{0} {1}", - "커플링밴드" - ], - "값꼴": [ - "{}", - "Φ{}" - ], - "빈값": [ - "관종 미입력", - "관경 미입력" - ] - } - }, - { - "id": "pg_in", - "머리": [ - "관보호공", - "(형식마다)", - "유입", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 관 날개벽 형식 · 유입구가 집수정이면 집수정 형식 · 형식마다 열(유출구는 기슭막이 — 돌기슭막이 열)", - "일위대가": "UA000008", - "일위대가후보": [ - "UA000008", - "UA000025", - "UA000029" - ], - "바인딩": { - "종류": "pipe", - "펼침": [ - "wing_wall_type" - ], - "값": "개소", - "묶음": "pg", - "머리틀": [ - "관보호공", - "{0}", - "유입" - ], - "빈값": [ - "형식 미입력" - ], - "이름": { - "A-TYPE": "날개벽 A형", - "C-TYPE": "날개벽 C형", - "A-TYPE+집수정": "날개벽 A형+집수정" - }, - "더함": [ - { - "종류": "pipe", - "펼침": [ - "inlet_basin_form" - ], - "값": "개소", - "조건": { - "inlet_type": "집수정" - } - } - ] - } - }, - { - "id": "bx_len", - "머리": [ - "BOX암거", - "(폭×높이마다)", - "연장", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — BOX 연장(계류 방향)", - "일위대가": "UA000014", - "바인딩": { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "length_m", - "묶음": "bx", - "머리틀": [ - "BOX암거", - "{0}×{1}", - "연장" - ], - "빈값": [ - "폭 미입력", - "높이 미입력" - ] - } - }, - { - "id": "bx_wing", - "머리": [ - "BOX암거", - "(폭×높이마다)", - "날개벽", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 날개벽 유입·유출 있음 수", - "일위대가": "UA000025", - "바인딩": { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "개소", - "묶음": "bx", - "머리틀": [ - "BOX암거", - "{0}×{1}", - "날개벽" - ], - "빈값": [ - "폭 미입력", - "높이 미입력" - ], - "조건": { - "wing_in": "있음" - }, - "더함": [ - { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "개소", - "조건": { - "wing_out": "있음" - } - } - ] - } - }, - { - "id": "fb", - "머리": [ - "세월교", - "(관경 × 련수마다)", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 세월교 개소", - "바인딩": { - "종류": "ford_bridge", - "펼침": [ - "pipe_diameter_mm", - "pipe_count" - ], - "값": "개소", - "묶음": "fb", - "머리틀": [ - "세월교", - "{0}×{1}", - null - ], - "값꼴": [ - "Φ{}", - "{}련" - ], - "빈값": [ - "관경 미입력", - "련수 미입력" - ] - } - }, - { - "id": "fp_w", - "머리": [ - "물넘이포장", - "T=(두께마다)", - "폭", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 월류 폭", - "바인딩": { - "종류": "ford_pavement", - "펼침": [ - "thickness_cm" - ], - "값": "ford_width_m", - "묶음": "fp", - "머리틀": [ - "물넘이포장", - "{0}", - "폭" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "fp_l", - "머리": [ - "물넘이포장", - "T=(두께마다)", - "길이", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 포장 길이(노폭 방향)", - "바인딩": { - "종류": "ford_pavement", - "펼침": [ - "thickness_cm" - ], - "값": "length_m", - "묶음": "fp", - "머리틀": [ - "물넘이포장", - "{0}", - "길이" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - } - }, - { - "id": "od_len", - "머리": [ - "횡단개거", - "(규격마다)", - "길이", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 개거 연장", - "일위대가": "UA000026", - "바인딩": { - "종류": "open_ditch", - "펼침": [ - "ditch_spec" - ], - "값": "length_m", - "묶음": "od", - "머리틀": [ - "횡단개거", - "{0}", - "길이" - ], - "빈값": [ - "규격 미입력" - ] - } - }, - { - "id": "od_cnt", - "머리": [ - "횡단개거", - "(규격마다)", - "개소", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 개거 · 노출형 횡단수로 개소", - "일위대가": "UA000003", - "바인딩": { - "종류": "open_ditch", - "펼침": [ - "ditch_spec" - ], - "값": "개소", - "묶음": "od", - "머리틀": [ - "횡단개거", - "{0}", - "개소" - ], - "빈값": [ - "규격 미입력" - ], - "더함": [ - { - "종류": "cross_drain_exposed", - "펼침": [ - "=노출형" - ], - "값": "개소" - } - ] - } - }, - { - "id": "rw", - "머리": [ - "옹벽", - "(형식마다)", - "H=(높이마다)", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 옹벽 길이", - "바인딩": { - "종류": "retaining_wall", - "펼침": [ - "form", - "height_m" - ], - "값": "길이", - "묶음": "rw", - "머리틀": [ - "옹벽", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형식 미입력", - "높이 미입력" - ] - } - }, - { - "id": "ms", - "머리": [ - "돌쌓기", - "(찰·메마다)", - "H=(높이마다)", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 돌쌓기 길이", - "바인딩": { - "종류": "masonry_wet", - "펼침": [ - "=찰쌓기", - "height_m" - ], - "값": "길이", - "묶음": "ms", - "머리틀": [ - "돌쌓기", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "", - "높이 미입력" - ], - "더함": [ - { - "종류": "masonry_dry", - "펼침": [ - "=메쌓기", - "height_m" - ], - "값": "길이" - } - ] - } - }, - { - "id": "sg", - "머리": [ - "흙막이", - "(형태마다)", - "H=(높이마다)", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만", - "일위대가": "UA000032", - "바인딩": { - "종류": "soil_guard", - "펼침": [ - "form", - "height_m" - ], - "값": "길이", - "묶음": "sg", - "머리틀": [ - "흙막이", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형식 미입력", - "높이 미입력" - ] - } - }, - { - "id": "bm_len", - "머리": [ - "큰돌쌓기", - "(찰·메 · 돌 크기 · 높이마다)", - "길이", - "" - ], - "단위": "m", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 큰돌쌓기 길이", - "바인딩": { - "종류": "boulder_masonry", - "펼침": [ - "bond", - "stone_cm", - "height_m" - ], - "값": "길이", - "묶음": "bm", - "머리틀": [ - "큰돌쌓기", - "{0} {1} {2}", - "길이" - ], - "값꼴": [ - "{}", - "{}", - "H={}" - ], - "빈값": [ - "쌓기 방식 미입력", - "돌규격 미입력", - "높이 미입력" - ] - } - }, - { - "id": "bm_area", - "머리": [ - "큰돌쌓기", - "(찰·메 · 돌 크기 · 높이마다)", - "면적", - "" - ], - "단위": "㎡", - "꼴": "수", - "펼침": true, - "설명": "계산 — L × H (H = 펼친 높이)", - "일위대가": "UA000031", - "펼침틀": { - "묶음": "bm", - "머리틀": [ - "큰돌쌓기", - "{0} {1} {2}", - "면적" - ], - "값꼴": [ - "{}", - "{}", - "H={}" - ], - "빈값": [ - "쌓기 방식 미입력", - "돌규격 미입력", - "높이 미입력" - ], - "식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")" - } - }, - { - "id": "ec", - "머리": [ - "골막이", - "(형태마다)", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 골막이 개소", - "바인딩": { - "종류": "erosion_check", - "펼침": [ - "form" - ], - "값": "개소", - "묶음": "ec", - "머리틀": [ - "골막이", - "{0}", - null - ], - "빈값": [ - "형식 미입력" - ] - } - }, - { - "id": "be", - "머리": [ - "소단", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "설명": "설계값 — 소단 길이", - "바인딩": { - "종류": "berm", - "값": "길이" - } - }, - { - "id": "rf", - "머리": [ - "대피소", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 대피소", - "바인딩": { - "종류": "refuge", - "값": "개소" - } - }, - { - "id": "c2", - "머리": [ - "대피소", - "새 분류1", - "", - "" - ], - "단위": "개소", - "꼴": "수" - }, - { - "id": "wy", - "머리": [ - "정차·작업장", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 정차·작업장", - "바인딩": { - "종류": "work_yard", - "값": "개소" - } - }, - { - "id": "ta", - "머리": [ - "차돌림곳", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 차돌림곳", - "바인딩": { - "종류": "turnaround", - "값": "개소" - } - }, - { - "id": "gr_rail", - "머리": [ - "가드레일", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "설명": "설계값 — 가드레일 길이", - "바인딩": { - "종류": "guardrail", - "값": "길이", - "조건": { - "kind": "가드레일" - } - } - }, - { - "id": "gr_curb", - "머리": [ - "경계석", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "설명": "설계값 — 경계석 길이", - "바인딩": { - "종류": "guardrail", - "값": "길이", - "조건": { - "kind": "경계석" - } - } - }, - { - "id": "gr_sign", - "머리": [ - "위험표지", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 위험표지", - "일위대가": "UA000017", - "바인딩": { - "종류": "guardrail", - "값": "개소", - "조건": { - "kind": "위험표지" - } - } - }, - { - "id": "mr", - "머리": [ - "반사경", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 반사경", - "일위대가": "UA000018", - "바인딩": { - "종류": "mirror", - "값": "개소" - } - }, - { - "id": "bg", - "머리": [ - "차단기", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 차단기", - "일위대가": "UA000010", - "바인딩": { - "종류": "barrier_gate", - "값": "개소" - } - }, - { - "id": "ps", - "머리": [ - "국가지점번호판", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 번호판 · 전구간 줄은 손 입력", - "일위대가": "UA000016", - "바인딩": { - "종류": "position_sign", - "값": "개소" - } - }, - { - "id": "cs", - "머리": [ - "준공표지석", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "설명": "설계값 — 준공표지판", - "일위대가": "UA000002", - "바인딩": { - "종류": "completion_sign", - "값": "개소" - } - }, - { - "id": "etc", - "머리": [ - "기타", - "(이름마다)", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "펼침": true, - "설명": "설계값 — 기타 구조물 개소", - "바인딩": { - "종류": "etc", - "펼침": [ - "name" - ], - "값": "개소", - "묶음": "etc", - "머리틀": [ - "기타", - "{0}", - null - ], - "빈값": [ - "이름 미입력" - ] - } - }, - { - "id": "h_intake", - "머리": [ - "취수정", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000005" - }, - { - "id": "h_rip_c", - "머리": [ - "돌붙임", - "찰붙임", - "", - "" - ], - "단위": "㎡", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000009" - }, - { - "id": "h_rip_m", - "머리": [ - "돌붙임", - "메붙임", - "", - "" - ], - "단위": "㎡", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000009" - }, - { - "id": "h_stone_ch", - "머리": [ - "돌수로", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000011" - }, - { - "id": "h_stone_bed", - "머리": [ - "돌조공", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000023" - }, - { - "id": "h_pv_rail", - "머리": [ - "포장 난간", - "", - "", - "" - ], - "단위": "m", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000012" - }, - { - "id": "h_pg_rail", - "머리": [ - "관보호공 안전난간", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000013" - }, - { - "id": "h_soil_ditch", - "머리": [ - "수로형 토사개거", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000033" - }, - { - "id": "h_ford_basin", - "머리": [ - "물넘이 집수부", - "", - "", - "" - ], - "단위": "개소", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": "UA000034" - }, - { - "id": "h_rockfall", - "머리": [ - "낙석방지책", - "", - "", - "" - ], - "단위": "경간", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": null - }, - { - "id": "h_break", - "머리": [ - "콘크리트·포장 깨기", - "", - "", - "" - ], - "단위": "㎥", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": null - }, - { - "id": "h_waste", - "머리": [ - "폐기물처리", - "", - "", - "" - ], - "단위": "ton", - "꼴": "수", - "손": true, - "설명": "손 입력", - "일위대가": null - }, { "id": "memo", "머리": [ @@ -1334,8 +80,8 @@ "열너비": { "no": 40, "sta": 150, - "memo": 120, - "h_soil_ditch": 32 + "memo": 120 } - } + }, + "폴더": [] } diff --git a/resources/master_template/구조물집계표조합.json b/resources/master_template/구조물집계표조합.json index 31cb46e1..55f6a82d 100644 --- a/resources/master_template/구조물집계표조합.json +++ b/resources/master_template/구조물집계표조합.json @@ -1,1295 +1,60 @@ { "그룹": "구조물집계표조합", "판": 1, - "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀", + "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키(마스터 SS · 회사 SC{회사}- · 개인 SP{사용자}- + 6자리)는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 옛키 = 옮기기 전 키 · 경로 = 폴더 이름 배열", "다음": 52, - "줄": [ - { - "키": "SS000001", - "열": "rv", - "종류": "돌기슭막이", - "분류1": "메쌓기", - "분류2": "1.2m", - "분류3": "기초유", - "단위": "m", - "일위대가": "UA000004", - "자재조합": null, - "도번": "SD01010100", - "면적번": "SA01010100", - "재료번": "SM01010100", - "바인딩": { - "종류": "revetment", - "펼침": [ - "form", - "height_m" - ], - "값": "length_m", - "묶음": "rv", - "머리틀": [ - "돌기슭막이", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형태 미입력", - "높이 미입력" - ], - "이름": { - "돌쌓기(찰)": "찰쌓기", - "돌쌓기(메)": "메쌓기" - }, - "더함": [ - { - "종류": "pipe", - "펼침": [ - "inlet_revet_form", - "inlet_revet_height_m" - ], - "값": "inlet_revet_length_m", - "조건": { - "inlet_type": "기슭막이" - } - }, - { - "종류": "pipe", - "펼침": [ - "outlet_revet_form", - "outlet_revet_height_m" - ], - "값": "outlet_revet_length_m", - "조건": { - "outlet_type": "기슭막이" - } - } - ] - }, - "펼침": true - }, - { - "키": "SS000002", - "열": "c4", - "종류": "돌기슭막이", - "분류1": "메쌓기", - "분류2": "1.2m", - "분류3": "기초무, 소단", - "단위": "m", - "일위대가": "UA000004", - "자재조합": null, - "도번": "SD01010101", - "면적번": "SA01010101", - "재료번": "SM01010101", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000003", - "열": "c1", - "종류": "돌기슭막이", - "분류1": "메쌓기", - "분류2": "1.5m", - "분류3": "기초유", - "단위": "m", - "일위대가": "UA000004", - "자재조합": null, - "도번": "SD01010200", - "면적번": "SA01010200", - "재료번": "SM01010200", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000004", - "열": "c5", - "종류": "돌기슭막이", - "분류1": "찰쌓기", - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": "UA000004", - "자재조합": null, - "도번": "SD01020000", - "면적번": "SA01020000", - "재료번": "SM01020000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000005", - "열": "pv_b", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "B", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD02010100", - "면적번": "SA02010100", - "재료번": "SM02010100", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "width_m", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "B" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000006", - "열": "pv_l", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "L", - "분류3": null, - "단위": "m", - "일위대가": "UA000007", - "자재조합": null, - "도번": "SD02010200", - "면적번": "SA02010200", - "재료번": "SM02010200", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "길이", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "L" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000007", - "열": "pv_w", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "확폭", - "분류3": null, - "단위": "㎡", - "일위대가": null, - "자재조합": null, - "도번": "SD02010300", - "면적번": "SA02010300", - "재료번": "SM02010300", - "바인딩": { - "종류": "pavement_concrete", - "펼침": [ - "thickness_cm" - ], - "값": "widening_area_m2", - "묶음": "pv", - "머리틀": [ - "콘크리트포장", - "{0}", - "확폭" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000008", - "열": "pv_a", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "A", - "분류3": null, - "단위": "㎡", - "일위대가": "UA000006", - "자재조합": null, - "도번": "SD02010400", - "면적번": "SA02010400", - "재료번": "SM02010400", - "바인딩": null, - "펼침": true - }, - { - "키": "SS000009", - "열": "pv_jt", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "수축줄눈", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD02010500", - "면적번": "SA02010500", - "재료번": "SM02010500", - "바인딩": null, - "펼침": true - }, - { - "키": "SS000010", - "열": "c3", - "종류": "콘크리트포장", - "분류1": "T=(두께마다)", - "분류2": "새 분류2", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD02010600", - "면적번": "SA02010600", - "재료번": "SM02010600", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000011", - "열": "pp_len", - "종류": "관공", - "분류1": "(관종·관경마다)", - "분류2": "관매설", - "분류3": null, - "단위": "m", - "일위대가": "UA000036", - "자재조합": null, - "도번": "SD03010100", - "면적번": "SA03010100", - "재료번": "SM03010100", - "바인딩": { - "종류": "pipe", - "펼침": [ - "pipe_kind", - "pipe_diameter_mm" - ], - "값": "관연장", - "묶음": "pp", - "머리틀": [ - "관공", - "{0} {1}", - "관매설" - ], - "값꼴": [ - "{}", - "Φ{}" - ], - "빈값": [ - "관종 미입력", - "관경 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000012", - "열": "pp_cp", - "종류": "관공", - "분류1": "(관종·관경마다)", - "분류2": "커플링밴드", - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD03010200", - "면적번": "SA03010200", - "재료번": "SM03010200", - "바인딩": null, - "펼침": true - }, - { - "키": "SS000013", - "열": "pg_in", - "종류": "관보호공", - "분류1": "(형식마다)", - "분류2": "유입", - "분류3": null, - "단위": "개소", - "일위대가": "UA000008", - "자재조합": null, - "도번": "SD04010100", - "면적번": "SA04010100", - "재료번": "SM04010100", - "바인딩": { - "종류": "pipe", - "펼침": [ - "wing_wall_type" - ], - "값": "개소", - "묶음": "pg", - "머리틀": [ - "관보호공", - "{0}", - "유입" - ], - "빈값": [ - "형식 미입력" - ], - "이름": { - "A-TYPE": "날개벽 A형", - "C-TYPE": "날개벽 C형", - "A-TYPE+집수정": "날개벽 A형+집수정" - }, - "더함": [ - { - "종류": "pipe", - "펼침": [ - "inlet_basin_form" - ], - "값": "개소", - "조건": { - "inlet_type": "집수정" - } - } - ] - }, - "펼침": true - }, - { - "키": "SS000014", - "열": "bx_len", - "종류": "BOX암거", - "분류1": "(폭×높이마다)", - "분류2": "연장", - "분류3": null, - "단위": "m", - "일위대가": "UA000014", - "자재조합": null, - "도번": "SD05010100", - "면적번": "SA05010100", - "재료번": "SM05010100", - "바인딩": { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "length_m", - "묶음": "bx", - "머리틀": [ - "BOX암거", - "{0}×{1}", - "연장" - ], - "빈값": [ - "폭 미입력", - "높이 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000015", - "열": "bx_wing", - "종류": "BOX암거", - "분류1": "(폭×높이마다)", - "분류2": "날개벽", - "분류3": null, - "단위": "개소", - "일위대가": "UA000025", - "자재조합": null, - "도번": "SD05010200", - "면적번": "SA05010200", - "재료번": "SM05010200", - "바인딩": { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "개소", - "묶음": "bx", - "머리틀": [ - "BOX암거", - "{0}×{1}", - "날개벽" - ], - "빈값": [ - "폭 미입력", - "높이 미입력" - ], - "조건": { - "wing_in": "있음" - }, - "더함": [ - { - "종류": "box_culvert", - "펼침": [ - "body_width_m", - "body_height_m" - ], - "값": "개소", - "조건": { - "wing_out": "있음" - } - } - ] - }, - "펼침": true - }, - { - "키": "SS000016", - "열": "fb", - "종류": "세월교", - "분류1": "(관경 × 련수마다)", - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD06010000", - "면적번": "SA06010000", - "재료번": "SM06010000", - "바인딩": { - "종류": "ford_bridge", - "펼침": [ - "pipe_diameter_mm", - "pipe_count" - ], - "값": "개소", - "묶음": "fb", - "머리틀": [ - "세월교", - "{0}×{1}", - null - ], - "값꼴": [ - "Φ{}", - "{}련" - ], - "빈값": [ - "관경 미입력", - "련수 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000017", - "열": "fp_w", - "종류": "물넘이포장", - "분류1": "T=(두께마다)", - "분류2": "폭", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD07010100", - "면적번": "SA07010100", - "재료번": "SM07010100", - "바인딩": { - "종류": "ford_pavement", - "펼침": [ - "thickness_cm" - ], - "값": "ford_width_m", - "묶음": "fp", - "머리틀": [ - "물넘이포장", - "{0}", - "폭" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000018", - "열": "fp_l", - "종류": "물넘이포장", - "분류1": "T=(두께마다)", - "분류2": "길이", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD07010200", - "면적번": "SA07010200", - "재료번": "SM07010200", - "바인딩": { - "종류": "ford_pavement", - "펼침": [ - "thickness_cm" - ], - "값": "length_m", - "묶음": "fp", - "머리틀": [ - "물넘이포장", - "{0}", - "길이" - ], - "값꼴": [ - "T={}cm" - ], - "빈값": [ - "두께 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000019", - "열": "od_len", - "종류": "횡단개거", - "분류1": "(규격마다)", - "분류2": "길이", - "분류3": null, - "단위": "m", - "일위대가": "UA000026", - "자재조합": null, - "도번": "SD08010100", - "면적번": "SA08010100", - "재료번": "SM08010100", - "바인딩": { - "종류": "open_ditch", - "펼침": [ - "ditch_spec" - ], - "값": "length_m", - "묶음": "od", - "머리틀": [ - "횡단개거", - "{0}", - "길이" - ], - "빈값": [ - "규격 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000020", - "열": "od_cnt", - "종류": "횡단개거", - "분류1": "(규격마다)", - "분류2": "개소", - "분류3": null, - "단위": "개소", - "일위대가": "UA000003", - "자재조합": null, - "도번": "SD08010200", - "면적번": "SA08010200", - "재료번": "SM08010200", - "바인딩": { - "종류": "open_ditch", - "펼침": [ - "ditch_spec" - ], - "값": "개소", - "묶음": "od", - "머리틀": [ - "횡단개거", - "{0}", - "개소" - ], - "빈값": [ - "규격 미입력" - ], - "더함": [ - { - "종류": "cross_drain_exposed", - "펼침": [ - "=노출형" - ], - "값": "개소" - } - ] - }, - "펼침": true - }, - { - "키": "SS000021", - "열": "rw", - "종류": "옹벽", - "분류1": "(형식마다)", - "분류2": "H=(높이마다)", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD09010100", - "면적번": "SA09010100", - "재료번": "SM09010100", - "바인딩": { - "종류": "retaining_wall", - "펼침": [ - "form", - "height_m" - ], - "값": "길이", - "묶음": "rw", - "머리틀": [ - "옹벽", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형식 미입력", - "높이 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000022", - "열": "ms", - "종류": "돌쌓기", - "분류1": "(찰·메마다)", - "분류2": "H=(높이마다)", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD10010100", - "면적번": "SA10010100", - "재료번": "SM10010100", - "바인딩": { - "종류": "masonry_wet", - "펼침": [ - "=찰쌓기", - "height_m" - ], - "값": "길이", - "묶음": "ms", - "머리틀": [ - "돌쌓기", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "", - "높이 미입력" - ], - "더함": [ - { - "종류": "masonry_dry", - "펼침": [ - "=메쌓기", - "height_m" - ], - "값": "길이" - } - ] - }, - "펼침": true - }, - { - "키": "SS000023", - "열": "sg", - "종류": "흙막이", - "분류1": "(형태마다)", - "분류2": "H=(높이마다)", - "분류3": null, - "단위": "m", - "일위대가": "UA000032", - "자재조합": null, - "도번": "SD11010100", - "면적번": "SA11010100", - "재료번": "SM11010100", - "바인딩": { - "종류": "soil_guard", - "펼침": [ - "form", - "height_m" - ], - "값": "길이", - "묶음": "sg", - "머리틀": [ - "흙막이", - "{0}", - "{1}" - ], - "값꼴": [ - "{}", - "H={}" - ], - "빈값": [ - "형식 미입력", - "높이 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000024", - "열": "bm_len", - "종류": "큰돌쌓기", - "분류1": "(찰·메 · 돌 크기 · 높이마다)", - "분류2": "길이", - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD12010100", - "면적번": "SA12010100", - "재료번": "SM12010100", - "바인딩": { - "종류": "boulder_masonry", - "펼침": [ - "bond", - "stone_cm", - "height_m" - ], - "값": "길이", - "묶음": "bm", - "머리틀": [ - "큰돌쌓기", - "{0} {1} {2}", - "길이" - ], - "값꼴": [ - "{}", - "{}", - "H={}" - ], - "빈값": [ - "쌓기 방식 미입력", - "돌규격 미입력", - "높이 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000025", - "열": "bm_area", - "종류": "큰돌쌓기", - "분류1": "(찰·메 · 돌 크기 · 높이마다)", - "분류2": "면적", - "분류3": null, - "단위": "㎡", - "일위대가": "UA000031", - "자재조합": null, - "도번": "SD12010200", - "면적번": "SA12010200", - "재료번": "SM12010200", - "바인딩": null, - "펼침": true - }, - { - "키": "SS000026", - "열": "ec", - "종류": "골막이", - "분류1": "(형태마다)", - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD13010000", - "면적번": "SA13010000", - "재료번": "SM13010000", - "바인딩": { - "종류": "erosion_check", - "펼침": [ - "form" - ], - "값": "개소", - "묶음": "ec", - "머리틀": [ - "골막이", - "{0}", - null - ], - "빈값": [ - "형식 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000027", - "열": "be", - "종류": "소단", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD14000000", - "면적번": "SA14000000", - "재료번": "SM14000000", - "바인딩": { - "종류": "berm", - "값": "길이" - }, - "펼침": null - }, - { - "키": "SS000028", - "열": "rf", - "종류": "대피소", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD15000000", - "면적번": "SA15000000", - "재료번": "SM15000000", - "바인딩": { - "종류": "refuge", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000029", - "열": "c2", - "종류": "대피소", - "분류1": "새 분류1", - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD15010000", - "면적번": "SA15010000", - "재료번": "SM15010000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000030", - "열": "wy", - "종류": "정차·작업장", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD16000000", - "면적번": "SA16000000", - "재료번": "SM16000000", - "바인딩": { - "종류": "work_yard", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000031", - "열": "ta", - "종류": "차돌림곳", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD17000000", - "면적번": "SA17000000", - "재료번": "SM17000000", - "바인딩": { - "종류": "turnaround", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000032", - "열": "gr_rail", - "종류": "가드레일", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD18000000", - "면적번": "SA18000000", - "재료번": "SM18000000", - "바인딩": { - "종류": "guardrail", - "값": "길이", - "조건": { - "kind": "가드레일" - } - }, - "펼침": null - }, - { - "키": "SS000033", - "열": "gr_curb", - "종류": "경계석", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": null, - "자재조합": null, - "도번": "SD19000000", - "면적번": "SA19000000", - "재료번": "SM19000000", - "바인딩": { - "종류": "guardrail", - "값": "길이", - "조건": { - "kind": "경계석" - } - }, - "펼침": null - }, - { - "키": "SS000034", - "열": "gr_sign", - "종류": "위험표지", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000017", - "자재조합": null, - "도번": "SD20000000", - "면적번": "SA20000000", - "재료번": "SM20000000", - "바인딩": { - "종류": "guardrail", - "값": "개소", - "조건": { - "kind": "위험표지" - } - }, - "펼침": null - }, - { - "키": "SS000035", - "열": "mr", - "종류": "반사경", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000018", - "자재조합": null, - "도번": "SD21000000", - "면적번": "SA21000000", - "재료번": "SM21000000", - "바인딩": { - "종류": "mirror", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000036", - "열": "bg", - "종류": "차단기", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000010", - "자재조합": null, - "도번": "SD22000000", - "면적번": "SA22000000", - "재료번": "SM22000000", - "바인딩": { - "종류": "barrier_gate", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000037", - "열": "ps", - "종류": "국가지점번호판", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000016", - "자재조합": null, - "도번": "SD23000000", - "면적번": "SA23000000", - "재료번": "SM23000000", - "바인딩": { - "종류": "position_sign", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000038", - "열": "cs", - "종류": "준공표지석", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000002", - "자재조합": null, - "도번": "SD24000000", - "면적번": "SA24000000", - "재료번": "SM24000000", - "바인딩": { - "종류": "completion_sign", - "값": "개소" - }, - "펼침": null - }, - { - "키": "SS000039", - "열": "etc", - "종류": "기타", - "분류1": "(이름마다)", - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": null, - "자재조합": null, - "도번": "SD25010000", - "면적번": "SA25010000", - "재료번": "SM25010000", - "바인딩": { - "종류": "etc", - "펼침": [ - "name" - ], - "값": "개소", - "묶음": "etc", - "머리틀": [ - "기타", - "{0}", - null - ], - "빈값": [ - "이름 미입력" - ] - }, - "펼침": true - }, - { - "키": "SS000040", - "열": "h_intake", - "종류": "취수정", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000005", - "자재조합": null, - "도번": "SD26000000", - "면적번": "SA26000000", - "재료번": "SM26000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000041", - "열": "h_rip_c", - "종류": "돌붙임", - "분류1": "찰붙임", - "분류2": null, - "분류3": null, - "단위": "㎡", - "일위대가": "UA000009", - "자재조합": null, - "도번": "SD27010000", - "면적번": "SA27010000", - "재료번": "SM27010000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000042", - "열": "h_rip_m", - "종류": "돌붙임", - "분류1": "메붙임", - "분류2": null, - "분류3": null, - "단위": "㎡", - "일위대가": "UA000009", - "자재조합": null, - "도번": "SD27020000", - "면적번": "SA27020000", - "재료번": "SM27020000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000043", - "열": "h_stone_ch", - "종류": "돌수로", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": "UA000011", - "자재조합": null, - "도번": "SD28000000", - "면적번": "SA28000000", - "재료번": "SM28000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000044", - "열": "h_stone_bed", - "종류": "돌조공", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": "UA000023", - "자재조합": null, - "도번": "SD29000000", - "면적번": "SA29000000", - "재료번": "SM29000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000045", - "열": "h_pv_rail", - "종류": "포장 난간", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "m", - "일위대가": "UA000012", - "자재조합": null, - "도번": "SD30000000", - "면적번": "SA30000000", - "재료번": "SM30000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000046", - "열": "h_pg_rail", - "종류": "관보호공 안전난간", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000013", - "자재조합": null, - "도번": "SD31000000", - "면적번": "SA31000000", - "재료번": "SM31000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000047", - "열": "h_soil_ditch", - "종류": "수로형 토사개거", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000033", - "자재조합": null, - "도번": "SD32000000", - "면적번": "SA32000000", - "재료번": "SM32000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000048", - "열": "h_ford_basin", - "종류": "물넘이 집수부", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "개소", - "일위대가": "UA000034", - "자재조합": null, - "도번": "SD33000000", - "면적번": "SA33000000", - "재료번": "SM33000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000049", - "열": "h_rockfall", - "종류": "낙석방지책", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "경간", - "일위대가": null, - "자재조합": null, - "도번": "SD34000000", - "면적번": "SA34000000", - "재료번": "SM34000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000050", - "열": "h_break", - "종류": "콘크리트·포장 깨기", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "㎥", - "일위대가": null, - "자재조합": null, - "도번": "SD35000000", - "면적번": "SA35000000", - "재료번": "SM35000000", - "바인딩": null, - "펼침": null - }, - { - "키": "SS000051", - "열": "h_waste", - "종류": "폐기물처리", - "분류1": null, - "분류2": null, - "분류3": null, - "단위": "ton", - "일위대가": null, - "자재조합": null, - "도번": "SD36000000", - "면적번": "SA36000000", - "재료번": "SM36000000", - "바인딩": null, - "펼침": null - } - ] + "지운번호": [ + "01010100", + "01010101", + "01010200", + "01020000", + "02010100", + "02010200", + "02010300", + "02010400", + "02010500", + "02010600", + "03010100", + "03010200", + "04010100", + "05010100", + "05010200", + "06010000", + "07010100", + "07010200", + "08010100", + "08010200", + "09010100", + "10010100", + "11010100", + "12010100", + "12010200", + "13010000", + "14000000", + "15000000", + "15010000", + "16000000", + "17000000", + "18000000", + "19000000", + "20000000", + "21000000", + "22000000", + "23000000", + "24000000", + "25010000", + "26000000", + "27010000", + "27020000", + "28000000", + "29000000", + "30000000", + "31000000", + "32000000", + "33000000", + "34000000", + "35000000", + "36000000" + ], + "줄": [] } diff --git a/resources/master_template/area/c4.json b/resources/tester/fixtures/m02_master_sample/area/c4.json similarity index 100% rename from resources/master_template/area/c4.json rename to resources/tester/fixtures/m02_master_sample/area/c4.json diff --git a/resources/master_template/area/ms.json b/resources/tester/fixtures/m02_master_sample/area/ms.json similarity index 100% rename from resources/master_template/area/ms.json rename to resources/tester/fixtures/m02_master_sample/area/ms.json diff --git a/resources/master_template/area/pp_len.json b/resources/tester/fixtures/m02_master_sample/area/pp_len.json similarity index 100% rename from resources/master_template/area/pp_len.json rename to resources/tester/fixtures/m02_master_sample/area/pp_len.json diff --git a/resources/master_template/area/rv.json b/resources/tester/fixtures/m02_master_sample/area/rv.json similarity index 100% rename from resources/master_template/area/rv.json rename to resources/tester/fixtures/m02_master_sample/area/rv.json diff --git a/resources/master_template/material/c4.json b/resources/tester/fixtures/m02_master_sample/material/c4.json similarity index 100% rename from resources/master_template/material/c4.json rename to resources/tester/fixtures/m02_master_sample/material/c4.json diff --git a/resources/master_template/material/ms.json b/resources/tester/fixtures/m02_master_sample/material/ms.json similarity index 100% rename from resources/master_template/material/ms.json rename to resources/tester/fixtures/m02_master_sample/material/ms.json diff --git a/resources/master_template/material/pp_len.json b/resources/tester/fixtures/m02_master_sample/material/pp_len.json similarity index 100% rename from resources/master_template/material/pp_len.json rename to resources/tester/fixtures/m02_master_sample/material/pp_len.json diff --git a/resources/master_template/material/rv.json b/resources/tester/fixtures/m02_master_sample/material/rv.json similarity index 100% rename from resources/master_template/material/rv.json rename to resources/tester/fixtures/m02_master_sample/material/rv.json diff --git a/resources/master_template/structure/c4.json b/resources/tester/fixtures/m02_master_sample/structure/c4.json similarity index 79% rename from resources/master_template/structure/c4.json rename to resources/tester/fixtures/m02_master_sample/structure/c4.json index b189032d..a1b37243 100644 --- a/resources/master_template/structure/c4.json +++ b/resources/tester/fixtures/m02_master_sample/structure/c4.json @@ -56,23 +56,6 @@ "y": 265.72465372792067 } } - }, - { - "id": "358697a5-1159-4aa4-bc84-100ed5b27f82", - "type": "Line", - "lineColor": "#ffffff", - "lineWidth": 1, - "layerId": "기본", - "shapeData": { - "startPoint": { - "x": 408.7256717135351, - "y": 265.72465372792067 - }, - "endPoint": { - "x": 231.45113982994633, - "y": 277.54295585349314 - } - } } ], "layers": [ diff --git a/resources/master_template/structure/ms.json b/resources/tester/fixtures/m02_master_sample/structure/ms.json similarity index 100% rename from resources/master_template/structure/ms.json rename to resources/tester/fixtures/m02_master_sample/structure/ms.json diff --git a/resources/master_template/structure/pp_len.json b/resources/tester/fixtures/m02_master_sample/structure/pp_len.json similarity index 100% rename from resources/master_template/structure/pp_len.json rename to resources/tester/fixtures/m02_master_sample/structure/pp_len.json diff --git a/resources/tester/fixtures/m02_master_sample/structure/rv.json b/resources/tester/fixtures/m02_master_sample/structure/rv.json new file mode 100644 index 00000000..674a6cb7 --- /dev/null +++ b/resources/tester/fixtures/m02_master_sample/structure/rv.json @@ -0,0 +1,77 @@ +{ + "양식": "구조물도면", + "종류": "structure", + "판": 1, + "열": "rv", + "도번": "SD01010100", + "도면": { + "format": 6, + "entities": [ + { + "id": "6f01250e-97dd-4e69-8743-8ae8968b7cea", + "type": "Line", + "lineColor": "#fff", + "lineWidth": 1, + "layerId": "b08-frame", + "shapeData": { + "startPoint": { + "x": 4255.194526991341, + "y": 5857.308300811681 + }, + "endPoint": { + "x": 5342.438965520298, + "y": 5857.308300811681 + } + } + }, + { + "id": "408cfd22-a8e3-46da-ae11-d2b50fc97b79", + "type": "Line", + "lineColor": "#fff", + "lineWidth": 1, + "layerId": "b08-frame", + "shapeData": { + "startPoint": { + "x": 5842.438965520298, + "y": 5357.308300811681 + }, + "endPoint": { + "x": 5342.438965520298, + "y": 5857.308300811681 + } + } + }, + { + "id": "1865b0b5-c1f2-45ce-8999-05a5deb4bc90", + "type": "Line", + "lineColor": "#fff", + "lineWidth": 1, + "layerId": "b08-frame", + "shapeData": { + "startPoint": { + "x": 5842.438965520298, + "y": 5357.308300811681 + }, + "endPoint": { + "x": 6842.438965520298, + "y": 5357.308300811681 + } + } + } + ], + "layers": [ + { + "id": "b08-frame", + "name": "도각", + "isVisible": true, + "isLocked": false + } + ], + "drawing_area": [ + 42, + 47, + 812, + 567 + ] + } +} diff --git a/resources/tester/fixtures/m02_master_sample/table/구조물집계표.json b/resources/tester/fixtures/m02_master_sample/table/구조물집계표.json new file mode 100644 index 00000000..860fb71b --- /dev/null +++ b/resources/tester/fixtures/m02_master_sample/table/구조물집계표.json @@ -0,0 +1,1551 @@ +{ + "양식": "구조물집계표", + "종류": "표", + "판": 1, + "설명": "측점마다 구조물 수량 한 줄 · 머리 4층(종류 · 공법 · 규격 · 단위) · 설계값 열은 프로젝트 설계에서 채움(화면에서 못 고침 — 설계를 고침) · 손 열은 사용자가 적음 · 계산 열은 식. 종류 정본 = B05_Profile/B05_Profile_Structure_Types.json.", + "바인딩규칙": "종류 = type_id · 펼침 = 값마다 열을 나눌 칸 키(앞에 = 이면 고정 글) · 값 = 칸 키 | 개소(1건 1) | 길이(length_m, 없으면 끝-시작) | 관연장(B06 횡단 pipe_length_m) · 묶음 = 같은 펼침으로 함께 나뉘는 열 · 머리틀 = 펼친 머리({0}{1}… = 펼침 값 차례) · 이름 = 설계 값 → 머리 글 · 조건 = {칸: 값}(맞는 것만) · 더함 = 같은 열에 더하는 다른 출처 · 식틀 = 펼친 열의 식({0}… 펼침 값) · 값꼴 = 자리마다 머리 글 꼴({} = 값) · 빈값 = 설계값이 빈 자리 머리 글 · 펼침틀 = 계산 열이 같은 묶음으로 따라 나뉨(묶음 · 머리틀 · 값꼴 · 빈값 · 식틀 — 설계값을 받지 않아 바인딩 없음)", + "층": [ + "종류", + "분류1", + "분류2", + "분류3" + ], + "변수": { + "파형강관_1본_m": 8, + "흄관_1본_m": "", + "VR관_1본_m": "", + "수축줄눈_간격_m": 6, + "측점간격_m": 20 + }, + "쪽줄": 50, + "열": [ + { + "id": "no", + "머리": [ + "NO", + null, + null, + null + ], + "단위": null, + "꼴": "글", + "설명": "줄 차례(전구간 줄 빼고 1부터)" + }, + { + "id": "sta", + "머리": [ + "측점", + null, + null, + null + ], + "단위": null, + "꼴": "글", + "설명": "점 NO.x+y · 구간 NO.a~NO.b · 맨 위 전구간" + }, + { + "id": "rv", + "머리": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초유" + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 기슭막이 · 관 유입·유출 기슭막이 길이 · 형태 → 높이마다 열", + "일위대가": "UA000004", + "바인딩": { + "종류": "revetment", + "펼침": [ + "form", + "height_m" + ], + "값": "length_m", + "묶음": "rv", + "머리틀": [ + "돌기슭막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형태 미입력", + "높이 미입력" + ], + "이름": { + "돌쌓기(찰)": "찰쌓기", + "돌쌓기(메)": "메쌓기" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_revet_form", + "inlet_revet_height_m" + ], + "값": "inlet_revet_length_m", + "조건": { + "inlet_type": "기슭막이" + } + }, + { + "종류": "pipe", + "펼침": [ + "outlet_revet_form", + "outlet_revet_height_m" + ], + "값": "outlet_revet_length_m", + "조건": { + "outlet_type": "기슭막이" + } + } + ] + }, + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초유" + ] + }, + { + "id": "c4", + "머리": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초무, 소단" + ], + "단위": "m", + "꼴": "수", + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초무, 소단" + ] + }, + { + "id": "c1", + "머리": [ + "돌기슭막이", + "메쌓기", + "1.5m", + "기초유" + ], + "단위": "m", + "꼴": "수", + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.5m", + "기초유" + ] + }, + { + "id": "c5", + "머리": [ + "돌기슭막이", + "찰쌓기", + null, + null + ], + "단위": "m", + "꼴": "수", + "일위대가": "UA000004", + "경로": [ + "돌기슭막이", + "찰쌓기" + ] + }, + { + "id": "pv_b", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "B", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 포장 폭", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "width_m", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "B" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "B" + ] + }, + { + "id": "pv_l", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "L", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 포장 길이", + "일위대가": "UA000007", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "길이", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "L" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "L" + ] + }, + { + "id": "pv_w", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "확폭", + null + ], + "단위": "㎡", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 확폭 면적", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "widening_area_m2", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "확폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "확폭" + ] + }, + { + "id": "pv_a", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "A", + null + ], + "단위": "㎡", + "꼴": "수", + "펼침": true, + "끝수": { + "자리": 2, + "방법": "반올림" + }, + "설명": "계산 — B × L + 확폭", + "일위대가": "UA000006", + "펼침틀": { + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "A" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "A" + ] + }, + { + "id": "pv_jt", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "수축줄눈", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "끝수": { + "자리": 2, + "방법": "반올림" + }, + "설명": "계산 — 내림(L ÷ 줄눈 간격) × B · 줄눈 간격 = 설계값(기본 6m · 다르면 그 줄 식에 박음)", + "펼침틀": { + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "수축줄눈" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "수축줄눈" + ] + }, + { + "id": "c3", + "머리": [ + "콘크리트포장", + "T=(두께마다)", + "새 분류2", + null + ], + "단위": "m", + "꼴": "수", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "새 분류2" + ] + }, + { + "id": "pp_len", + "머리": [ + "관공", + "(관종·관경마다)", + "관매설", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 횡단 관 연장(B06) · 같은 측점에 관이 둘이면 줄을 나눔", + "일위대가": "UA000036", + "일위대가후보": [ + "UA000036", + "UA000027" + ], + "바인딩": { + "종류": "pipe", + "펼침": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "값": "관연장", + "묶음": "pp", + "머리틀": [ + "관공", + "{0} {1}", + "관매설" + ], + "값꼴": [ + "{}", + "Φ{}" + ], + "빈값": [ + "관종 미입력", + "관경 미입력" + ] + }, + "경로": [ + "관공", + "(관종·관경마다)", + "관매설" + ] + }, + { + "id": "pp_cp", + "머리": [ + "관공", + "(관종·관경마다)", + "커플링밴드", + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "계산 — 올림(L ÷ 1본 길이) − 1 · 관마다(줄마다) 계산 뒤 합 · 1본 길이 = 변수(관종마다 · 빈칸이면 계산 안 함)", + "펼침틀": { + "묶음": "pp", + "머리틀": [ + "관공", + "{0} {1}", + "커플링밴드" + ], + "값꼴": [ + "{}", + "Φ{}" + ], + "빈값": [ + "관종 미입력", + "관경 미입력" + ] + }, + "경로": [ + "관공", + "(관종·관경마다)", + "커플링밴드" + ] + }, + { + "id": "pg_in", + "머리": [ + "관보호공", + "(형식마다)", + "유입", + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 관 날개벽 형식 · 유입구가 집수정이면 집수정 형식 · 형식마다 열(유출구는 기슭막이 — 돌기슭막이 열)", + "일위대가": "UA000008", + "일위대가후보": [ + "UA000008", + "UA000025", + "UA000029" + ], + "바인딩": { + "종류": "pipe", + "펼침": [ + "wing_wall_type" + ], + "값": "개소", + "묶음": "pg", + "머리틀": [ + "관보호공", + "{0}", + "유입" + ], + "빈값": [ + "형식 미입력" + ], + "이름": { + "A-TYPE": "날개벽 A형", + "C-TYPE": "날개벽 C형", + "A-TYPE+집수정": "날개벽 A형+집수정" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_basin_form" + ], + "값": "개소", + "조건": { + "inlet_type": "집수정" + } + } + ] + }, + "경로": [ + "관보호공", + "(형식마다)", + "유입" + ] + }, + { + "id": "bx_len", + "머리": [ + "BOX암거", + "(폭×높이마다)", + "연장", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — BOX 연장(계류 방향)", + "일위대가": "UA000014", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "length_m", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "연장" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ] + }, + "경로": [ + "BOX암거", + "(폭×높이마다)", + "연장" + ] + }, + { + "id": "bx_wing", + "머리": [ + "BOX암거", + "(폭×높이마다)", + "날개벽", + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 날개벽 유입·유출 있음 수", + "일위대가": "UA000025", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "날개벽" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ], + "조건": { + "wing_in": "있음" + }, + "더함": [ + { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "조건": { + "wing_out": "있음" + } + } + ] + }, + "경로": [ + "BOX암거", + "(폭×높이마다)", + "날개벽" + ] + }, + { + "id": "fb", + "머리": [ + "세월교", + "(관경 × 련수마다)", + null, + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 세월교 개소", + "바인딩": { + "종류": "ford_bridge", + "펼침": [ + "pipe_diameter_mm", + "pipe_count" + ], + "값": "개소", + "묶음": "fb", + "머리틀": [ + "세월교", + "{0}×{1}", + null + ], + "값꼴": [ + "Φ{}", + "{}련" + ], + "빈값": [ + "관경 미입력", + "련수 미입력" + ] + }, + "경로": [ + "세월교", + "(관경 × 련수마다)" + ] + }, + { + "id": "fp_w", + "머리": [ + "물넘이포장", + "T=(두께마다)", + "폭", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 월류 폭", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "ford_width_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "물넘이포장", + "T=(두께마다)", + "폭" + ] + }, + { + "id": "fp_l", + "머리": [ + "물넘이포장", + "T=(두께마다)", + "길이", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 포장 길이(노폭 방향)", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "length_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "길이" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "경로": [ + "물넘이포장", + "T=(두께마다)", + "길이" + ] + }, + { + "id": "od_len", + "머리": [ + "횡단개거", + "(규격마다)", + "길이", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 개거 연장", + "일위대가": "UA000026", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "length_m", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "길이" + ], + "빈값": [ + "규격 미입력" + ] + }, + "경로": [ + "횡단개거", + "(규격마다)", + "길이" + ] + }, + { + "id": "od_cnt", + "머리": [ + "횡단개거", + "(규격마다)", + "개소", + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 개거 · 노출형 횡단수로 개소", + "일위대가": "UA000003", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "개소", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "개소" + ], + "빈값": [ + "규격 미입력" + ], + "더함": [ + { + "종류": "cross_drain_exposed", + "펼침": [ + "=노출형" + ], + "값": "개소" + } + ] + }, + "경로": [ + "횡단개거", + "(규격마다)", + "개소" + ] + }, + { + "id": "rw", + "머리": [ + "옹벽", + "(형식마다)", + "H=(높이마다)", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 옹벽 길이", + "바인딩": { + "종류": "retaining_wall", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "rw", + "머리틀": [ + "옹벽", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "경로": [ + "옹벽", + "(형식마다)", + "H=(높이마다)" + ] + }, + { + "id": "ms", + "머리": [ + "돌쌓기", + "(찰·메마다)", + "H=(높이마다)", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 돌쌓기 길이", + "바인딩": { + "종류": "masonry_wet", + "펼침": [ + "=찰쌓기", + "height_m" + ], + "값": "길이", + "묶음": "ms", + "머리틀": [ + "돌쌓기", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "", + "높이 미입력" + ], + "더함": [ + { + "종류": "masonry_dry", + "펼침": [ + "=메쌓기", + "height_m" + ], + "값": "길이" + } + ] + }, + "경로": [ + "돌쌓기", + "(찰·메마다)", + "H=(높이마다)" + ] + }, + { + "id": "sg", + "머리": [ + "흙막이", + "(형태마다)", + "H=(높이마다)", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 흙막이 길이 · 일위대가는 큰돌만", + "일위대가": "UA000032", + "바인딩": { + "종류": "soil_guard", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "sg", + "머리틀": [ + "흙막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "경로": [ + "흙막이", + "(형태마다)", + "H=(높이마다)" + ] + }, + { + "id": "bm_len", + "머리": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "길이", + null + ], + "단위": "m", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 큰돌쌓기 길이", + "바인딩": { + "종류": "boulder_masonry", + "펼침": [ + "bond", + "stone_cm", + "height_m" + ], + "값": "길이", + "묶음": "bm", + "머리틀": [ + "큰돌쌓기", + "{0} {1} {2}", + "길이" + ], + "값꼴": [ + "{}", + "{}", + "H={}" + ], + "빈값": [ + "쌓기 방식 미입력", + "돌규격 미입력", + "높이 미입력" + ] + }, + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "길이" + ] + }, + { + "id": "bm_area", + "머리": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "면적", + null + ], + "단위": "㎡", + "꼴": "수", + "펼침": true, + "설명": "계산 — L × H (H = 펼친 높이)", + "일위대가": "UA000031", + "펼침틀": { + "묶음": "bm", + "머리틀": [ + "큰돌쌓기", + "{0} {1} {2}", + "면적" + ], + "값꼴": [ + "{}", + "{}", + "H={}" + ], + "빈값": [ + "쌓기 방식 미입력", + "돌규격 미입력", + "높이 미입력" + ], + "식틀": "IF([bm_len]>0,[bm_len]*{2},\"\")" + }, + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "면적" + ] + }, + { + "id": "ec", + "머리": [ + "골막이", + "(형태마다)", + null, + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 골막이 개소", + "바인딩": { + "종류": "erosion_check", + "펼침": [ + "form" + ], + "값": "개소", + "묶음": "ec", + "머리틀": [ + "골막이", + "{0}", + null + ], + "빈값": [ + "형식 미입력" + ] + }, + "경로": [ + "골막이", + "(형태마다)" + ] + }, + { + "id": "be", + "머리": [ + "소단", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "설명": "설계값 — 소단 길이", + "바인딩": { + "종류": "berm", + "값": "길이" + }, + "경로": [ + "소단" + ] + }, + { + "id": "rf", + "머리": [ + "대피소", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 대피소", + "바인딩": { + "종류": "refuge", + "값": "개소" + }, + "경로": [ + "대피소" + ] + }, + { + "id": "c2", + "머리": [ + "대피소", + "새 분류1", + null, + null + ], + "단위": "개소", + "꼴": "수", + "경로": [ + "대피소", + "새 분류1" + ] + }, + { + "id": "wy", + "머리": [ + "정차·작업장", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 정차·작업장", + "바인딩": { + "종류": "work_yard", + "값": "개소" + }, + "경로": [ + "정차·작업장" + ] + }, + { + "id": "ta", + "머리": [ + "차돌림곳", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 차돌림곳", + "바인딩": { + "종류": "turnaround", + "값": "개소" + }, + "경로": [ + "차돌림곳" + ] + }, + { + "id": "gr_rail", + "머리": [ + "가드레일", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "설명": "설계값 — 가드레일 길이", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "가드레일" + } + }, + "경로": [ + "가드레일" + ] + }, + { + "id": "gr_curb", + "머리": [ + "경계석", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "설명": "설계값 — 경계석 길이", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "경계석" + } + }, + "경로": [ + "경계석" + ] + }, + { + "id": "gr_sign", + "머리": [ + "위험표지", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 위험표지", + "일위대가": "UA000017", + "바인딩": { + "종류": "guardrail", + "값": "개소", + "조건": { + "kind": "위험표지" + } + }, + "경로": [ + "위험표지" + ] + }, + { + "id": "mr", + "머리": [ + "반사경", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 반사경", + "일위대가": "UA000018", + "바인딩": { + "종류": "mirror", + "값": "개소" + }, + "경로": [ + "반사경" + ] + }, + { + "id": "bg", + "머리": [ + "차단기", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 차단기", + "일위대가": "UA000010", + "바인딩": { + "종류": "barrier_gate", + "값": "개소" + }, + "경로": [ + "차단기" + ] + }, + { + "id": "ps", + "머리": [ + "국가지점번호판", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 번호판 · 전구간 줄은 손 입력", + "일위대가": "UA000016", + "바인딩": { + "종류": "position_sign", + "값": "개소" + }, + "경로": [ + "국가지점번호판" + ] + }, + { + "id": "cs", + "머리": [ + "준공표지석", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "설명": "설계값 — 준공표지판", + "일위대가": "UA000002", + "바인딩": { + "종류": "completion_sign", + "값": "개소" + }, + "경로": [ + "준공표지석" + ] + }, + { + "id": "etc", + "머리": [ + "기타", + "(이름마다)", + null, + null + ], + "단위": "개소", + "꼴": "수", + "펼침": true, + "설명": "설계값 — 기타 구조물 개소", + "바인딩": { + "종류": "etc", + "펼침": [ + "name" + ], + "값": "개소", + "묶음": "etc", + "머리틀": [ + "기타", + "{0}", + null + ], + "빈값": [ + "이름 미입력" + ] + }, + "경로": [ + "기타", + "(이름마다)" + ] + }, + { + "id": "h_intake", + "머리": [ + "취수정", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000005", + "경로": [ + "취수정" + ] + }, + { + "id": "h_rip_c", + "머리": [ + "돌붙임", + "찰붙임", + null, + null + ], + "단위": "㎡", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000009", + "경로": [ + "돌붙임", + "찰붙임" + ] + }, + { + "id": "h_rip_m", + "머리": [ + "돌붙임", + "메붙임", + null, + null + ], + "단위": "㎡", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000009", + "경로": [ + "돌붙임", + "메붙임" + ] + }, + { + "id": "h_stone_ch", + "머리": [ + "돌수로", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000011", + "경로": [ + "돌수로" + ] + }, + { + "id": "h_stone_bed", + "머리": [ + "돌조공", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000023", + "경로": [ + "돌조공" + ] + }, + { + "id": "h_pv_rail", + "머리": [ + "포장 난간", + null, + null, + null + ], + "단위": "m", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000012", + "경로": [ + "포장 난간" + ] + }, + { + "id": "h_pg_rail", + "머리": [ + "관보호공 안전난간", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000013", + "경로": [ + "관보호공 안전난간" + ] + }, + { + "id": "h_soil_ditch", + "머리": [ + "수로형 토사개거", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000033", + "경로": [ + "수로형 토사개거" + ] + }, + { + "id": "h_ford_basin", + "머리": [ + "물넘이 집수부", + null, + null, + null + ], + "단위": "개소", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": "UA000034", + "경로": [ + "물넘이 집수부" + ] + }, + { + "id": "h_rockfall", + "머리": [ + "낙석방지책", + null, + null, + null + ], + "단위": "경간", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": null, + "경로": [ + "낙석방지책" + ] + }, + { + "id": "h_break", + "머리": [ + "콘크리트·포장 깨기", + null, + null, + null + ], + "단위": "㎥", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": null, + "경로": [ + "콘크리트·포장 깨기" + ] + }, + { + "id": "h_waste", + "머리": [ + "폐기물처리", + null, + null, + null + ], + "단위": "ton", + "꼴": "수", + "손": true, + "설명": "손 입력", + "일위대가": null, + "경로": [ + "폐기물처리" + ] + }, + { + "id": "memo", + "머리": [ + "비고", + null, + null, + null + ], + "단위": null, + "꼴": "글", + "손": true, + "설명": "손 입력" + } + ], + "줄": [ + { + "id": "all", + "값": { + "sta": "전구간" + }, + "고정": "전구간" + } + ], + "합계줄": [ + { + "id": "sum", + "이름": "합계", + "식": "SUM" + } + ], + "보기": { + "틀고정": { + "열": 2 + }, + "열너비": { + "no": 40, + "sta": 150, + "memo": 120, + "h_soil_ditch": 32 + } + }, + "폴더": [] +} diff --git a/resources/tester/fixtures/m02_master_sample/구조물집계표조합.json b/resources/tester/fixtures/m02_master_sample/구조물집계표조합.json new file mode 100644 index 00000000..e66d5b18 --- /dev/null +++ b/resources/tester/fixtures/m02_master_sample/구조물집계표조합.json @@ -0,0 +1,1555 @@ +{ + "그룹": "구조물집계표조합", + "판": 1, + "비고": "구조물집계표 구조물 열(꼴=수)마다 한 줄 · 서버가 집계표 [저장] 때 맞춤(손으로 고치지 않음) · 키 SS 는 한 번 주면 안 바뀌고 지운 열의 키는 다시 안 씀 · 경로 = 폴더 이름 배열", + "다음": 52, + "줄": [ + { + "키": "SS000001", + "열": "rv", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초유" + ], + "이름": "돌기슭막이 메쌓기 1.2m 기초유", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.2m", + "분류3": "기초유", + "단위": "m", + "일위대가": "UA000004", + "자재조합": null, + "도번": "SD01010100", + "면적번": "SA01010100", + "재료번": "SM01010100", + "바인딩": { + "종류": "revetment", + "펼침": [ + "form", + "height_m" + ], + "값": "length_m", + "묶음": "rv", + "머리틀": [ + "돌기슭막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형태 미입력", + "높이 미입력" + ], + "이름": { + "돌쌓기(찰)": "찰쌓기", + "돌쌓기(메)": "메쌓기" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_revet_form", + "inlet_revet_height_m" + ], + "값": "inlet_revet_length_m", + "조건": { + "inlet_type": "기슭막이" + } + }, + { + "종류": "pipe", + "펼침": [ + "outlet_revet_form", + "outlet_revet_height_m" + ], + "값": "outlet_revet_length_m", + "조건": { + "outlet_type": "기슭막이" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000002", + "열": "c4", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.2m", + "기초무, 소단" + ], + "이름": "돌기슭막이 메쌓기 1.2m 기초무, 소단", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.2m", + "분류3": "기초무, 소단", + "단위": "m", + "일위대가": "UA000004", + "자재조합": null, + "도번": "SD01010101", + "면적번": "SA01010101", + "재료번": "SM01010101", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000003", + "열": "c1", + "경로": [ + "돌기슭막이", + "메쌓기", + "1.5m", + "기초유" + ], + "이름": "돌기슭막이 메쌓기 1.5m 기초유", + "종류": "돌기슭막이", + "분류1": "메쌓기", + "분류2": "1.5m", + "분류3": "기초유", + "단위": "m", + "일위대가": "UA000004", + "자재조합": null, + "도번": "SD01010200", + "면적번": "SA01010200", + "재료번": "SM01010200", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000004", + "열": "c5", + "경로": [ + "돌기슭막이", + "찰쌓기" + ], + "이름": "돌기슭막이 찰쌓기", + "종류": "돌기슭막이", + "분류1": "찰쌓기", + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000004", + "자재조합": null, + "도번": "SD01020000", + "면적번": "SA01020000", + "재료번": "SM01020000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000005", + "열": "pv_b", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "B" + ], + "이름": "콘크리트포장 T=(두께마다) B", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "B", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD02010100", + "면적번": "SA02010100", + "재료번": "SM02010100", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "width_m", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "B" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000006", + "열": "pv_l", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "L" + ], + "이름": "콘크리트포장 T=(두께마다) L", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "L", + "분류3": null, + "단위": "m", + "일위대가": "UA000007", + "자재조합": null, + "도번": "SD02010200", + "면적번": "SA02010200", + "재료번": "SM02010200", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "길이", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "L" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000007", + "열": "pv_w", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "확폭" + ], + "이름": "콘크리트포장 T=(두께마다) 확폭", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "확폭", + "분류3": null, + "단위": "㎡", + "일위대가": null, + "자재조합": null, + "도번": "SD02010300", + "면적번": "SA02010300", + "재료번": "SM02010300", + "바인딩": { + "종류": "pavement_concrete", + "펼침": [ + "thickness_cm" + ], + "값": "widening_area_m2", + "묶음": "pv", + "머리틀": [ + "콘크리트포장", + "{0}", + "확폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000008", + "열": "pv_a", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "A" + ], + "이름": "콘크리트포장 T=(두께마다) A", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "A", + "분류3": null, + "단위": "㎡", + "일위대가": "UA000006", + "자재조합": null, + "도번": "SD02010400", + "면적번": "SA02010400", + "재료번": "SM02010400", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000009", + "열": "pv_jt", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "수축줄눈" + ], + "이름": "콘크리트포장 T=(두께마다) 수축줄눈", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "수축줄눈", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD02010500", + "면적번": "SA02010500", + "재료번": "SM02010500", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000010", + "열": "c3", + "경로": [ + "콘크리트포장", + "T=(두께마다)", + "새 분류2" + ], + "이름": "콘크리트포장 T=(두께마다) 새 분류2", + "종류": "콘크리트포장", + "분류1": "T=(두께마다)", + "분류2": "새 분류2", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD02010600", + "면적번": "SA02010600", + "재료번": "SM02010600", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000011", + "열": "pp_len", + "경로": [ + "관공", + "(관종·관경마다)", + "관매설" + ], + "이름": "관공 (관종·관경마다) 관매설", + "종류": "관공", + "분류1": "(관종·관경마다)", + "분류2": "관매설", + "분류3": null, + "단위": "m", + "일위대가": "UA000036", + "자재조합": null, + "도번": "SD03010100", + "면적번": "SA03010100", + "재료번": "SM03010100", + "바인딩": { + "종류": "pipe", + "펼침": [ + "pipe_kind", + "pipe_diameter_mm" + ], + "값": "관연장", + "묶음": "pp", + "머리틀": [ + "관공", + "{0} {1}", + "관매설" + ], + "값꼴": [ + "{}", + "Φ{}" + ], + "빈값": [ + "관종 미입력", + "관경 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000012", + "열": "pp_cp", + "경로": [ + "관공", + "(관종·관경마다)", + "커플링밴드" + ], + "이름": "관공 (관종·관경마다) 커플링밴드", + "종류": "관공", + "분류1": "(관종·관경마다)", + "분류2": "커플링밴드", + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD03010200", + "면적번": "SA03010200", + "재료번": "SM03010200", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000013", + "열": "pg_in", + "경로": [ + "관보호공", + "(형식마다)", + "유입" + ], + "이름": "관보호공 (형식마다) 유입", + "종류": "관보호공", + "분류1": "(형식마다)", + "분류2": "유입", + "분류3": null, + "단위": "개소", + "일위대가": "UA000008", + "자재조합": null, + "도번": "SD04010100", + "면적번": "SA04010100", + "재료번": "SM04010100", + "바인딩": { + "종류": "pipe", + "펼침": [ + "wing_wall_type" + ], + "값": "개소", + "묶음": "pg", + "머리틀": [ + "관보호공", + "{0}", + "유입" + ], + "빈값": [ + "형식 미입력" + ], + "이름": { + "A-TYPE": "날개벽 A형", + "C-TYPE": "날개벽 C형", + "A-TYPE+집수정": "날개벽 A형+집수정" + }, + "더함": [ + { + "종류": "pipe", + "펼침": [ + "inlet_basin_form" + ], + "값": "개소", + "조건": { + "inlet_type": "집수정" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000014", + "열": "bx_len", + "경로": [ + "BOX암거", + "(폭×높이마다)", + "연장" + ], + "이름": "BOX암거 (폭×높이마다) 연장", + "종류": "BOX암거", + "분류1": "(폭×높이마다)", + "분류2": "연장", + "분류3": null, + "단위": "m", + "일위대가": "UA000014", + "자재조합": null, + "도번": "SD05010100", + "면적번": "SA05010100", + "재료번": "SM05010100", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "length_m", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "연장" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000015", + "열": "bx_wing", + "경로": [ + "BOX암거", + "(폭×높이마다)", + "날개벽" + ], + "이름": "BOX암거 (폭×높이마다) 날개벽", + "종류": "BOX암거", + "분류1": "(폭×높이마다)", + "분류2": "날개벽", + "분류3": null, + "단위": "개소", + "일위대가": "UA000025", + "자재조합": null, + "도번": "SD05010200", + "면적번": "SA05010200", + "재료번": "SM05010200", + "바인딩": { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "묶음": "bx", + "머리틀": [ + "BOX암거", + "{0}×{1}", + "날개벽" + ], + "빈값": [ + "폭 미입력", + "높이 미입력" + ], + "조건": { + "wing_in": "있음" + }, + "더함": [ + { + "종류": "box_culvert", + "펼침": [ + "body_width_m", + "body_height_m" + ], + "값": "개소", + "조건": { + "wing_out": "있음" + } + } + ] + }, + "펼침": true + }, + { + "키": "SS000016", + "열": "fb", + "경로": [ + "세월교", + "(관경 × 련수마다)" + ], + "이름": "세월교 (관경 × 련수마다)", + "종류": "세월교", + "분류1": "(관경 × 련수마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD06010000", + "면적번": "SA06010000", + "재료번": "SM06010000", + "바인딩": { + "종류": "ford_bridge", + "펼침": [ + "pipe_diameter_mm", + "pipe_count" + ], + "값": "개소", + "묶음": "fb", + "머리틀": [ + "세월교", + "{0}×{1}", + null + ], + "값꼴": [ + "Φ{}", + "{}련" + ], + "빈값": [ + "관경 미입력", + "련수 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000017", + "열": "fp_w", + "경로": [ + "물넘이포장", + "T=(두께마다)", + "폭" + ], + "이름": "물넘이포장 T=(두께마다) 폭", + "종류": "물넘이포장", + "분류1": "T=(두께마다)", + "분류2": "폭", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD07010100", + "면적번": "SA07010100", + "재료번": "SM07010100", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "ford_width_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "폭" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000018", + "열": "fp_l", + "경로": [ + "물넘이포장", + "T=(두께마다)", + "길이" + ], + "이름": "물넘이포장 T=(두께마다) 길이", + "종류": "물넘이포장", + "분류1": "T=(두께마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD07010200", + "면적번": "SA07010200", + "재료번": "SM07010200", + "바인딩": { + "종류": "ford_pavement", + "펼침": [ + "thickness_cm" + ], + "값": "length_m", + "묶음": "fp", + "머리틀": [ + "물넘이포장", + "{0}", + "길이" + ], + "값꼴": [ + "T={}cm" + ], + "빈값": [ + "두께 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000019", + "열": "od_len", + "경로": [ + "횡단개거", + "(규격마다)", + "길이" + ], + "이름": "횡단개거 (규격마다) 길이", + "종류": "횡단개거", + "분류1": "(규격마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": "UA000026", + "자재조합": null, + "도번": "SD08010100", + "면적번": "SA08010100", + "재료번": "SM08010100", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "length_m", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "길이" + ], + "빈값": [ + "규격 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000020", + "열": "od_cnt", + "경로": [ + "횡단개거", + "(규격마다)", + "개소" + ], + "이름": "횡단개거 (규격마다) 개소", + "종류": "횡단개거", + "분류1": "(규격마다)", + "분류2": "개소", + "분류3": null, + "단위": "개소", + "일위대가": "UA000003", + "자재조합": null, + "도번": "SD08010200", + "면적번": "SA08010200", + "재료번": "SM08010200", + "바인딩": { + "종류": "open_ditch", + "펼침": [ + "ditch_spec" + ], + "값": "개소", + "묶음": "od", + "머리틀": [ + "횡단개거", + "{0}", + "개소" + ], + "빈값": [ + "규격 미입력" + ], + "더함": [ + { + "종류": "cross_drain_exposed", + "펼침": [ + "=노출형" + ], + "값": "개소" + } + ] + }, + "펼침": true + }, + { + "키": "SS000021", + "열": "rw", + "경로": [ + "옹벽", + "(형식마다)", + "H=(높이마다)" + ], + "이름": "옹벽 (형식마다) H=(높이마다)", + "종류": "옹벽", + "분류1": "(형식마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD09010100", + "면적번": "SA09010100", + "재료번": "SM09010100", + "바인딩": { + "종류": "retaining_wall", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "rw", + "머리틀": [ + "옹벽", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000022", + "열": "ms", + "경로": [ + "돌쌓기", + "(찰·메마다)", + "H=(높이마다)" + ], + "이름": "돌쌓기 (찰·메마다) H=(높이마다)", + "종류": "돌쌓기", + "분류1": "(찰·메마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD10010100", + "면적번": "SA10010100", + "재료번": "SM10010100", + "바인딩": { + "종류": "masonry_wet", + "펼침": [ + "=찰쌓기", + "height_m" + ], + "값": "길이", + "묶음": "ms", + "머리틀": [ + "돌쌓기", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "", + "높이 미입력" + ], + "더함": [ + { + "종류": "masonry_dry", + "펼침": [ + "=메쌓기", + "height_m" + ], + "값": "길이" + } + ] + }, + "펼침": true + }, + { + "키": "SS000023", + "열": "sg", + "경로": [ + "흙막이", + "(형태마다)", + "H=(높이마다)" + ], + "이름": "흙막이 (형태마다) H=(높이마다)", + "종류": "흙막이", + "분류1": "(형태마다)", + "분류2": "H=(높이마다)", + "분류3": null, + "단위": "m", + "일위대가": "UA000032", + "자재조합": null, + "도번": "SD11010100", + "면적번": "SA11010100", + "재료번": "SM11010100", + "바인딩": { + "종류": "soil_guard", + "펼침": [ + "form", + "height_m" + ], + "값": "길이", + "묶음": "sg", + "머리틀": [ + "흙막이", + "{0}", + "{1}" + ], + "값꼴": [ + "{}", + "H={}" + ], + "빈값": [ + "형식 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000024", + "열": "bm_len", + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "길이" + ], + "이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 길이", + "종류": "큰돌쌓기", + "분류1": "(찰·메 · 돌 크기 · 높이마다)", + "분류2": "길이", + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD12010100", + "면적번": "SA12010100", + "재료번": "SM12010100", + "바인딩": { + "종류": "boulder_masonry", + "펼침": [ + "bond", + "stone_cm", + "height_m" + ], + "값": "길이", + "묶음": "bm", + "머리틀": [ + "큰돌쌓기", + "{0} {1} {2}", + "길이" + ], + "값꼴": [ + "{}", + "{}", + "H={}" + ], + "빈값": [ + "쌓기 방식 미입력", + "돌규격 미입력", + "높이 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000025", + "열": "bm_area", + "경로": [ + "큰돌쌓기", + "(찰·메 · 돌 크기 · 높이마다)", + "면적" + ], + "이름": "큰돌쌓기 (찰·메 · 돌 크기 · 높이마다) 면적", + "종류": "큰돌쌓기", + "분류1": "(찰·메 · 돌 크기 · 높이마다)", + "분류2": "면적", + "분류3": null, + "단위": "㎡", + "일위대가": "UA000031", + "자재조합": null, + "도번": "SD12010200", + "면적번": "SA12010200", + "재료번": "SM12010200", + "바인딩": null, + "펼침": true + }, + { + "키": "SS000026", + "열": "ec", + "경로": [ + "골막이", + "(형태마다)" + ], + "이름": "골막이 (형태마다)", + "종류": "골막이", + "분류1": "(형태마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD13010000", + "면적번": "SA13010000", + "재료번": "SM13010000", + "바인딩": { + "종류": "erosion_check", + "펼침": [ + "form" + ], + "값": "개소", + "묶음": "ec", + "머리틀": [ + "골막이", + "{0}", + null + ], + "빈값": [ + "형식 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000027", + "열": "be", + "경로": [ + "소단" + ], + "이름": "소단", + "종류": "소단", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD14000000", + "면적번": "SA14000000", + "재료번": "SM14000000", + "바인딩": { + "종류": "berm", + "값": "길이" + }, + "펼침": null + }, + { + "키": "SS000028", + "열": "rf", + "경로": [ + "대피소" + ], + "이름": "대피소", + "종류": "대피소", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD15000000", + "면적번": "SA15000000", + "재료번": "SM15000000", + "바인딩": { + "종류": "refuge", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000029", + "열": "c2", + "경로": [ + "대피소", + "새 분류1" + ], + "이름": "대피소 새 분류1", + "종류": "대피소", + "분류1": "새 분류1", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD15010000", + "면적번": "SA15010000", + "재료번": "SM15010000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000030", + "열": "wy", + "경로": [ + "정차·작업장" + ], + "이름": "정차·작업장", + "종류": "정차·작업장", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD16000000", + "면적번": "SA16000000", + "재료번": "SM16000000", + "바인딩": { + "종류": "work_yard", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000031", + "열": "ta", + "경로": [ + "차돌림곳" + ], + "이름": "차돌림곳", + "종류": "차돌림곳", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD17000000", + "면적번": "SA17000000", + "재료번": "SM17000000", + "바인딩": { + "종류": "turnaround", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000032", + "열": "gr_rail", + "경로": [ + "가드레일" + ], + "이름": "가드레일", + "종류": "가드레일", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD18000000", + "면적번": "SA18000000", + "재료번": "SM18000000", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "가드레일" + } + }, + "펼침": null + }, + { + "키": "SS000033", + "열": "gr_curb", + "경로": [ + "경계석" + ], + "이름": "경계석", + "종류": "경계석", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": null, + "자재조합": null, + "도번": "SD19000000", + "면적번": "SA19000000", + "재료번": "SM19000000", + "바인딩": { + "종류": "guardrail", + "값": "길이", + "조건": { + "kind": "경계석" + } + }, + "펼침": null + }, + { + "키": "SS000034", + "열": "gr_sign", + "경로": [ + "위험표지" + ], + "이름": "위험표지", + "종류": "위험표지", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000017", + "자재조합": null, + "도번": "SD20000000", + "면적번": "SA20000000", + "재료번": "SM20000000", + "바인딩": { + "종류": "guardrail", + "값": "개소", + "조건": { + "kind": "위험표지" + } + }, + "펼침": null + }, + { + "키": "SS000035", + "열": "mr", + "경로": [ + "반사경" + ], + "이름": "반사경", + "종류": "반사경", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000018", + "자재조합": null, + "도번": "SD21000000", + "면적번": "SA21000000", + "재료번": "SM21000000", + "바인딩": { + "종류": "mirror", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000036", + "열": "bg", + "경로": [ + "차단기" + ], + "이름": "차단기", + "종류": "차단기", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000010", + "자재조합": null, + "도번": "SD22000000", + "면적번": "SA22000000", + "재료번": "SM22000000", + "바인딩": { + "종류": "barrier_gate", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000037", + "열": "ps", + "경로": [ + "국가지점번호판" + ], + "이름": "국가지점번호판", + "종류": "국가지점번호판", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000016", + "자재조합": null, + "도번": "SD23000000", + "면적번": "SA23000000", + "재료번": "SM23000000", + "바인딩": { + "종류": "position_sign", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000038", + "열": "cs", + "경로": [ + "준공표지석" + ], + "이름": "준공표지석", + "종류": "준공표지석", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000002", + "자재조합": null, + "도번": "SD24000000", + "면적번": "SA24000000", + "재료번": "SM24000000", + "바인딩": { + "종류": "completion_sign", + "값": "개소" + }, + "펼침": null + }, + { + "키": "SS000039", + "열": "etc", + "경로": [ + "기타", + "(이름마다)" + ], + "이름": "기타 (이름마다)", + "종류": "기타", + "분류1": "(이름마다)", + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": null, + "자재조합": null, + "도번": "SD25010000", + "면적번": "SA25010000", + "재료번": "SM25010000", + "바인딩": { + "종류": "etc", + "펼침": [ + "name" + ], + "값": "개소", + "묶음": "etc", + "머리틀": [ + "기타", + "{0}", + null + ], + "빈값": [ + "이름 미입력" + ] + }, + "펼침": true + }, + { + "키": "SS000040", + "열": "h_intake", + "경로": [ + "취수정" + ], + "이름": "취수정", + "종류": "취수정", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000005", + "자재조합": null, + "도번": "SD26000000", + "면적번": "SA26000000", + "재료번": "SM26000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000041", + "열": "h_rip_c", + "경로": [ + "돌붙임", + "찰붙임" + ], + "이름": "돌붙임 찰붙임", + "종류": "돌붙임", + "분류1": "찰붙임", + "분류2": null, + "분류3": null, + "단위": "㎡", + "일위대가": "UA000009", + "자재조합": null, + "도번": "SD27010000", + "면적번": "SA27010000", + "재료번": "SM27010000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000042", + "열": "h_rip_m", + "경로": [ + "돌붙임", + "메붙임" + ], + "이름": "돌붙임 메붙임", + "종류": "돌붙임", + "분류1": "메붙임", + "분류2": null, + "분류3": null, + "단위": "㎡", + "일위대가": "UA000009", + "자재조합": null, + "도번": "SD27020000", + "면적번": "SA27020000", + "재료번": "SM27020000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000043", + "열": "h_stone_ch", + "경로": [ + "돌수로" + ], + "이름": "돌수로", + "종류": "돌수로", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000011", + "자재조합": null, + "도번": "SD28000000", + "면적번": "SA28000000", + "재료번": "SM28000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000044", + "열": "h_stone_bed", + "경로": [ + "돌조공" + ], + "이름": "돌조공", + "종류": "돌조공", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000023", + "자재조합": null, + "도번": "SD29000000", + "면적번": "SA29000000", + "재료번": "SM29000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000045", + "열": "h_pv_rail", + "경로": [ + "포장 난간" + ], + "이름": "포장 난간", + "종류": "포장 난간", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "m", + "일위대가": "UA000012", + "자재조합": null, + "도번": "SD30000000", + "면적번": "SA30000000", + "재료번": "SM30000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000046", + "열": "h_pg_rail", + "경로": [ + "관보호공 안전난간" + ], + "이름": "관보호공 안전난간", + "종류": "관보호공 안전난간", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000013", + "자재조합": null, + "도번": "SD31000000", + "면적번": "SA31000000", + "재료번": "SM31000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000047", + "열": "h_soil_ditch", + "경로": [ + "수로형 토사개거" + ], + "이름": "수로형 토사개거", + "종류": "수로형 토사개거", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000033", + "자재조합": null, + "도번": "SD32000000", + "면적번": "SA32000000", + "재료번": "SM32000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000048", + "열": "h_ford_basin", + "경로": [ + "물넘이 집수부" + ], + "이름": "물넘이 집수부", + "종류": "물넘이 집수부", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "개소", + "일위대가": "UA000034", + "자재조합": null, + "도번": "SD33000000", + "면적번": "SA33000000", + "재료번": "SM33000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000049", + "열": "h_rockfall", + "경로": [ + "낙석방지책" + ], + "이름": "낙석방지책", + "종류": "낙석방지책", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "경간", + "일위대가": null, + "자재조합": null, + "도번": "SD34000000", + "면적번": "SA34000000", + "재료번": "SM34000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000050", + "열": "h_break", + "경로": [ + "콘크리트·포장 깨기" + ], + "이름": "콘크리트·포장 깨기", + "종류": "콘크리트·포장 깨기", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "㎥", + "일위대가": null, + "자재조합": null, + "도번": "SD35000000", + "면적번": "SA35000000", + "재료번": "SM35000000", + "바인딩": null, + "펼침": null + }, + { + "키": "SS000051", + "열": "h_waste", + "경로": [ + "폐기물처리" + ], + "이름": "폐기물처리", + "종류": "폐기물처리", + "분류1": null, + "분류2": null, + "분류3": null, + "단위": "ton", + "일위대가": null, + "자재조합": null, + "도번": "SD36000000", + "면적번": "SA36000000", + "재료번": "SM36000000", + "바인딩": null, + "펼침": null + } + ] +} diff --git a/resources/tester/fixtures/m02_master_sample/자재조합.json b/resources/tester/fixtures/m02_master_sample/자재조합.json new file mode 100644 index 00000000..aa84200a --- /dev/null +++ b/resources/tester/fixtures/m02_master_sample/자재조합.json @@ -0,0 +1,7 @@ +{ + "그룹": "자재조합", + "판": "2026", + "비고": ["자재 조합 = 구조물 열 재료산출이 쓰는 자재 목록 묶음 · 수량·계산은 두지 않음(재료산출 몫) · 키 UM 은 「다음」 번호로 매김 · 지운 키는 다시 안 씀"], + "다음": 1, + "줄": [] +} diff --git a/resources/tester/helper_b05_routeedit_initial.cjs b/resources/tester/helper_b05_routeedit_initial.cjs index f74dc95d..2e8aca0c 100644 --- a/resources/tester/helper_b05_routeedit_initial.cjs +++ b/resources/tester/helper_b05_routeedit_initial.cjs @@ -162,8 +162,8 @@ try { const labels = { follow: model.choiceLabel("follow", null), weighted: model.choiceLabel("weighted", { expected: 60, earthwork: 20, grade: 20 }), - metrics: model.metricsLine(plain.metrics), - metricsNone: model.metricsLine(null), + metrics: model.metricsLines(plain.metrics), + metricsNone: model.metricsLines(null).route, criteria: model.criteriaLine(plain.criteria, 0), criteriaFallback: model.criteriaLine(null, 15), violations: model.violationsLine(plain.metrics), @@ -255,6 +255,147 @@ trail.commit(step(1, { logic: "earthwork" })); trail.commit(step(2, null)); const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec); +// ⑧ 비중 고정(계획서 26-2c) — 고정한 값은 안 움직이고 나머지가 합 100 을 맞춘다. +const W = { expected: 70, earthwork: 15, grade: 15 }; +const L = (...keys) => ({ expected: false, earthwork: false, grade: false, ...Object.fromEntries(keys.map((k) => [k, true])) }); +const sweep = []; +for (const locks of [L(), L("expected"), L("earthwork"), L("grade"), L("expected", "grade")]) { + for (const key of ["expected", "earthwork", "grade"]) { + for (let v = -5; v <= 105; v += 5) { + const out = model.moveWeight(W, locks, key, v); + sweep.push({ + sum: out.expected + out.earthwork + out.grade, + lockedSame: Object.keys(locks).every((k) => !locks[k] || out[k] === W[k]), + range: [out.expected, out.earthwork, out.grade].every((n) => Number.isInteger(n) && n >= 0 && n <= 100), + }); + } + } +} +const weightLocks = { + freeThree: model.moveWeight(W, L(), "expected", 40), + lockExpected: model.moveWeight(W, L("expected"), "earthwork", 20), + lockExpectedClamp: model.moveWeight(W, L("expected"), "earthwork", 90), + twoLocked: model.moveWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 50), + movingLocked: model.moveWeight(W, L("expected"), "expected", 10), + derived: [model.derivedWeightKey(L()), model.derivedWeightKey(L("expected")), model.derivedWeightKey(L("expected", "earthwork"))], + canLock: { + none: model.canLockWeight(L(), "grade"), + one: model.canLockWeight(L("expected"), "grade"), + two: model.canLockWeight(L("expected", "earthwork"), "grade"), + unlockOfTwo: model.canLockWeight(L("expected", "earthwork"), "expected"), + }, + input: { + free: model.inputWeight(W, L(), "earthwork", 55), + lockedOk: model.inputWeight(W, L("expected"), "earthwork", 20), + lockedTooBig: model.inputWeight(W, L("expected"), "earthwork", 40), + fraction: model.inputWeight(W, L(), "earthwork", 1.5), + negative: model.inputWeight(W, L(), "earthwork", -1), + over: model.inputWeight(W, L(), "earthwork", 101), + nan: model.inputWeight(W, L(), "earthwork", Number.NaN), + onLocked: model.inputWeight(W, L("earthwork"), "earthwork", 15), + derivedSame: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 10), + derivedOther: model.inputWeight({ expected: 70, earthwork: 20, grade: 10 }, L("expected", "earthwork"), "grade", 30), + }, +}; + +// ⑨ 추천 갈래 여덟 · 새 비교값 · 영선 목표 기울기 · 같은 노선 알림(계획서 23-5b 화면). +const newMetrics = { + side_slope_avg_pct: 65.8, + side_slope_max_pct: 104.1, + hazard_m: 458.4, + stream_crossings: 0, + stream_offset_avg_m: 312.44, + grade_dev_pct: 8.53, + cost_index: 9691.4, + surplus_m3: -578.2, +}; +const withExtras = (extra, metricExtra = {}, logic = "gradeline") => + model.normalizeRouteInitial( + { ...plainResponse, ...extra, metrics: { ...plainResponse.metrics, ...metricExtra } }, + logic, + null, + ); +const gradeline = withExtras( + { criteria: { ...plainResponse.criteria, target_grade_pct: 12 } }, + newMetrics, +); +const sixth = { + labels: Object.fromEntries( + ["follow", "gradeline", "earthwork", "gentle", "safe", "crossings", "cost", "weighted"].map((k) => [ + k, + model.LOGIC_LABEL[k], + ]), + ), + removedLogics: ["short", "balance"].map((k) => k in model.LOGIC_LABEL), + // 옛 저장값(short · balance)은 예상노선 추적으로 읽는다 — 서버 기억 · 캐시 둘 다. + legacyBase: ["short", "balance"].map((k) => model.serverBase({ logic: k })), + legacyCache: undefined, // 아래 ④ 캐시 왕복 뒤에 채운다(저장소를 같이 쓰므로) + surplus: [122.4, -578.2, 0, 0.4].map((v) => model.surplusText(v)), + groups: model.metricsLines(gradeline.metrics), + groupsMinus: model.metricsLines(withExtras({}, { surplus_m3: -578 }).metrics), + groupsOld: model.metricsLines(withExtras({}).metrics), + criteriaTarget: model.criteriaLine(gradeline.criteria, 0, true), + criteriaNoTarget: model.criteriaLine(gradeline.criteria, 0, false), + targetParsed: gradeline.criteria.targetGradePct, + targetOverride: model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }) + .criteriaOverrides, + targetBase: model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + sameTarget: [ + model.sameBase( + model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 10 }), + model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + ), + model.sameBase( + model.normalizeRouteInitial(plainResponse, "gradeline", null, { target_grade_pct: 12 }), + model.serverBase({ logic: "gradeline", criteria: { target_grade_pct: 10 } }), + ), + ], + similarTwo: model.similarLine( + withExtras({ + band_width_m: 13.5, + similar: [ + { logic: "earthwork", max_gap_m: 0.0 }, + { logic: "gentle", max_gap_m: 6.1 }, + ], + }), + ), + similarOne: model.similarLine( + withExtras({ band_width_m: 13.54, similar: [{ logic: "safe", max_gap_m: 6.1 }] }), + ), + similarNoBand: model.similarLine(withExtras({ similar: [{ logic: "cost", max_gap_m: 9.1 }] })), + similarEmpty: model.similarLine(withExtras({ band_width_m: 13.5, similar: [] })), + similarMissing: model.similarLine(withExtras({})), + similarNull: model.similarLine(null), + similarUnknown: withExtras({ + similar: [ + { logic: "nope", max_gap_m: 1 }, + { logic: "short", max_gap_m: 2 }, + { logic: "cost", max_gap_m: "x" }, + ], + }).similar, + bandParsed: withExtras({ band_width_m: 13.5 }).bandWidthM, + hint: model.SIMILAR_HINT, + weightedViolation: model.violationsLine({ + ...plain.metrics, + violations: [ + { + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음(측점 사이 지반이 상한을 넘음)", + }, + ], + }), + weightedViolationReason: model.violationText({ + kind: "grade", + station: "No.9+0.0", + value: 29.1, + limit: 14, + reason: "비중 계산 — 예상노선 몫만큼 제약을 막지 않음", + }), +}; + // ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다. model.writeInitialChoice("p-1", plain); const readBack = model.readInitialChoice("p-1"); @@ -278,6 +419,14 @@ const badLogic = model.readInitialChoice("p-1"); model.writeInitialChoice("p-1", null); const cleared = model.readInitialChoice("p-1"); +// 옛 저장값(short)이 캐시에 있어도 읽지 않는다 — 서버 기억(추적)을 쓴다. +session._map.clear(); +session.setItem( + "aislo:result:routeedit-initial:p-9", + JSON.stringify({ logic: "short", weights: null, snapshot: { planned: [[0, 0], [1, 1]] } }), +); +sixth.legacyCache = model.readInitialChoice("p-9"); + process.stdout.write( JSON.stringify({ moves, @@ -296,5 +445,8 @@ process.stdout.write( initialRoute, mapLines, historyRecs, + sweep, + weightLocks, + sixth, }), ); diff --git a/resources/tester/helper_b05_routeedit_initial_apply.cjs b/resources/tester/helper_b05_routeedit_initial_apply.cjs index c7e59008..a1372bef 100644 --- a/resources/tester/helper_b05_routeedit_initial_apply.cjs +++ b/resources/tester/helper_b05_routeedit_initial_apply.cjs @@ -81,7 +81,7 @@ const out = jobs.map((job) => { }, }, label: model.choiceLabel(choice.logic, choice.weights), - metrics: model.metricsLine(choice.metrics), + metrics: model.metricsLines(choice.metrics), violations: model.violationsLine(choice.metrics), relaxed: model.relaxedLine(choice), criteria: model.criteriaLine(choice.criteria, 0), diff --git a/resources/tester/helper_item_picker_project.cjs b/resources/tester/helper_item_picker_project.cjs index 25343dbf..55ad7a21 100644 --- a/resources/tester/helper_item_picker_project.cjs +++ b/resources/tester/helper_item_picker_project.cjs @@ -75,8 +75,26 @@ globalThis.fetch = async (url, init = {}) => { const [, , folderEnc, partEnc] = m; if (!folderEnc && method === "GET") return reply(200, { 항목: Object.values(state.folders).map((x) => x.info) }); + if (folderEnc === "new" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const item = { + layer: "personal", + owner: "1/3", + key: "SS900001", + name: [...body.경로, body.이름].join(" "), + }; + return reply(200, { + key: "SS900001", + 이름: item.name, + 폴더: body.structure, + summary_item: item, + }); + } if (!folderEnc && method === "POST") { - const folder = `${body.layer}_${(body.owner ?? "none").replace(/\//g, "-")}_${body.key}`; + if (!body.structure) + return reply(400, { detail: "미리 가져올 구조물(structure)이 필요합니다." }); + const folder = body.structure.replace("#", "n"); state.folders[folder] = { info: { 폴더: folder, 원본: body, 마스터바뀜: false, 가져온날: "2026-09-30T01:00:00" }, parts: { 면적: { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }, 입력값: null }, @@ -86,9 +104,24 @@ globalThis.fetch = async (url, init = {}) => { const item = state.folders[decodeURIComponent(folderEnc)]; if (!item) return reply(404, { detail: "복사본이 없음" }); const part = partEnc && decodeURIComponent(partEnc); - if (part === "refresh") { + if (part === "reapply") { item.parts.면적 = { 시트: [{ id: "s", 칸: { A1: { 값: 1 } } }] }; - return reply(200, { 폴더: item.info.폴더, 가져온날: "2026-09-30T02:00:00", 원본판: "새판" }); + return reply(200, { + 폴더: item.info.폴더, + 가져온날: "2026-09-30T02:00:00", + 원본판: "새판", + 덮임: ["면적"], + }); + } + if (part === "save-as" && method === "POST") { + if (state.noCompany) + return reply(409, { detail: "회사부터 등록 — 개인 층은 회사 연결이 필요합니다." }); + const name = body.이름 ?? "복사"; + return reply(200, { + key: "SS900002", + 이름: name, + summary_item: { layer: "personal", owner: "1/3", key: "SS900002", name }, + }); } if (part === "calc") return reply(200, { @@ -122,17 +155,24 @@ const row = (over = {}) => ({ key: "SS000001", 열: "rv", layer: "system", owner async function main() { const out = {}; - out.folders = [ - project.folderOf(row()), - project.folderOf(row({ layer: "company", owner: "3" })), - project.folderOf(row({ layer: "personal", owner: "3/12", key: "SS000002" })), - ]; + // 구조물 id → 복사본 폴더(서버 `folder_name`) · 관 지점 id(서버 `pipe@{round(측점, 3)}` 파이썬 float 글) + out.folders = ["st-1", "#3", "pipe@85.05"].map(project.structureFolder); + out.pipeIds = [85.05, 100, 258.12, 0.1234, 12.0004].map(project.pipeStructureId); const items = project.projectItems("P1"); out.listEmpty = await items.list(); - const info = await items.importItem(row({ layer: "personal", owner: "1/3" })); + const info = await items.importItem(row({ layer: "personal", owner: "1/3" }), "pipe@85.05"); out.info = info; out.importCall = state.calls.find((c) => c.method === "POST"); + out.sameItem = [ + project.sameItem(info, row({ layer: "personal", owner: "1/3" })), + project.sameItem(info, row({ layer: "personal", owner: "1/3", key: "SS000002" })), + project.sameItem(info, row()), + ]; + await items.importItem(row(), "").then( + () => (out.noStructure = "성공"), + (error) => (out.noStructure = error.message), + ); out.list = (await items.list()).map((x) => x.폴더); out.read = await items.read(info.폴더, "면적"); @@ -164,11 +204,25 @@ async function main() { ); state.readOnly = false; - const fresh = await items.refresh(info); - out.refresh = fresh; - out.refreshCall = state.calls - .filter((c) => /refresh$/.test(c.url)) + const again = await items.reapply(info); + out.reapply = again; + out.reapplyCall = state.calls + .filter((c) => /reapply$/.test(c.url)) .map((c) => `${c.method} ${c.url}`); + out.saveAs = await items.saveAs(info.폴더, ["sub5", "경로"], "내 항목"); + out.saveAsCall = state.calls.find((c) => /save-as$/.test(c.url)).body; + out.newItem = await items.newItem("pipe@85.05", ["개인", "폴더"], "새 항목", "m"); + out.newCall = state.calls.find((c) => /items\/new$/.test(c.url)).body; + state.noCompany = true; + await items.saveAs(info.폴더, [], "x").then( + () => (out.noCompanySave = "성공"), + (error) => (out.noCompanySave = error.message), + ); + await items.newItem("pipe@85.05", [], "x", "").then( + () => (out.noCompanyNew = "성공"), + (error) => (out.noCompanyNew = error.message), + ); + state.noCompany = false; out.calc = await items.calc(info.폴더); out.calcMissing = await items.calc("없는폴더"); diff --git a/resources/tester/helper_m02_master_sample.py b/resources/tester/helper_m02_master_sample.py new file mode 100644 index 00000000..a1218519 --- /dev/null +++ b/resources/tester/helper_m02_master_sample.py @@ -0,0 +1,27 @@ +"""시험 안 마스터 견본(PLAN 26-5a) — 초기화 전 마스터 층을 줄여 얼린 것 +(`fixtures/m02_master_sample`). + +집계표(바인딩 열 54) · SS 장부(51 줄) · 자재조합 · 세 파일은 rv · c4 · pp_len · ms 넷(도면 줄임) — +시험은 실제 `resources/master_template` 을 안 읽고 이 견본을 임시 폴더에 복사해 씀. +""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path +from typing import Any + +SAMPLE = Path(__file__).resolve().parent / "fixtures" / "m02_master_sample" +TABLE = SAMPLE / "table" / "구조물집계표.json" + + +def copy_master_sample(target: Path) -> Path: + """견본을 `target` 에 복사(없으면 만듦) · `target` 을 돌려줌.""" + shutil.copytree(SAMPLE, target, dirs_exist_ok=True) + return target + + +def sample_table() -> dict[str, Any]: + """견본 구조물집계표 문서(새 dict).""" + return json.loads(TABLE.read_text(encoding="utf-8")) diff --git a/resources/tester/helper_m02_treepanel.cjs b/resources/tester/helper_m02_treepanel.cjs new file mode 100644 index 00000000..2b7a3be9 --- /dev/null +++ b/resources/tester/helper_m02_treepanel.cjs @@ -0,0 +1,54 @@ +/* M02 항목 나무 자료(`M02_MasterTemplete_TreePanel_Model.ts`) 단위검증 헬퍼 — 트랜스파일해 Node 로 돌리고 JSON 으로 냄. */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const file = path.join(__dirname, "..", "..", "M02_MasterTemplete", "M02_MasterTemplete_TreePanel_Model.ts"); +const js = ts.transpileModule(fs.readFileSync(file, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, +}).outputText; +const box = { exports: {} }; +new Function("exports", "module", "require", js)(box.exports, box, require); +const m = box.exports; + +const item = (열, 경로, key) => ({ key, 열, 경로, 이름: 경로.join(" "), 단위: "m", 일위대가: null }); +const view = { + 판: "v1", + 폴더: [["빈 폴더"], ["돌기슭막이", "빈 아래"]], + items: [ + item("c1", ["돌기슭막이", "메쌓기", "1.2m"], "SS000001"), + item("c2", ["돌기슭막이", "찰쌓기"], "SS000002"), + item("c3", ["다섯", "둘", "셋", "넷", "잎"], "SS000003"), + item("c4", ["최상위 항목"], "SS000004"), + ], +}; +const shape = (n) => ({ + path: n.path, + items: n.items.map((i) => i.열), + folders: n.folders.map(shape), +}); +const all = m.buildFolders(view); +const found = m.buildFolders(view, "찰쌓기"); + +process.stdout.write( + JSON.stringify({ + all: shape(all), + count: m.countItems(all), + found: shape(found), + foundCount: m.countItems(found), + none: shape(m.buildFolders(view, "없는말")), + folders: m.listFolders(all), + ancestors: m.ancestorsOf(view, "c3"), + ancestorsTop: m.ancestorsOf(view, "c4"), + ancestorsNone: m.ancestorsOf(view, "zz"), + under: [ + m.isUnder(["a", "b"], ["a"]), + m.isUnder(["a"], ["a"]), + m.isUnder(["a"], ["a", "b"]), + m.isUnder(["x"], ["a"]), + ], + leaf: m.leafName(view.items[0]), + dir: m.folderOf(view.items[0]), + byKey: m.buildFolders(view, "ss000003").items.length + m.buildFolders(view, "SS000003").folders.length, + }), +); diff --git a/resources/tester/spreadsheet/test_spreadsheet_saved_values.py b/resources/tester/spreadsheet/test_spreadsheet_saved_values.py index 0b0cc9f8..0d165bb7 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_saved_values.py +++ b/resources/tester/spreadsheet/test_spreadsheet_saved_values.py @@ -1,19 +1,17 @@ """저장 뒤 식 · 값 유지 — M02 면적산출 · 재료산출 저장본을 서버 엔진으로 다시 풀면 저장된 `계산값` 과 같음. -읽기만 함(저장본을 고치지 않음). 저장 때 서버가 적은 값과 지금 엔진 풀이가 어긋나면 화면을 다시 열 때 +시험 안 견본 저장본(`fixtures/m02_master_sample` · PLAN 26-5a)을 읽기만 함. +저장 때 서버가 적은 값과 지금 엔진 풀이가 어긋나면 화면을 다시 열 때 값이 달라 보임. """ import json import shutil -from pathlib import Path import pytest from common_util import common_util_spreadsheet as spreadsheet - -ROOT = Path(__file__).resolve().parents[3] -FOLDER = ROOT / "resources" / "master_template" +from resources.tester.helper_m02_master_sample import SAMPLE as FOLDER pytestmark = pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") diff --git a/resources/tester/spreadsheet/test_spreadsheet_size.py b/resources/tester/spreadsheet/test_spreadsheet_size.py index 549afd08..57f4c888 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_size.py +++ b/resources/tester/spreadsheet/test_spreadsheet_size.py @@ -76,4 +76,6 @@ def test_M02_만_늦게_불러옴(): users.append(rel) # 서버 Node 진입만 정적(브라우저 번들 밖) assert offenders == ["common_util/common_util_spreadsheet_node.ts"], offenders - assert users and all(u.startswith("M02_MasterTemplete/") for u in users), users + # M02 화면 + 고르기 모달 편집 화면(카드 [수정] 을 누를 때만 `import(`) + later = ("M02_MasterTemplete/", "ui_template/ui_template_item_picker_edit.ts") + assert users and all(u.startswith(later) for u in users), users diff --git a/resources/tester/spreadsheet/test_spreadsheet_store.py b/resources/tester/spreadsheet/test_spreadsheet_store.py index 44e83528..edac0a18 100644 --- a/resources/tester/spreadsheet/test_spreadsheet_store.py +++ b/resources/tester/spreadsheet/test_spreadsheet_store.py @@ -67,13 +67,14 @@ def test_옛_산출근거를_면적산출로_옮기고_번호_8자리(client, tm def test_새_구조물_도면은_면적_재료_진짜_빈_시트_같은_8자리(client): made1 = client.post("/api/m02/structures/rv").json()["문서"] made2 = client.post("/api/m02/structures/pv_b").json()["문서"] - assert made1["도번"] == "SD01010100" and made2["도번"] == "SD02010100" + # 새 항목 번호 = 자리 뜻 없는 일련번호(PLAN 26-1) + assert made1["도번"] == "SD-000001" and made2["도번"] == "SD-000002" for kind, key, head in (("area", "면적번", "SA"), ("material", "재료번", "SM")): doc = client.get(f"/api/m02/templates/{kind}/pv_b").json()["문서"] - assert doc[key] == f"{head}02010100" + assert doc[key] == f"{head}-000002" assert doc["시트"][0]["칸"] == {} and doc["서식"] == [{}] # 머리 줄 없음 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["pv_b"] == {"도번": "SD02010100", "면적번": "SA02010100", "재료번": "SM02010100"} + assert numbers["pv_b"] == {"도번": "SD-000002", "면적번": "SA-000002", "재료번": "SM-000002"} @pytest.mark.parametrize( @@ -89,7 +90,7 @@ def test_다른_열_내용을_복사해_가져옴_연결도_같이(client, kind, assert r.status_code == 200 ms = client.get(f"/api/m02/templates/{kind}/ms").json()["문서"] assert (ms["시트"][0]["칸"], ms["연결"]) == ({"A1": {"식": "1+1"}}, LINKS) - assert (ms[key], ms["열"]) == (f"{head}05010100", "ms") # 번호 · 열은 받는 쪽 그대로 + assert (ms[key], ms["열"]) == (f"{head}-000010", "ms") # 번호 · 열은 받는 쪽 그대로 assert ms["계산값"] == SERVER # 서버가 다시 풂 assert client.post(f"/api/m02/{kind}/ms/copy", params={"from": "없는열"}).status_code == 404 diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py index fb6068d8..f5b9c444 100644 --- a/resources/tester/test_b05_routeedit_initial.py +++ b/resources/tester/test_b05_routeedit_initial.py @@ -77,11 +77,12 @@ def test_비교값과_기준값_글(results): labels = results["labels"] assert labels["follow"] == "예상노선 추적" assert labels["weighted"] == "비중 계산 60/20/20" - assert labels["metrics"] == ( - "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 절토 1,500㎥ · 성토 900㎥" - " · 기준 초과 2구간 · 이격 3.3m" - ) - assert "로직을 누르면" in labels["metricsNone"] + # 두 묶음 — 노선 · 기울기 / 흙 · 안전 · 비용(서버가 안 준 새 칸은 빠짐) + assert labels["metrics"] == { + "route": "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m", + "work": "절토 1,500㎥ · 성토 900㎥", + } + assert "알약을 누르면" in labels["metricsNone"] # 기준값 확정(2026-09-29) — 「후보 · 사용자 확정 대기」 꼬리표가 더는 안 붙는다. assert labels["criteria"] == ( "공통 제약 종단기울기 ≤ 14.0% · 최소 R 12m · 계류 이격 ≥ 5.0m · 기울기 잴 지반 5m 고름" @@ -213,3 +214,144 @@ def test_노선_추천_다듬기_23_3(): assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위 assert "writing-mode: vertical-lr" in css assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts") + + +def test_비중_고정_합_100_규칙(results): + """고정한 값은 안 움직이고 나머지가 합 100 — 어떤 고정 · 어떤 값으로 옮겨도 정수 0~100 · 합 100.""" + assert all(row["sum"] == 100 and row["lockedSame"] and row["range"] for row in results["sweep"]) + w = results["weightLocks"] + # 안 고정: 지금 비율대로 나눔(기존 규칙) + assert w["freeThree"] == {"expected": 40, "earthwork": 30, "grade": 30} + # 예상 70 고정 → 토공을 끌면 기울기만 따라 바뀜 + assert w["lockExpected"] == {"expected": 70, "earthwork": 20, "grade": 10} + # 남는 몫(30)을 넘게 끌면 잘림 + assert w["lockExpectedClamp"] == {"expected": 70, "earthwork": 30, "grade": 0} + # 둘 고정 → 셋째는 저절로 정해져 안 움직임 + assert w["twoLocked"] == {"expected": 70, "earthwork": 20, "grade": 10} + assert w["movingLocked"] == {"expected": 70, "earthwork": 15, "grade": 15} + assert w["derived"] == [None, None, "grade"] + # 셋 다 고정은 막음 · 푸는 것은 늘 됨 + assert w["canLock"] == {"none": True, "one": True, "two": False, "unlockOfTwo": True} + + +def test_비중_값_칸_직접_입력(results): + """0~100 정수 · 고정 규칙 아래 합 100 을 못 맞추면 받지 않음(빨간 테두리 · 안 보냄).""" + i = results["weightLocks"]["input"] + assert i["free"] == {"ok": True, "weights": {"expected": 37, "earthwork": 55, "grade": 8}} + assert i["lockedOk"] == {"ok": True, "weights": {"expected": 70, "earthwork": 20, "grade": 10}} + for name in ("lockedTooBig", "fraction", "negative", "over", "nan", "onLocked"): + assert i[name]["ok"] is False, name + assert i[name]["weights"] == {"expected": 70, "earthwork": 15, "grade": 15}, name + assert i["derivedSame"]["ok"] is True + assert i["derivedOther"] == { + "ok": False, + "weights": {"expected": 70, "earthwork": 20, "grade": 10}, + } + + +def test_추천_갈래_여덟_이름_알약_순서(results): + six = results["sixth"] + assert six["labels"] == { + "follow": "예상노선 추적", + "gradeline": "영선 노선", + "earthwork": "토공 최소", + "gentle": "기울기 순한", + "safe": "안전 우선", + "crossings": "계류 횡단 최소", + "cost": "공사비 어림 최소", + "weighted": "비중 계산", + } + # 짧은 노선 · 흙 균형은 뺐다 + assert six["removedLogics"] == [False, False] + page = os.path.join(HERE, "..", "..", "B05_Profile", "B05_Profile_UI_RouteEdit_Recommend.ts") + with open(page, encoding="utf-8") as handle: + text = handle.read() + start = text.index("const PILLS") + pills = text[start : text.index("];", start)] + order = [ + pills.index(f'"{name}"') + for name in ( + "follow", + "gradeline", + "earthwork", + "gentle", + "safe", + "crossings", + "cost", + "weighted", + ) + ] + assert order == sorted(order) # 알약 순서 + assert '"short"' not in pills and '"balance"' not in pills + assert ( + "grid-template-columns: repeat(4" + in open( + os.path.join( + HERE, "..", "..", "B05_Profile", "B05_Profile_UI_Style_RouteRecommend.css" + ), + encoding="utf-8", + ).read() + ) # 네 칸 두 줄 + + +def test_옛_저장값_short_balance는_추적으로_읽음(results): + six = results["sixth"] + assert six["legacyBase"] == [ + {"logic": "follow", "weights": None, "criteria": None}, + {"logic": "follow", "weights": None, "criteria": None}, + ] + assert six["legacyCache"] is None # 캐시의 옛 갈래는 버림 → 서버 기억(추적)을 씀 + + +def test_비교값_새_칸_두_묶음(results): + six = results["sixth"] + assert six["surplus"] == ["+122㎥(남음)", "−578㎥(모자람)", "0㎥(균형)", "0㎥(균형)"] + groups = six["groups"] + assert groups["route"] == ( + "연장 1,234.6m · 기울기 최대 12.3% · 평균 6.1% · 목표 기울기 편차 8.5%p" + " · 옆경사 평균 65.8% · 최대 104.1% · 기준 초과 2구간 · 예상노선과 이격 3.3m" + ) + assert groups["work"] == ( + "절토 1,500㎥ · 성토 900㎥ · 남는 흙 −578㎥(모자람) · 위험 구간 통과 458m" + " · 계류 횡단 0회 · 계류 이격 평균 312.4m · 공사비 어림 9,691(어림 · 상대값)" + ) + assert "남는 흙 −578㎥(모자람)" in six["groupsMinus"]["work"] + # 서버가 안 준 칸(옛 답)은 빠진다 + assert "옆경사" not in six["groupsOld"]["route"] and "공사비" not in six["groupsOld"]["work"] + + +def test_영선_목표_기울기(results): + six = results["sixth"] + assert six["targetParsed"] == 12 + assert six["criteriaTarget"].endswith("영선 목표 기울기 12.0% · 기울기 잴 지반 5m 고름") or ( + "영선 목표 기울기 12.0%" in six["criteriaTarget"] + ) + assert "영선 목표" not in six["criteriaNoTarget"] # 영선이 아니면 안 보임 + # 요청에 실은 값은 겹침으로 남고 · 서버 기억과 견줘 다르면 캐시를 버림 + assert six["targetOverride"] == {"target_grade_pct": 10} + assert six["targetBase"]["criteria"] == {"target_grade_pct": 10} + assert six["sameTarget"] == [True, False] + + +def test_같은_노선_알림_줄(results): + six = results["sixth"] + assert six["similarTwo"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — " + "토공 최소 · 기울기 순한과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + assert six["similarOne"] == ( + "이 지형은 제약 안에서 갈 길이 폭 13.5m 한 띠뿐 — 안전 우선과 거의 같은 노선(가장 먼 거리 6.1m)" + ) + # 띠 폭이 없어도 줄은 선다 · 받침 있는 이름은 「과」 + assert "폭" not in six["similarNoBand"] and "공사비 어림 최소와 거의" in six["similarNoBand"] + # 없으면 줄 숨김 — 빈 목록 · 칸 없음 · 선택 없음 + assert six["similarEmpty"] == "" and six["similarMissing"] == "" and six["similarNull"] == "" + assert six["similarUnknown"] == [] # 이름 · 거리를 모르는 항목(옛 short 포함)은 버림 + assert six["bandParsed"] == 13.5 + assert "제약 값을 바꾸면" in six["hint"] + + +def test_비중_계산_넘은_구간_사유_그대로(results): + six = results["sixth"] + assert six["weightedViolation"] == "제약 못 지킨 구간 1곳: No.9+0.0 29.1%(비중 계산)" + assert six["weightedViolationReason"] == "No.9+0.0 29.1%(비중 계산)" diff --git a/resources/tester/test_b05_routeedit_initial_roundtrip.py b/resources/tester/test_b05_routeedit_initial_roundtrip.py index a3e9d833..f30b98b3 100644 --- a/resources/tester/test_b05_routeedit_initial_roundtrip.py +++ b/resources/tester/test_b05_routeedit_initial_roundtrip.py @@ -43,6 +43,11 @@ LOGICS: list[tuple[str, dict[str, float] | None]] = [ ("follow", None), ("earthwork", None), ("gentle", None), + # PLAN 23-5 — 새 갈래 넷(영선 · 안전 우선 · 계류 횡단 최소 · 공사비 어림 최소)도 같은 왕복. + ("gradeline", None), + ("safe", None), + ("crossings", None), + ("cost", None), ("weighted", {"expected": 50, "earthwork": 25, "grade": 25}), ] @@ -199,7 +204,18 @@ def test_비교값이_응답에_다_있음(rounds): assert isinstance(metrics["over_limit"]["total"], int) assert isinstance(metrics["offset_avg_m"], (int, float)) # 화면 한 줄에 빈 칸(—)이 없다. - assert "—" not in item["applied"]["metrics"], item["applied"]["metrics"] + shown = item["applied"]["metrics"] + assert "—" not in shown["route"] + shown["work"], shown + # 새 비교값 칸도 화면 글에 다 있음(계획서 23-5) + for label in ( + "옆경사 평균", + "목표 기울기 편차", + "위험 구간 통과", + "계류 횡단", + "계류 이격 평균", + "공사비 어림", + ): + assert label in shown["route"] + shown["work"], (item["logic"], label) def test_제약_못_지킨_구간이_목록과_화면_줄에(rounds): diff --git a/resources/tester/test_item_copy_complete.py b/resources/tester/test_item_copy_complete.py index 1546f92c..9d1265f5 100644 --- a/resources/tester/test_item_copy_complete.py +++ b/resources/tester/test_item_copy_complete.py @@ -1,4 +1,6 @@ -"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 임시 저장소 · 임시 마스터 사본). +"""프로젝트 집계 항목 복사본 완전체 (PLAN 25-2b · 25-2e · 26-2b · 임시 저장소 · 임시 마스터 사본). + +복사본은 구조물마다(`templates/structures/{구조물 id}/` · 관은 `pipe@측점`). - 지정 → [저장] → 폴더에 새 파일(입력연결 · 입력값 · 고른자재 · 단가) · manifest 가져온날 · 원본판 - 단가 묶음이 닫혀 있음 — 조합 37개 · 로직 170개를 마스터와 복사본으로 돌려 같은 값 @@ -28,11 +30,13 @@ from M02_MasterTemplete import M02_Item_Snapshot as snap from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layer_routes from M02_MasterTemplete import M02_Project_Items_Router as items_router from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE -REAL = config_system.PROJECT_ROOT / "resources/master_template" +REAL = SAMPLE # 시험 안 견본(PLAN 26-5a) — 실제 마스터 안 읽음 MASTER = config_system.PROJECT_ROOT / "resources/master_data" PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} -FOLDER = "system_none_SS000011" +FOLDER = "pipe@40.0" # 관 지점 복사본 폴더 = 구조물 id(26-2b) +PIPE_PATH = ["관공", "(관종·관경마다)", "관매설"] LABOR_PRICE = "LB000002" # 보통인부 — 되메우기 · 파형강관 부설 호표가 씀 @@ -82,7 +86,7 @@ def _read(path: Path) -> Any: def _auto_values(root: Path) -> dict[str, Any]: """담은 로직마다 자동값을 확인한 값으로 — 사용자가 다 채운 것과 같게.""" - labor = _read(root / "templates/items" / FOLDER / "일위대가.json") + labor = _read(root / "templates/structures" / FOLDER / "일위대가.json") _, whole = m01.loaded() out: dict[str, Any] = {} for row in labor["로직"]: @@ -104,7 +108,7 @@ def _fill_values(root: Path) -> dict[str, Any]: def test_지정하면_새_파일까지_복사(world): root = world["root"] assert _save(root, [_pipe()])["만듦"] == [FOLDER] - folder = root / "templates/items" / FOLDER + folder = root / "templates/structures" / FOLDER assert _read(folder / "입력연결.json") == {"판": None, "줄": []} # 층 연결이 아직 없음 assert _read(folder / "입력값.json") == {} and _read(folder / "고른자재.json") == {} price = _read(folder / "단가.json") @@ -118,13 +122,14 @@ def test_지정하면_새_파일까지_복사(world): { "폴더": FOLDER, "이름": "관공 관매설", - "원본": PIPE, + "원본": {**PIPE, "경로": PIPE_PATH}, "원본판": manifest["원본판"], "가져온날": manifest["가져온날"], "마스터바뀜": False, "원본없음": False, "구조물": ["pipe@40.0"], "고침": False, + "값있음": False, } ] @@ -221,7 +226,7 @@ def test_마스터_단가를_바꿔도_계산_그대로_새로_가져오면_바 def test_새로_가져오면_없는_키는_버림(world): root = world["root"] _save(root, [_pipe()]) - folder = root / "templates/items" / FOLDER + folder = root / "templates/structures" / FOLDER (folder / "입력값.json").write_text( json.dumps( { @@ -267,31 +272,37 @@ def test_쓰기_뒤_다시_읽기_같음_판_다르면_409(world): copies.read_file(root, "../../etc", "입력값") -def test_고친_복사본은_구조물에서_빠져도_안_지움(world): +def test_고친_복사본은_구조물에서_빠지면_지난에_남김(world): root = world["root"] _save(root, [_pipe()]) - assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움(지금 규칙) + assert _save(root, [_pipe(None)])["지움"] == [FOLDER] # 안 고쳤으면 지움 + assert not (root / "templates/structures/_지난").exists() _save(root, [_pipe()]) current = copies.read_file(root, FOLDER, "입력값") copies.write_file(root, FOLDER, "입력값", current["판"], {"GF000176": {"관경": {"값": 600}}}) - assert _save(root, [_pipe(None)])["지움"] == [] - assert copies.list_items(root)[0]["구조물"] == [] and copies.list_items(root)[0]["고침"] + assert _save(root, [_pipe(None)])["지움"] == [FOLDER] + assert copies.list_items(root) == [] + past = list((root / "templates/structures/_지난").iterdir()) + assert len(past) == 1 and past[0].name.startswith("pipe@40.0_") + assert _read(past[0] / "입력값.json") == {"GF000176": {"관경": {"값": 600}}} def test_미리_가져오기(world): root = world["root"] _save(root, []) - made = copies.prefetch(root, PIPE) + made = copies.prefetch(root, FOLDER, PIPE) assert made["폴더"] == FOLDER and made["만듦"] is True - assert copies.prefetch(root, PIPE)["만듦"] is False # 있으면 그대로 - assert copies.list_items(root)[0]["구조물"] == [] - assert _save(root, [])["지움"] == [FOLDER] # 안 고쳤으면 [저장] 때 지움 + assert copies.prefetch(root, FOLDER, PIPE)["만듦"] is False # 같은 항목이면 그대로 + assert copies.list_items(root)[0]["구조물"] == [FOLDER] + assert _save(root, [])["지움"] == [FOLDER] # [저장] 뒤에도 안 쓰면 지움 + with pytest.raises(ValueError): + copies.prefetch(root, "../x", PIPE) def test_옛_복사본은_읽을_때_채움(world): root = world["root"] _save(root, [_pipe()]) - folder = root / "templates/items" / FOLDER + folder = root / "templates/structures" / FOLDER for name in ("입력연결.json", "입력값.json", "고른자재.json", "단가.json"): (folder / name).unlink() manifest = _read(folder / "manifest.json") @@ -331,12 +342,201 @@ def test_길_권한_남의_프로젝트는_403(world, monkeypatch): assert stale.status_code == 409 and stale.json()["detail"]["판"] == put.json()["판"] assert client.get(f"{base}/{FOLDER}/모름").status_code == 422 assert client.post(f"{base}/{FOLDER}/calc").json()["멈춤"] - assert client.post(base, json={"summary_item": {"layer": "x"}}).status_code == 400 - made = client.post(base, json={"layer": "system", "owner": None, "key": "SS000022"}).json() - assert made["폴더"] == "system_none_SS000022" and made["만듦"] is True + wall = {"structure": "st9", "layer": "system", "owner": None} + assert client.post(base, json={**wall, "summary_item": {"layer": "x"}}).status_code == 400 + assert client.post(base, json={"layer": "system", "key": "SS000022"}).status_code == 400 + made = client.post(base, json={**wall, "key": "SS000022"}).json() + assert made["폴더"] == "st9" and made["만듦"] is True names = {i["폴더"]: i["이름"] for i in client.get(base).json()["항목"]} - assert names["system_none_SS000022"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름 - assert client.post(base, json={"layer": "system", "key": "SS999999"}).status_code == 404 + assert names["st9"] == "돌쌓기 (찰·메마다) H=(높이마다)" # 층 SS 줄 이름(경로 이음) + assert client.post(base, json={**wall, "key": "SS999999"}).status_code == 404 + assert client.post(f"{base}/{FOLDER}/reapply").json()["덮임"] == [] session["company_id"] = 4 assert client.get(base).status_code == 403 assert client.post(f"{base}/{FOLDER}/refresh").status_code == 403 + + +# ── 구조물마다 복사본 (26-2b) ───────────────────────── + + +MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} + + +def _wall(sid: str, summary: dict | None = MS) -> dict[str, Any]: + return { + "structure_id": sid, + "type_id": "masonry_dry", + "placement": "interval", + "chainage_m": 85, + "start_m": 80, + "end_m": 90, + "options": {"height_m": 2.0, "length_m": 10}, + "summary_item": summary, + } + + +def _save_all(root: Path, walls: list[dict], pipes: list[dict]) -> dict[str, list[str]]: + (root / "B05_Profile/route/structures.json").write_text( + json.dumps({"revision": 1, "structures": walls}, ensure_ascii=False), encoding="utf-8" + ) + (root / "B04_PreProcess/drainage/edits/pipe_points.json").write_text( + json.dumps({"points": pipes}, ensure_ascii=False), encoding="utf-8" + ) + return snap.sync_snapshots(root) + + +def _memo(root: Path, folder: str, text: str) -> None: + """면적 시트에 표식 한 칸 — 한쪽만 고쳤는지 보려는 것(계산값 풀이는 건너뜀).""" + area = copies.read_file(root, folder, "면적") + body = {**area["내용"], "메모": text} + copies.write_file(root, folder, "면적", area["판"], body, recalc=lambda doc: doc) + + +def test_같은_항목_구조물_둘은_따로(world): + root = world["root"] + second = {**_pipe(), "chainage_m": 60.0} + made = _save_all(root, [_wall("a1"), _wall("a2")], [_pipe(), second])["만듦"] + assert made == ["a1", "a2", FOLDER, "pipe@60.0"] + _memo(root, "a1", "a1 만 고침") + assert copies.read_file(root, "a1", "면적")["내용"]["메모"] == "a1 만 고침" + assert "메모" not in copies.read_file(root, "a2", "면적")["내용"] # 다른 쪽 그대로 + _fill_values(root) # pipe@40.0 에만 값 + assert copies.calc(root, FOLDER)["멈춤"] == [] and copies.calc(root, FOLDER)["계"] > 0 + assert len(copies.calc(root, "pipe@60.0")["멈춤"]) == 3 # 계산도 각자 + flags = {i["폴더"]: i["고침"] for i in copies.list_items(root)} + assert flags == {"a1": True, "a2": False, FOLDER: True, "pipe@60.0": False} + + +def test_원본_항목을_지워도_복사본_계산_그대로(world): + root, system = world["root"], world["system"] + _save(root, [_pipe()]) + _fill_values(root) + before = copies.calc(root, FOLDER) + combo = _read(system / layers.COMBO_FILE) + combo["줄"] = [r for r in combo["줄"] if r["키"] != PIPE["key"]] + (system / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8") + assert copies.calc(root, FOLDER) == before + row = copies.list_items(root)[0] + assert row["원본없음"] is True and row["마스터바뀜"] is False + assert _save(root, [_pipe()])["만듦"] == [] # 다시 저장해도 복사본 그대로 + with pytest.raises(LookupError): + copies.reapply(root, FOLDER) # 원본이 없으면 재적용 못 함(404) + + +def test_재적용은_시트_고침을_덮고_알림_입력값은_살림(world): + root = world["root"] + _save(root, [_pipe()]) + values = _fill_values(root) + _memo(root, FOLDER, "x") + assert copies.reapply(root, FOLDER)["덮임"] == ["면적"] + assert "메모" not in copies.read_file(root, FOLDER, "면적")["내용"] + assert copies.read_file(root, FOLDER, "입력값")["내용"] == values + assert copies.reapply(root, FOLDER)["덮임"] == [] + + +def test_개인에_저장하면_개인_층에_항목(world): + root = world["root"] + _save(root, [_pipe()]) + rows = [{"로직": "GF000176", "입력": "관경", "자리": "선택"}] + current = copies.read_file(root, FOLDER, "입력연결") + copies.write_file(root, FOLDER, "입력연결", current["판"], {"판": 3, "줄": rows}) + made = copies.save_as(root, FOLDER, 3, 12, ["관공", "우리 현장"], "파형강관 매설") + assert made["summary_item"] == { + "layer": "personal", + "owner": "3/12", + "key": made["key"], + "name": "관공 우리 현장 파형강관 매설", + } + folder = layers.personal_dir(3, 12) + row = next(r for r in _read(folder / layers.COMBO_FILE)["줄"] if r["키"] == made["key"]) + assert [row["종류"], row["분류1"], row["분류2"]] == ["관공", "우리 현장", "파형강관 매설"] + assert row["일위대가"] == "UA000036" and row["단위"] == "m" + copy = root / "templates/structures" / FOLDER + drawing = _read(folder / "structure" / f"{made['열']}.json") + assert drawing["도면"] == _read(copy / "도면.json")["도면"] + assert _read(folder / "inputs" / f"{made['열']}.json") == {"판": 1, "줄": rows} + assert not list(folder.rglob("단가.json")) # 단가 묶음은 안 가져감 + # 개인 층 항목을 다른 구조물에 지정하면 그 내용으로 복사본이 섬 + assert _save_all(root, [_wall("b1", made["summary_item"])], [])["만듦"] == ["b1"] + + +def test_프로젝트에서_새_항목(world): + root = world["root"] + _save_all(root, [_wall("c1")], []) + made = copies.new_item(root, "c1", 3, 12, ["신규 공사"], "돌붙임", "㎡") + assert made["폴더"] == "c1" and made["summary_item"]["layer"] == "personal" + origin = _read(root / "templates/structures/c1/manifest.json")["원본"] + assert origin["key"] == made["key"] and origin["경로"] == ["신규 공사", "돌붙임"] + assert copies.list_items(root)[0]["이름"] == "신규 공사 돌붙임" + # 화면이 새 항목을 지정해 [저장] 하면 그대로 남음 + assert _save_all(root, [_wall("c1", made["summary_item"])], [])["만듦"] == [] + with pytest.raises(ValueError): + copies.new_item(root, "c2", 3, 12, [], "", None) # 경로 · 이름 빈 값 + + +def test_옛_항목_폴더는_쓰는_구조물마다_옮겨_받음(world): + root = world["root"] + _save_all(root, [_wall("d1"), _wall("d2")], []) + base = root / "templates/structures" + old = root / "templates/items/system_none_SS000022" + old.parent.mkdir(parents=True) + shutil.move(str(base / "d1"), str(old)) + shutil.rmtree(base / "d2") + manifest = _read(old / "manifest.json") + manifest.update({"구조물": ["d1", "d2"], "고침": True}) + (old / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False), "utf-8") + (old / "입력값.json").write_text(json.dumps({"GF": {"a": {"값": 1}}}), "utf-8") + assert [i["폴더"] for i in copies.list_items(root)] == ["d1", "d2"] + assert not (root / "templates/items").exists() + for sid in ("d1", "d2"): + assert _read(base / sid / "입력값.json") == {"GF": {"a": {"값": 1}}} # 고친 값 유지 + assert _read(base / sid / "manifest.json")["구조물"] == [sid] + assert _save_all(root, [_wall("d1"), _wall("d2")], [])["만듦"] == [] + + +def test_지정을_바꾸면_고친_옛_복사본은_지난에(world): + root = world["root"] + _save_all(root, [_wall("e1")], []) + _memo(root, "e1", "y") + rv = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이"} + result = _save_all(root, [_wall("e1", rv)], []) + assert result["만듦"] == ["e1"] and result["지움"] == ["e1"] + assert _read(root / "templates/structures/e1/manifest.json")["원본"]["key"] == "SS000001" + past = list((root / "templates/structures/_지난").iterdir()) + assert len(past) == 1 and _read(past[0] / "면적.json")["메모"] == "y" + + +def test_새_항목_길_회사_없으면_409(world, monkeypatch): + root = world["root"] + _save_all(root, [_wall("f1")], []) + + async def fake_row(project_id: str) -> dict[str, Any]: + return {"company_id": None, "storage_path": str(root)} + + monkeypatch.setattr(layer_routes, "_project_row", fake_row) + monkeypatch.setattr(layer_routes, "resolve_stored_project_path", lambda path: path) + app = FastAPI() + app.include_router(items_router.router) + session = {"role": "SYSTEM_ADMIN", "company_id": None, "user_id": 12} + app.dependency_overrides[verify_session] = lambda: session + client = TestClient(app) + body = {"structure": "f1", "경로": ["새"], "이름": "것"} + assert client.post("/api/projects/p1/items/new", json=body).status_code == 409 + save_as = client.post("/api/projects/p1/items/f1/save-as", json={"경로": ["a"], "이름": "b"}) + assert save_as.status_code == 409 + + +def test_재적용_뒤_고침은_꺼지고_값있음만_남음(world): + root = world["root"] + _save(root, [_pipe()]) + _memo(root, FOLDER, "x") + assert copies.list_items(root)[0]["고침"] is True + got = copies.reapply(root, FOLDER) + assert got["값있음"] is False + row = copies.list_items(root)[0] + assert row["고침"] is False and row["값있음"] is False + _fill_values(root) # 입력값 씀 — 고침 켜짐 + assert copies.list_items(root)[0]["고침"] is True + assert copies.reapply(root, FOLDER)["값있음"] is True + row = copies.list_items(root)[0] + assert row["고침"] is False and row["값있음"] is True diff --git a/resources/tester/test_item_picker_detail.py b/resources/tester/test_item_picker_detail.py index 63956516..c261a3e4 100644 --- a/resources/tester/test_item_picker_detail.py +++ b/resources/tester/test_item_picker_detail.py @@ -36,26 +36,28 @@ def out(): return json.loads(proc.stdout) -def test_폴더_이름은_서버_snapshot_folder_name_과_같은_글(out): - rows = [ - {"layer": "system", "owner": None, "key": "SS000001"}, - {"layer": "company", "owner": "3", "key": "SS000001"}, - {"layer": "personal", "owner": "3/12", "key": "SS000002"}, +def test_구조물_id_는_복사본_폴더와_관_지점_id_로(out): + assert out["folders"] == ["st-1", "n3", "pipe@85.05"] # `#차례` = `n차례` (서버 folder_name) + # 서버 `pipe@{round(측점, 3)}` — 파이썬 float 글(정수도 `100.0`) + assert out["pipeIds"] == [ + f"pipe@{round(x, 3)}" for x in (85.05, 100.0, 258.12, 0.1234, 12.0004) ] - assert out["folders"] == [snapshot_folder_name(row) for row in rows] - assert out["info"]["폴더"] == snapshot_folder_name( - {"layer": "personal", "owner": "1/3", "key": "SS000001"} - ) -def test_가져오기는_POST_원본_참조_목록은_항목(out): +def test_가져오기는_구조물마다_POST_본문에_structure(out): assert out["listEmpty"] == [] assert out["importCall"] == { "method": "POST", "url": "/api/projects/P1/items", - "body": {"layer": "personal", "owner": "1/3", "key": "SS000001"}, + "body": {"structure": "pipe@85.05", "layer": "personal", "owner": "1/3", "key": "SS000001"}, } - assert out["list"] == [out["info"]["폴더"]] + assert out["info"]["폴더"] == "pipe@85.05" # 폴더 = 구조물 id + assert out["list"] == ["pipe@85.05"] + assert out["noStructure"] == "미리 가져올 구조물(structure)이 필요합니다." # 서버 까닭 그대로 + + +def test_같은_구조물이_다른_항목으로_바뀌었는지_원본_참조로_가림(out): + assert out["sameItem"] == [True, False, False] def test_읽기는_내용만_돌려주고_없으면_null(out): @@ -76,14 +78,31 @@ def test_판이_달라지면_409_까닭_권한_없으면_서버_까닭_그대로 assert out["readOnly"] == "프로젝트 편집 권한이 없음" -def test_새로_가져오기와_계산_한_줄(out): - assert out["refreshCall"][0].startswith("POST /api/projects/P1/items/") - assert out["refreshCall"][0].endswith("/refresh") - assert out["refresh"]["마스터바뀜"] is False and out["refresh"]["원본판"] == "새판" +def test_재적용은_덮임을_돌려주고_계산_한_줄(out): + assert out["reapplyCall"][0] == "POST /api/projects/P1/items/pipe%4085.05/reapply" + assert out["reapply"]["covered"] == ["면적"] # 덮이는 것을 알림으로 + assert out["reapply"]["info"]["마스터바뀜"] is False assert out["calc"] == {"total": 12345.6, "gaps": 2, "stuck": 1} assert out["calcMissing"] is None # 없는 복사본 = 계산 줄 없음 +def test_개인에_저장과_새_항목_본문_summary_item(out): + assert out["saveAsCall"] == {"to": "personal", "경로": ["sub5", "경로"], "이름": "내 항목"} + assert out["saveAs"]["summary_item"]["key"] == "SS900002" + assert out["newCall"] == { + "structure": "pipe@85.05", + "경로": ["개인", "폴더"], + "이름": "새 항목", + "단위": "m", + } + assert out["newItem"]["summary_item"]["name"] == "개인 폴더 새 항목" + + +def test_회사_없음_409_는_서버_까닭_그대로(out): + for key in ("noCompanySave", "noCompanyNew"): + assert out[key].startswith("회사부터 등록"), key + + def test_입력_목록은_한_번만_받고_로직마다_갈라_둠(out): assert out["inputsHits"] == 1 assert out["inputsLogics"] == [["GA1", ["지역", "소량감률"]], ["GA2", ["지역"]]] @@ -164,3 +183,20 @@ def test_소스가_700줄을_넘지_않음(): M02 / "M02_MasterTemplete_Inputs_Model.ts", ): assert len(path.read_text(encoding="utf-8").splitlines()) <= 700, path.name + + +def test_나무_오른쪽_클릭은_메뉴_항목을_밖에서_주는_자리(): + tree = (UI / "ui_template_tree.ts").read_text(encoding="utf-8") + assert "menu?: () => TreeMenuItem[]" in tree and "export function openTreeMenu(" in tree + assert '"contextmenu"' in tree + picker = (UI / "ui_template_item_picker.ts").read_text(encoding="utf-8") + # 새 항목 메뉴는 개인 그룹 · 폴더에만(마스터 · 회사는 메뉴 없음) · 프로젝트 안에서만 + assert 'folder.layer !== "personal" || !opts.projectId' in picker + assert "menu: menuOf(node)" in picker + + +def test_B05_구조물_폼이_구조물_id_를_모달에_넘김(): + panel = (ROOT / "B05_Profile" / "B05_Profile_UI_Structures_Panel.ts").read_text( + encoding="utf-8" + ) + assert "pipeStructureId(selectedPipeChainage)" in panel and "editingId ??" in panel diff --git a/resources/tester/test_m02_combo_screen.py b/resources/tester/test_m02_combo_screen.py new file mode 100644 index 00000000..b9ee90e0 --- /dev/null +++ b/resources/tester/test_m02_combo_screen.py @@ -0,0 +1,107 @@ +"""관리 화면 「구조물 조합」(PLAN 26-7a~d) — 이름 · 숨김(비고 · 전구간 · 합계) · 참조표 잠금 · +나무 자리. + +① `comboView` · `comboMerge`(Node) — 보일 문서에서 비고 열 · 줄 · 합계 줄을 빼고, [저장] 때 + 저장본 것을 다시 끼움(열 차례 = 저장본 · 보인 고침은 받음). +② 표 부품 `lockHead` — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(단위 · 조회 후 선택은 + 그대로). +③ 좌측 패널에 나무 없음 · 페이지가 나무를 편집 칸 왼쪽(`aside`)에 · 「구조물 조합」일 때만 보임. +""" + +from __future__ import annotations + +import json +import re +import shutil +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +M02 = ROOT / "M02_MasterTemplete" +SHEET = ROOT / "ui_template" / "sheet" + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음") +def test_숨긴_것은_저장_때_저장본에서_다시_끼움(): + src = _text(M02 / "M02_MasterTemplete_Structure.ts") + part = re.search( + r"^const HIDDEN_COLUMNS[\s\S]*?^export function comboMerge[\s\S]*?^}", src, re.M + ) + assert part, "comboView · comboMerge 를 못 찾음" + script = ( + "interface StructureColumn { id: string; 머리: (string | null)[]; 꼴?: string | null;" + " 단위?: string }\n" + + part.group(0).replace("export ", "") + + """ +const stored = { + 열: [{ id: "no", 머리: ["NO"] }, { id: "a", 머리: ["가"], 꼴: "수" }, + { id: "b", 머리: ["나"], 꼴: "수" }, { id: "memo", 머리: ["비고"] }], + 줄: [{ id: "all", 값: { memo: "x" } }], 합계줄: [{ id: "sum" }], 변수: { k: 1 }, +}; +const view = comboView(stored); +const edited = structuredClone(view); +edited.열[2].단위 = "m"; +const saved = comboMerge(stored, edited); +console.log(JSON.stringify([ + view.열.map((c) => c.id), view.줄, view.합계줄, stored.열.length, + saved.열.map((c) => c.id), saved.열[2].단위, saved.줄, saved.합계줄, saved.변수, +])); +""" + ) + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", script], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert result.returncode == 0, result.stderr + assert json.loads(result.stdout) == [ + ["no", "a", "b"], + [], + [], + 4, # 저장본은 안 바뀜 + ["no", "a", "b", "memo"], + "m", + [{"id": "all", "값": {"memo": "x"}}], + [{"id": "sum"}], + {"k": 1}, + ] + + +def test_참조표는_열_머리를_못_고침(): + sheet = _text(SHEET / "ui_template_sheet.ts") + assert 'const editCols = opts.mode === "master" && !ro && !opts.lockHead;' in sheet + assert "if (editCols) toolbar.append(colDelete);" in sheet + assert "if (editCols) syncLevels(!!col);" in sheet + assert "if (th && editCols) editHead(th);" in sheet + assert "else if (layer && editCols) editLayer(layer);" in sheet + assert 'ro || opts.lockHead ? { hidden: "" }' in sheet + render = _text(SHEET / "ui_template_sheet_render.ts") + assert 'const heads = mode === "master" && !state.headLocked;' in render + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert "struct ? comboView(doc) : doc" in main + assert "getDoc: () => comboMerge(loaded, shown.getDoc())" in main + + +def test_나무는_편집_칸_왼쪽_좌측_패널엔_없음(): + side = _text(M02 / "M02_MasterTemplete_UI_Side.ts") + assert "createTreePanel" not in side and "M02_TreeTitle" not in side + assert 'info.이름 === STRUCTURE_TABLE ? L("M02_KindTable")' in side # 좌측 줄 이름 + page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") + assert "createTreePanel({" in page and "aside," in page and "tree.select(column)" in page + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert 'at.kind === "table" && at.name === STRUCTURE_TABLE && at.layer !== "project"' in main + assert 'isCombo(next) ? L("M02_KindTable")' in main # 상세 제목 + assert main.count("showAside();") == 3 # 열 때 · 양식 밖 화면 · 처음 + locale = _text(ROOT / "ui_template" / "ui_template_locale_m2.ts") + assert 'M02_KindTable: ["구조물 조합"' in locale + assert ".m02-shell__tree[hidden]" in _text(M02 / "M02_MasterTemplete_UI_Style.css") + tree = _text(M02 / "M02_MasterTemplete_TreePanel.ts") + assert ( + 'error instanceof StaleError && error.message === "HTTP 409"' in tree + ) # 같은 이름 = 까닭 글 diff --git a/resources/tester/test_m02_empty_master.py b/resources/tester/test_m02_empty_master.py new file mode 100644 index 00000000..3b28042c --- /dev/null +++ b/resources/tester/test_m02_empty_master.py @@ -0,0 +1,126 @@ +"""빈 마스터(PLAN 26-5a) — 마스터 구조물 항목을 다 지운 뒤에도 M02 가 돎 · 프로젝트 복사본은 그대로. + +마스터 초기화와 같은 줄기(나무 지우기 `tree.apply` → 집계표 저장 `store.write`)를 시험 안 견본 +사본(`fixtures/m02_master_sample`)에 돌림 — 실제 마스터는 안 읽음. +① 지운 뒤 — 항목 0 · 남는 열 no · sta · 비고 · 빈 나무 · 장부 `다음` 유지 · 지운 번호 기록 · + M02 첫 화면 길 · 새 폴더 · 새 항목(다음 키) · 지우기 · 고르기 모달의 마스터 0. +② 프로젝트 복사본 — 마스터가 비어도 계산 그대로 · 목록은 원본없음. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_Item_Copy as copies +from M02_MasterTemplete import M02_MasterTemplete_Router as main_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import copy_master_sample +from resources.tester.test_item_copy_complete import ( # noqa: F401 — 복사본 world 고정물 + FOLDER, + _fill_values, + _pipe, + _save, + master_copy, + world, +) + + +def empty_master(folder: Path) -> list[str]: + """마스터 초기화 — 맨 위 폴더마다 나무 지우기 · 지운 열 너비 뺌 · 집계표 저장 한 번(세 파일 · + 장부가 따라옴).""" + with store.at(folder): + got = store.read("table", store.STRUCTURE_TABLE) + doc = tree.normalize_table(got["문서"]) + tops = {tree.path_of(c)[0] for c in doc["열"] if tree.is_item(c)} + gone: list[str] = [] + for top in sorted(tops | {f[0] for f in tree.folders_of(doc)}): + doc, note = tree.apply(doc, {"op": "delete", "경로": [top]}) + gone += note["지운항목"] + widths = doc.get("보기", {}).get("열너비") or {} + keep = {c["id"] for c in doc["열"]} + if widths: + doc["보기"]["열너비"] = {k: v for k, v in widths.items() if k in keep} + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + return gone + + +@pytest.fixture() +def empty(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = copy_master_sample(tmp_path / "master") + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(tmp_path / "storage")) + gone = empty_master(system) + session = {"user_id": 42, "company_id": 7, "role": "SYSTEM_ADMIN", "is_master": True} + app = FastAPI() + for module in (main_router, items_router, layers_router): + app.include_router(module.router) + app.dependency_overrides[verify_session] = lambda: session + return {"client": TestClient(app), "system": system, "gone": gone} + + +def test_지운_뒤_항목_0_틀은_남음(empty): + system, client = empty["system"], empty["client"] + assert len(empty["gone"]) == 51 + table = client.get("/api/m02/templates/table/구조물집계표").json()["문서"] + assert [c["id"] for c in table["열"]] == ["no", "sta", "memo"] and table["폴더"] == [] + assert table["변수"] and table["쪽줄"] == 50 and table["합계줄"] and table["바인딩규칙"] + assert set(table["보기"]["열너비"]) <= {"no", "sta", "memo"} + combos = client.get("/api/m02/structure-combos").json() + assert combos["줄"] == [] and combos["다음"] == 52 + assert len(combos["지운번호"]) == 4 # 견본에 세 파일이 있던 넷(rv · c4 · pp_len · ms) + for kind in ("structure", "area", "material", "inputs"): + assert not list((system / kind).glob("*.json")), kind + assert (system / "자재조합.json").is_file() + + +def test_빈_마스터에서_첫_화면_나무_새_항목_지우기(empty): + client = empty["client"] + # M02 첫 화면 길 — 양식 목록 · 집계표 · 번호 · 장부 · 나무 · 옵션 + listed = client.get("/api/m02/layers/system/templates").json()["양식"] + assert "구조물집계표" in [r["이름"] for r in listed] + assert "구조물집계표" in [r["이름"] for r in client.get("/api/m02/templates").json()] + assert client.get("/api/m02/structures/numbers").json() == {} + assert client.get("/api/m02/structure-options").status_code == 200 + assert client.get("/api/m02/items").json() == [] + groups = client.get("/api/m02/items?all=1").json() # 고르기 모달 — 마스터 0 + assert groups[0]["layer"] == "system" and groups[0]["items"] == [] + url = "/api/m02/layers/system/tree" + now = client.get(url).json() + assert now["폴더"] == [] and now["items"] == [] + + now = client.post(url, json={"판": now["판"], "op": "add-folder", "경로": [], "이름": "옹벽"}) + assert now.status_code == 200, now.text + now = now.json() + assert now["폴더"] == [["옹벽"]] + body = {"판": now["판"], "op": "add-item", "경로": ["옹벽"], "이름": "L형", "단위": "m"} + now = client.post(url, json=body).json() + [item] = now["items"] + assert item["key"] == "SS000052" and item["경로"] == ["옹벽", "L형"] # 지운 키 다시 안 씀 + assert item["도번"] == "SD-000001" # 옛 8자리 지운 번호와 안 겹치는 새 모양 + now = client.post(url, json={"판": now["판"], "op": "delete", "키": item["key"]}).json() + assert now["items"] == [] and now["폴더"] == [["옹벽"]] + now = client.post(url, json={"판": now["판"], "op": "delete", "경로": ["옹벽"]}).json() + assert now["폴더"] == [] and client.get("/api/m02/structure-combos").json()["다음"] == 53 + + +def test_마스터를_비워도_프로젝트_복사본_계산_그대로(world): # noqa: F811 — 고정물 + root, system = world["root"], world["system"] + _save(root, [_pipe()]) + _fill_values(root) + before = copies.calc(root, FOLDER) + assert len(empty_master(system)) == 51 + assert copies.calc(root, FOLDER) == before + row = copies.list_items(root)[0] + assert row["원본없음"] is True and row["마스터바뀜"] is False diff --git a/resources/tester/test_m02_item_inputs.py b/resources/tester/test_m02_item_inputs.py index a72a8557..9e71112f 100644 --- a/resources/tester/test_m02_item_inputs.py +++ b/resources/tester/test_m02_item_inputs.py @@ -1,8 +1,8 @@ """M02 항목 선택 입력(PLAN 25-1b · 25-2a) — 입력 목록 · 항목 연결 파일 · 알약 · 권한. -① 입력 목록은 실제 마스터(시스템 층 · 읽기만)로 — 일위대가 있는 항목 셋에서 로직 수 · 입력 수 · - 자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄 `입력` · `master_auto`)과 같은지 · 로직마다 갈라 - 합치지 않는지 · 일위대가 없는 항목은 빈 목록. +① 입력 목록은 시험 안 마스터 견본(`fixtures/m02_master_sample` 사본 · PLAN 26-5a)으로 — + 일위대가 있는 항목 셋에서 로직 수 · 입력 수 · 자동값이 M01 조합 미리 보기가 쓰는 것(로직 줄 + `입력` · `master_auto`)과 같은지 · 로직마다 갈라 합치지 않는지 · 일위대가 없는 항목은 빈 목록. ② 연결 파일 쓰기는 임시 storage(회사 · 개인 층) + 견본 시스템 집계표로만 — 판 409 · 없는 키 422 · 사라짐 표시 · 알약 · 층 권한 · 열 지우기 · 항목 복사 · 분류 더하기 복사가 파일을 같이 다룸. """ @@ -26,6 +26,7 @@ from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import copy_master_sample from resources.tester.test_m02_structure import write_sample_table TABLE = "table/구조물집계표" @@ -56,10 +57,11 @@ def _m01_view(ua: str) -> list[tuple[str, list[str], dict]]: return out -# ── ① 입력 목록 — 실제 시스템 층(읽기만) ───────────────────── +# ── ① 입력 목록 — 마스터 견본 사본 ───────────────────── -def test_입력_목록은_M01_조합_미리_보기와_같음(): +def test_입력_목록은_M01_조합_미리_보기와_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(store, "FOLDER", copy_master_sample(tmp_path / "master")) client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) rows = [r for r in json.loads((store.FOLDER / store.COMBO_FILE).read_bytes())["줄"]] with_ua = [r for r in rows if r.get("일위대가")] diff --git a/resources/tester/test_m02_item_keys.py b/resources/tester/test_m02_item_keys.py new file mode 100644 index 00000000..f6c6a82e --- /dev/null +++ b/resources/tester/test_m02_item_keys.py @@ -0,0 +1,213 @@ +"""M02 항목 키 체계 · 품번 겹침 점검(PLAN 26-4a). + +키 — 마스터 `SS` + 6자리 · 회사 `SC{회사}-` + 6자리 · 개인 `SP{사용자}-` + 6자리 · +한 번 주면 안 바뀜 · 지운 키 다시 안 씀 · 층 사이 복사는 새 키 + 본뜬 곳 · +회사 · 개인 층의 옛 SS 키는 새 모양으로 옮기고 옛 키로도 찾음. +번호 — 새 일련 `SD-000001`(붙임표 + 6자리)이 옛 8자리(`SD01020300`) · 12장 6자리(`SD010101`)와 +모양이 달라 겹치지 않음 · 99 넘는 형제 · 깊이 6 에서도 넘침 없음 · 층마다 따로 셈. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +import pytest + +from common_util.common_util_summary_item import parse_summary_item +from config import config_system +from M02_MasterTemplete import M02_Item_Source as source +from M02_MasterTemplete import M02_MasterTemplete_Book as book +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.test_m02_structure import write_sample_table +from resources.tester.test_m02_tree import Tree, _app + +ROOT = Path(__file__).resolve().parents[2] +NEW = re.compile(r"^S[DAM]-\d{6}$") + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "ADMIN", "is_master": False} + return {"client": _app(session), "session": session, "storage": storage, "system": system} + + +def _numbers(root: Path, cid: str) -> set[str]: + return { + json.loads((root / kind / f"{cid}.json").read_bytes())[key] + for kind, key, _, _ in store.PARTS + } + + +# ── 키 ───────────────────────────────────────────── + + +def test_층마다_키_모양_안_바뀜_다시_안_씀(world): + client = world["client"] + company = Tree(client, "company") + company.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") + company.do(op="add-item", 경로=["돌쌓기"], 이름="메쌓기") + keys = [r["key"] for r in company.now["items"]] + assert keys == ["SC7-000001", "SC7-000002"] + # 이름 바꾸기 · 옮기기 · 표 저장에도 키 그대로(폴더 뜻 없음) + company.do(op="rename", 키="SC7-000001", 이름="찰쌓기2") + company.do(op="move", 키="SC7-000002", 새경로=["다른폴더"]) + assert {r["key"]: r["경로"] for r in company.now["items"]} == { + "SC7-000001": ["돌쌓기", "찰쌓기2"], + "SC7-000002": ["다른폴더", "메쌓기"], + } + # 지운 키는 다시 안 씀 + company.do(op="delete", 키="SC7-000002") + company.do(op="add-item", 경로=[], 이름="새것") + assert [r["key"] for r in company.now["items"]] == ["SC7-000001", "SC7-000003"] + # 개인 층 = SP{사용자} · 층 사이 복사 = 새 키 + 본뜬 곳 + personal = Tree(client, "personal") + personal.do(op="add-item", 경로=[], 이름="내것") + assert [r["key"] for r in personal.now["items"]] == ["SP42-000001"] + body = {"from_layer": "company", "from_owner": "7", "key": "SC7-000001"} + made = client.post("/api/m02/layers/personal/items/copy", json=body).json() + assert made["key"] == "SP42-000002" + table = client.get("/api/m02/layers/personal/templates/table/구조물집계표").json()["문서"] + col = next(c for c in table["열"] if c["id"] == made["열"]) + assert col["본뜬"] == {"layer": "company", "owner": "7", "key": "SC7-000001"} + # 마스터는 SS 그대로 + assert Tree(client, "system").now["items"][0]["key"].startswith("SS") + + +def _legacy_personal(storage: Path, rows: list[tuple[str, str]], after: int) -> Path: + """옛 개인 층 — 집계표 열 + SS 키 장부(26-4a 앞 모양).""" + folder = storage / "7" / "42" / "templates" + for kind in store.KINDS: + (folder / kind).mkdir(parents=True, exist_ok=True) + cols = [{"id": cid, "머리": [cid, None, None, None], "꼴": "수"} for _, cid in rows] + table = {"층": ["종류", "분류1", "분류2", "분류3"], "열": cols, "줄": [], "합계줄": []} + (folder / "table" / "구조물집계표.json").write_text(json.dumps(table), encoding="utf-8") + book_rows = [{"키": key, "열": cid, "종류": cid} for key, cid in rows] + doc = {"그룹": "구조물집계표조합", "다음": after, "줄": book_rows} + (folder / store.COMBO_FILE).write_text(json.dumps(doc), encoding="utf-8") + return folder + + +def test_옛_SS_키는_새_모양으로_옮기고_옛_키로도_찾음(world): + folder = _legacy_personal(world["storage"], [("SS000001", "a"), ("SS000003", "b")], 5) + raw = json.loads((folder / store.COMBO_FILE).read_bytes()) # 복사본 manifest 가 든 옛 줄 + client = world["client"] + rows = client.get("/api/m02/items?layer=personal").json() + assert [r["key"] for r in rows] == ["SP42-000001", "SP42-000003"] # 숫자 여섯 그대로 + migrated = json.loads((folder / store.COMBO_FILE).read_bytes()) + assert [r["옛키"] for r in migrated["줄"]] == [["SS000001"], ["SS000003"]] + assert migrated["다음"] == 5 + # 옛 키로도 찾음 — 입력 목록 · 나무 고침 · 복사본 원본 찾기(M02_Item_Source) + assert client.get("/api/m02/layers/personal/items/SS000003/inputs").status_code == 200 + tree_ = Tree(client, "personal") + tree_.do(op="rename", 키="SS000001", 이름="a2") + assert tree_.item(["a2"])["key"] == "SP42-000001" + assert source.row_of(migrated, "SS000003")["키"] == "SP42-000003" + assert source.row_of(raw, "SS000003")["키"] == "SS000003" # 옮기기 전 파일도 그대로 읽힘 + # 새 항목 = 다음 번호(옛 번호 · 지운 번호 안 씀) + tree_.do(op="add-item", 경로=[], 이름="c") + assert tree_.item(["c"])["key"] == "SP42-000005" + + +def test_옛_숫자가_이미_있으면_다음_번호로(world): + folder = _legacy_personal(world["storage"], [("SP42-000001", "a"), ("SS000001", "b")], 2) + rows = world["client"].get("/api/m02/items?layer=personal").json() + assert [r["key"] for r in rows] == ["SP42-000001", "SP42-000002"] + assert json.loads((folder / store.COMBO_FILE).read_bytes())["줄"][1]["옛키"] == ["SS000001"] + + +def test_집계_항목_참조_키_모양(): + ok = [ + {"layer": "system", "owner": None, "key": "SS000004"}, + {"layer": "company", "owner": "7", "key": "SC7-000001"}, + {"layer": "personal", "owner": "7/42", "key": "SP42-000001"}, + {"layer": "company", "owner": "7", "key": "SS000001"}, # 옛 키 + {"layer": "personal", "owner": "7/42", "key": "SS000002"}, # 옛 키 + ] + for item in ok: + assert parse_summary_item({**item, "name": "x"})["key"] == item["key"] + bad = [ + {"layer": "company", "owner": "8", "key": "SC7-000001"}, # 다른 회사 + {"layer": "personal", "owner": "7/43", "key": "SP42-000001"}, # 다른 사람 + {"layer": "personal", "owner": "7/42", "key": "SC7-000001"}, # 층 머리 틀림 + {"layer": "system", "owner": None, "key": "SC7-000001"}, + {"layer": "company", "owner": "7", "key": "SC7-00001"}, # 자리수 + {"layer": "company", "owner": "7", "key": "SD-000001"}, # 번호는 키가 아님 + ] + for item in bad: + with pytest.raises(ValueError): + parse_summary_item({**item, "name": "x"}) + + +# ── 번호 ──────────────────────────────────────────── + + +def test_새_번호는_옛_번호와_모양이_달라_안_겹침(world): + """마스터 옛 8자리는 그대로 · 새 열은 `-` + 6자리 · 99 넘는 형제 · 깊이 6 에서도 한 모양.""" + system = world["system"] + # 견본 열 둘을 옛 8자리(제목행 자리)로 — 마스터 51 항목이 지금 이 모양 + for cid, code in (("rv", "01010100"), ("c1", "01010200")): + for kind, key, head, _ in store.PARTS: + path = system / kind / f"{cid}.json" + doc = json.loads(path.read_bytes()) + path.write_text(json.dumps({**doc, key: f"{head}{code}"}), encoding="utf-8") + old = {cid: _numbers(system, cid) for cid in store._column_ids()} + assert old["rv"] == {"SD01010100", "SA01010100", "SM01010100"} + first = max(int(n[3:]) for nums in old.values() for n in nums if NEW.match(n)) + 1 + with store.at(system): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + deep = ["a", "b", "c", "d", "e", "f"] + doc["열"] += [ + {"id": f"x{i}", "머리": [*deep[:5], f"항목{i}"], "꼴": "수"} for i in range(120) + ] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + new = [_numbers(system, f"x{i}") for i in range(120)] + tails = [{n[2:] for n in nums} for nums in new] + assert all(len(t) == 1 for t in tails) # 세 파일 같은 뒷자리 + assert [t.pop() for t in tails] == [f"-{i:06d}" for i in range(first, first + 120)] + assert all(NEW.match(n) for nums in new for n in nums) + assert not any(store._OLD_CODE.match(n) for nums in new for n in nums) # 12장 6자리로 안 읽힘 + assert {cid: _numbers(system, cid) for cid in old} == old # 옛 번호 그대로 + # 지운 번호 다시 안 줌 · 셈은 새 모양끼리 + with store.at(system): + got = store.read("table", store.STRUCTURE_TABLE) + doc = got["문서"] + doc["열"] = [c for c in doc["열"] if c["id"] != "x119"] + store.write("table", store.STRUCTURE_TABLE, got["판"], doc) + got = store.read("table", store.STRUCTURE_TABLE) + got["문서"]["열"].append({"id": "y", "머리": ["y"], "꼴": "수"}) + store.write("table", store.STRUCTURE_TABLE, got["판"], got["문서"]) + last = f"-{first + 120:06d}" # 지운 x119 번호(first + 119) 다음 + assert _numbers(system, "y") == {f"SD{last}", f"SA{last}", f"SM{last}"} + + +def test_층마다_번호는_따로_파일은_열_id_로(world): + client = world["client"] + Tree(client, "company").do(op="add-item", 경로=[], 이름="회사것") + Tree(client, "personal").do(op="add-item", 경로=[], 이름="내것") + storage = world["storage"] + company = next((storage / "7" / "templates" / "structure").glob("*.json")) + mine = next((storage / "7" / "42" / "templates" / "structure").glob("*.json")) + assert json.loads(company.read_bytes())["도번"] == json.loads(mine.read_bytes())["도번"] + assert company.parent != mine.parent # 같은 번호여도 층 폴더가 달라 안 겹침 + + +def test_키_머리는_M01_키대장과_안_겹침(): + ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8"))["다음"] + for head in ("SS", "SC", "SP", "SD", "SA", "SM", "UM"): + assert head not in ledger, head + assert "UA" in ledger # 일위대가 조합은 M01 키대장 몫(M02 는 읽기만) + assert book.HEAD == "SS" diff --git a/resources/tester/test_m02_layer_screen.py b/resources/tester/test_m02_layer_screen.py index febeedee..0da4aa4d 100644 --- a/resources/tester/test_m02_layer_screen.py +++ b/resources/tester/test_m02_layer_screen.py @@ -104,7 +104,7 @@ def test_층_길_접두어와_고정_풀림(): def test_읽기만_회사용_개인용으로_복사_막대(): page = _text(M02 / "M02_MasterTemplete_UI_Page.ts") assert 'layer === "company" && !isAdmin ? buildReadOnlyBar(user)' in page - assert "onColumn: (column) => readOnlyBar?.setColumn(column)" in page + assert "readOnlyBar?.setColumn(column)" in page # 열 고름 → 막대 · 항목 나무가 함께 받음 assert "access.needCompany" in page and "buildNeedCompany()" in page assert "titleOf(layer)" in page layer = _text(LAYER) @@ -147,5 +147,6 @@ def test_읽기만_표는_단추_고치기_모두_막음(): assert sheet.count("if (ro") + sheet.count("!ro") + sheet.count("ro ||") >= 7 # 열 0 = 「열 더하기」 하나만 켜짐 · 첫 열 assert "b.disabled = !hasCol && !empty" in sheet and "addFirstColumn(" in sheet - assert re.search(r"readOnly,\s*onChange", _text(M02 / "M02_MasterTemplete_UI_Main.ts")) + main = _text(M02 / "M02_MasterTemplete_UI_Main.ts") + assert re.search(r"readOnly,\s*lockHead: !!struct,\s*onChange", main) assert ".ui-sheet__toolbar[hidden]" in _text(SHEET / "ui_template_sheet.css") diff --git a/resources/tester/test_m02_layers_store.py b/resources/tester/test_m02_layers_store.py index 2afbc3c2..eebcdaa1 100644 --- a/resources/tester/test_m02_layers_store.py +++ b/resources/tester/test_m02_layers_store.py @@ -108,7 +108,7 @@ def test_회사_층_첫_진입_뼈대와_쓰기_권한(world): assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 403 _add_column(_as(world, "ADMIN"), "company", COL) items = _as(world).get("/api/m02/items?layer=company").json() # 같은 회사 일반 사용자 - assert [(r["key"], r["열"]) for r in items] == [("SS000001", "cw")] + assert [(r["key"], r["열"]) for r in items] == [("SC7-000001", "cw")] # 회사 키(26-4a) assert items[0]["도번"].startswith("SD") and (root / "structure/cw.json").is_file() assert client.get("/api/m02/items?layer=company&owner=9").status_code == 403 # 남의 회사 # 일위대가(UA) 갈라 내기는 M01 시스템 마스터에 쓰므로 회사 관리자도 못 함 @@ -163,7 +163,7 @@ def test_회사_항목을_개인으로_복사(world): got = user.post("/api/m02/layers/personal/items/copy", json=body) assert got.status_code == 200, got.text made = got.json() - assert made["열"] == "cw" and made["key"] == "SS000001" # 응답 = 새 SS 키(바로 고름) + assert made["열"] == "cw" and made["key"] == "SP42-000001" # 응답 = 개인 층 새 키(바로 고름) mine_rows = user.get("/api/m02/items?layer=personal").json() assert mine_rows[0]["key"] == made["key"] and mine_rows[0]["names"]["일위대가"] == ua_name mine = "/api/m02/layers/personal" @@ -175,7 +175,7 @@ def test_회사_항목을_개인으로_복사(world): assert mat["이름"] == "회사 자재" and mat["담은자재"][0]["코드"] == _material()["코드"] # 또 복사하면 새 열 id · 새 SS 키 · 자재 조합 이름 (2) again = user.post("/api/m02/layers/personal/items/copy", json=body).json() - assert (again["열"], again["key"]) == ("cw_2", "SS000002") + assert (again["열"], again["key"]) == ("cw_2", "SP42-000002") names = [r["이름"] for r in user.get(f"{mine}/material-combos").json()["줄"]] assert names == ["회사 자재", "회사 자재 (2)"] # 마스터 항목도 개인으로 · 없는 키 404 diff --git a/resources/tester/test_m02_structure.py b/resources/tester/test_m02_structure.py index 7989dcbd..c532c67c 100644 --- a/resources/tester/test_m02_structure.py +++ b/resources/tester/test_m02_structure.py @@ -1,5 +1,6 @@ """M02 구조물 도면(structure) — 새로 · 도번 SD+8자리 · 409 · 저장 · 지운 뒤 번호 · -제목행 번호 매김(분류3 까지) · 집계표 저장 때 만들기/지우기 · 복사 가져오기 · 프로젝트 복사. +번호 매김(새 항목 = 자리 뜻 없는 일련번호 · PLAN 26-1) · 집계표 저장 때 만들기/지우기 · +복사 가져오기 · 프로젝트 복사. 면적산출(area) · 재료산출(material)은 따로 파일 — `resources/tester/spreadsheet/test_spreadsheet_store.py`.""" @@ -75,7 +76,7 @@ def test_새로_도번_자동_409_없는_열(client): "구조물도면", "structure", "pp_len", - "SD01010100", + "SD-000001", ) # 도각 없는 전용 화면 — 그린 만큼이 설계 영역 · 기본 도면층 하나(없으면 CAD 가 못 엶) assert doc["도면"]["format"] == 6 @@ -86,13 +87,13 @@ def test_새로_도번_자동_409_없는_열(client): for kind in ("area", "material"): assert client.get(f"/api/m02/templates/{kind}/pp_len").json()["문서"]["종류"] == "통합문서" - assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100" + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD-000002" assert client.post("/api/m02/structures/pp_len").status_code == 409 assert client.post("/api/m02/structures/없는열").status_code == 404 assert client.post("/api/m02/structures/no").status_code == 404 # 글 열은 구조물 아님 assert client.get("/api/m02/structures/numbers").json() == { - "pp_len": _num("01010100"), - "rv": _num("02010100"), + "pp_len": _num("-000001"), + "rv": _num("-000002"), } rows = client.get("/api/m02/templates").json() assert {(r["종류"], r["이름"]) for r in rows if r["종류"] == "structure"} == { @@ -118,7 +119,7 @@ def test_저장_뼈대_거름(client): "/api/m02/templates/structure/pp_len", json={"판": now, "문서": {**doc, "도번": "SD9"}} ) got = client.get("/api/m02/templates/structure/pp_len").json()["문서"] - assert got["도번"] == "SD01010100" # 번호는 서버 것 — 화면 값을 안 받음 + assert got["도번"] == "SD-000001" # 번호는 서버 것 — 화면 값을 안 받음 def test_지워도_번호를_당기지_않음_짝은_같은_번호(client): @@ -126,15 +127,15 @@ def test_지워도_번호를_당기지_않음_짝은_같은_번호(client): client.post(f"/api/m02/structures/{column}") assert client.delete("/api/m02/templates/structure/rv").status_code == 200 numbers = client.get("/api/m02/structures/numbers").json() - assert numbers["rv"] == {**_num("02010100"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠 - assert numbers["ms"] == _num("03010100") # 다른 열 번호는 그대로 - # 같은 종류(관공) 새 열 = 그 무리 다음 번호 · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음 - assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD01010200" - assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD02010100" + assert numbers["rv"] == {**_num("-000002"), "도번": ""} # 면적 · 재료는 남아 번호를 쥠 + assert numbers["ms"] == _num("-000003") # 다른 열 번호는 그대로 + # 새 열 = 다음 일련번호(자리 뜻 없음) · 다시 만든 도면은 짝(면적산출) 번호를 이어 받음 + assert client.post("/api/m02/structures/pp_cp").json()["문서"]["도번"] == "SD-000004" + assert client.post("/api/m02/structures/rv").json()["문서"]["도번"] == "SD-000002" -def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): - """옛 구-NN 파일 · 글 열 파일 → 새 번호(열 차례 = 종류 · 분류1 · 분류2 · 분류3 차례) · +def test_번호_없는_열은_열_차례로_일련번호_한_번_옮김(client, tmp_path): + """옛 구-NN 파일 · 글 열 파일 → 새 번호(번호 없는 열 = 열 차례 일련번호 · 자리 뜻 없음) · 내용 그대로.""" for column in ("no", "rv", "pv_l"): doc = {"양식": "구조물도면", "종류": "structure", "판": 1, "열": column, "도번": "구-07"} @@ -146,16 +147,16 @@ def test_제목행으로_번호_매김_한_번_옮김(client, tmp_path): assert done["지움"] == ["structure/no"] # 글 열(NO · 측점 · 비고)은 구조물 아님 numbers = client.get("/api/m02/structures/numbers").json() assert set(numbers) == set(store._column_ids()) and "sta" not in numbers - expect = { - "rv": "01010100", # 돌기슭막이 · (형태마다) · H=(높이마다) · 분류3 없음 = 00 - "c1": "01010200", # 같은 종류 · 분류1 · 다음 분류2 - "pv_b": "02010100", # 콘크리트포장 · T=(두께마다) · B - "pv_l": "02010200", - "pp_len": "03010100", - "fb": "04010000", # 세월교 · (관경 × 련수마다) · 분류2 없음 = 00 - "be": "06000000", # 소단 — 분류 모두 없음 - "h_rip_c": "07010000", # 돌붙임 · 찰붙임 - "h_rip_m": "07020000", # 돌붙임 · 메붙임 — 분류1 다음 번호 + expect = { # 구조물 열 차례 그대로 1 부터(제목행 자리 뜻 없음) + "rv": "-000001", + "c1": "-000002", + "pv_b": "-000003", + "pv_l": "-000004", + "pp_len": "-000007", + "fb": "-000009", + "be": "-000011", + "h_rip_c": "-000012", + "h_rip_m": "-000013", } for column, code in expect.items(): assert numbers[column] == _num(code), column @@ -182,24 +183,26 @@ def test_집계표_저장_때_새_열은_만들고_지운_열은_같이_지움(c assert "pv_a" not in numbers and "note2" not in numbers for kind_ in ("structure", "area", "material"): assert client.get(f"/api/m02/templates/{kind_}/pv_a").status_code == 404 - assert numbers["pv_x"] == _num("02010500") # 지운 pv_a(03)는 비워 둠 · 무리 다음 - assert numbers["zz"] == _num("08000000") # 새 종류 = 종류 다음 번호 - assert numbers["pv_y"] == _num("02010101") # pv_b 무리 안 분류3 다음 번호 + # 새 열 = 열 차례로 다음 일련번호 · 지운 pv_a(5) 는 비워 둠(다른 번호를 안 당김) + assert numbers["pv_x"] == _num("-000014") + assert numbers["zz"] == _num("-000015") + assert numbers["pv_y"] == _num("-000016") + assert numbers["pv_b"] == _num("-000003") # 있던 번호는 그대로 def test_지운_열_번호는_다음_저장에도_다시_안_줌(client): - """무리 맨 끝 열(pv_jt 02010400)을 지워 저장한 뒤 새 열을 더해도 04 를 다시 안 받음.""" + """가장 큰 번호 열(h_rip_m 13)을 지워 저장한 뒤 새 열을 더해도 13 을 다시 안 받음.""" store.renumber() url = "/api/m02/templates/table/구조물집계표" got = client.get(url).json() - got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "pv_jt"] + got["문서"]["열"] = [c for c in got["문서"]["열"] if c["id"] != "h_rip_m"] assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 - assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["02010400"] + assert client.get("/api/m02/structure-combos").json()["지운번호"] == ["-000013"] got = client.get(url).json() new = {"id": "pv_z", "머리": ["콘크리트포장", "T=(두께마다)", "Z", None], "꼴": "수"} got["문서"]["열"].append(new) assert client.put(url, json={"판": got["판"], "문서": got["문서"]}).status_code == 200 - assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("02010500") + assert client.get("/api/m02/structures/numbers").json()["pv_z"] == _num("-000014") def test_다른_열_도면을_복사해_가져옴(client): @@ -211,8 +214,8 @@ def test_다른_열_도면을_복사해_가져옴(client): assert r.status_code == 200 ms = client.get("/api/m02/templates/structure/ms").json()["문서"] assert ms["도면"]["entities"] == [{"type": "circle"}] - assert (ms["도번"], ms["열"]) == ("SD05010100", "ms") # 번호 · 열은 받는 쪽 그대로 - assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD01010100" + assert (ms["도번"], ms["열"]) == ("SD-000010", "ms") # 번호 · 열은 받는 쪽 그대로 + assert client.get("/api/m02/templates/structure/rv").json()["문서"]["도번"] == "SD-000001" bad = client.post("/api/m02/structures/ms/copy", params={"from": "없는열"}) assert bad.status_code == 404 diff --git a/resources/tester/test_m02_structure_combo.py b/resources/tester/test_m02_structure_combo.py index 9e68aacc..c10e1d7e 100644 --- a/resources/tester/test_m02_structure_combo.py +++ b/resources/tester/test_m02_structure_combo.py @@ -10,6 +10,7 @@ import pytest from fastapi import FastAPI from fastapi.testclient import TestClient +from M02_MasterTemplete import M02_MasterTemplete_Book as book from M02_MasterTemplete import M02_MasterTemplete_Store as store from M02_MasterTemplete import M02_Template_Layers as layers from M02_MasterTemplete.M02_MasterTemplete_Router import router @@ -97,7 +98,7 @@ def test_집계표_저장_때_새_열_지운_열_고친_열을_맞춤_키_유지 def test_SS_머리글은_키대장과_안_겹침(): ledger = json.loads((ROOT / "resources/master_data/_키대장.json").read_text("utf-8")) assert "SS" not in ledger["다음"] - assert store._COMBO_HEAD == "SS" + assert book.HEAD == "SS" # 장부는 `M02_MasterTemplete_Book`(26-2a 나눔) def test_새_프로젝트_복사에_실림(client, tmp_path, monkeypatch): diff --git a/resources/tester/test_m02_structure_panel.py b/resources/tester/test_m02_structure_panel.py index fd7e130f..1df1c34d 100644 --- a/resources/tester/test_m02_structure_panel.py +++ b/resources/tester/test_m02_structure_panel.py @@ -153,7 +153,7 @@ def test_좌측_구조물_집계_양식_아래_하위_넷_새로_본떠_지우 # 구조물 도면 · 면적산출 · 재료산출 · 자재 조합 = 구조물 집계 양식 컨테이너 안(같이 접힘) summary = side.split("const summary = section(")[1].split(");")[0] assert "COLUMN_KINDS.map" in summary and "matsetSection" in summary - assert 'M02_KindTable: ["구조물 집계 양식"' in _text( + assert 'M02_KindTable: ["구조물 조합"' in _text( # 화면 이름(26-7a) ROOT / "ui_template" / "ui_template_locale_m2.ts" ) diff --git a/resources/tester/test_m02_table_fill.py b/resources/tester/test_m02_table_fill.py index ed7b11cb..df9aab5a 100644 --- a/resources/tester/test_m02_table_fill.py +++ b/resources/tester/test_m02_table_fill.py @@ -8,14 +8,12 @@ from typing import Any import pytest -from config import config_system from M02_MasterTemplete import M02_Table_Fill as fill - -MASTER = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json" +from resources.tester.helper_m02_master_sample import sample_table def _master() -> dict[str, Any]: - return json.loads(MASTER.read_text(encoding="utf-8")) + return sample_table() # 시험 안 견본(PLAN 26-5a) — 실제 마스터 안 읽음 @pytest.fixture diff --git a/resources/tester/test_m02_template_layers.py b/resources/tester/test_m02_template_layers.py index 76b2b013..72d1cc1e 100644 --- a/resources/tester/test_m02_template_layers.py +++ b/resources/tester/test_m02_template_layers.py @@ -15,6 +15,7 @@ from common_util.common_util_auth import verify_session from config import config_system from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as router_module from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import sample_table P1 = "11111111-1111-1111-1111-111111111111" P2 = "22222222-2222-2222-2222-222222222222" @@ -197,10 +198,7 @@ def test_채운_표는_작업본을_채워_돌려주고_저장_안_함( monkeypatch.setattr(router_module, "_cross_designs", designs) monkeypatch.setattr(router_module.fill, "recalc", lambda document: {"계산": {}}) root = _root(world, P1) - master = config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json" - layers.write_template( - root / "templates", "table", "구조물집계표", json.loads(master.read_text(encoding="utf-8")) - ) + layers.write_template(root / "templates", "table", "구조물집계표", sample_table()) edits = root / "B04_PreProcess/drainage/edits" edits.mkdir(parents=True, exist_ok=True) (edits / "pipe_points.json").write_text( @@ -276,11 +274,7 @@ def test_프로젝트_표_저장은_서버가_설계값을_걸러_씀(world: dic from M02_MasterTemplete import M02_Table_Fill as fill client = world["client"] - master = json.loads( - ( - config_system.PROJECT_ROOT / "resources/master_template/table/구조물집계표.json" - ).read_text(encoding="utf-8") - ) + master = sample_table() filled = fill.fill_document( master, [ diff --git a/resources/tester/test_m02_tree.py b/resources/tester/test_m02_tree.py new file mode 100644 index 00000000..90bce3ee --- /dev/null +++ b/resources/tester/test_m02_tree.py @@ -0,0 +1,268 @@ +"""M02 집계 항목 폴더 구조(PLAN 26-1 · 26-2a) — 경로 · 옛 자료 읽기 · 일련번호 · 나무 고침 · 권한. + +① 옛 마스터(시험 안 견본 `fixtures/m02_master_sample` 사본 · PLAN 26-5a) 51 항목이 경로로 + 읽힘 — 개수 · 이름(옛 종류 · 분류1~3 이음과 같음) · 키 차례. +② 나무 고침은 임시 storage(회사 · 개인 층)와 견본 시스템 집계표로만 — 빈 층 첫 폴더 · 항목 · 깊이 + 1 · 2 · 5 · 이름 바꾸기 · 옮기기 · 지우기(세 파일 · 입력 연결 같이) · 마지막 항목 · 판 409 · + 422 · 404 · 권한 403 · 표 저장과 한 줄기(머리를 고치면 경로가 따라옴) · 번호 일련 · 안 되돌림. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from common_util.common_util_auth import verify_session +from config import config_system +from M02_MasterTemplete import M02_MasterTemplete_Book as book +from M02_MasterTemplete import M02_MasterTemplete_Router_Items as items_router +from M02_MasterTemplete import M02_MasterTemplete_Router_Layers as layers_router +from M02_MasterTemplete import M02_MasterTemplete_Store as store +from M02_MasterTemplete import M02_MasterTemplete_Tree as tree +from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE, copy_master_sample +from resources.tester.test_m02_structure import write_sample_table + +TABLE = "table/구조물집계표" +MASTER = SAMPLE + + +def _app(session: dict[str, Any]) -> TestClient: + app = FastAPI() + app.include_router(layers_router.router) + app.include_router(items_router.router) + app.dependency_overrides[verify_session] = lambda: session + return TestClient(app) + + +# ── ① 옛 마스터 — 견본 사본 ───────────────────────────── + + +def test_옛_마스터_51_항목이_경로로_읽힘(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr(store, "FOLDER", copy_master_sample(tmp_path / "master")) + raw = json.loads((MASTER / store.COMBO_FILE).read_bytes())["줄"] + old = [ + " ".join(str(r[k]) for k in ("종류", "분류1", "분류2", "분류3") if r.get(k)) for r in raw + ] + client = _app({"user_id": 1, "company_id": 1, "role": "USER", "is_master": False}) + rows = client.get("/api/m02/items").json() + assert len(rows) == len(raw) == 51 + assert [r["key"] for r in rows] == [r["키"] for r in raw] + assert [r["이름"] for r in rows] == old + assert all(r["경로"] and r["이름"] == " ".join(r["경로"]) for r in rows) + got = client.get("/api/m02/layers/system/tree").json() + assert len(got["items"]) == 51 and got["판"] + assert ["돌기슭막이"] in got["폴더"] and ["돌기슭막이", "메쌓기"] in got["폴더"] + # 옛 줄(경로 없음)도 종류 · 분류1~3 에서 경로로(빈 분류 건너뜀) + assert tree.row_path({"종류": "대피소", "분류1": None, "분류2": "", "분류3": "x"}) == [ + "대피소", + "x", + ] + + +def test_옮기기_함수는_두_번_돌려도_같음(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): + for kind in store.KINDS: + (tmp_path / kind).mkdir() + for name in ("table/구조물집계표.json", store.COMBO_FILE): + (tmp_path / name).write_bytes((MASTER / name).read_bytes()) + monkeypatch.setattr(store, "FOLDER", tmp_path) + before = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] + store.backfill_missing_structures() # 사본에 세 파일(번호) — 옮기기가 번호를 안 바꾸는지 봄 + assert book.migrate_paths() is True + assert book.migrate_paths() is False + after = json.loads((tmp_path / store.COMBO_FILE).read_bytes())["줄"] + assert [r["키"] for r in after] == [r["키"] for r in before] + assert all(r["경로"] == tree.row_path({**r, "경로": None}) for r in after) + table = json.loads((tmp_path / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == [] + assert all(c["경로"] == tree.path_of(c) for c in table["열"] if tree.is_item(c)) + + +# ── ② 나무 고침 — 임시 storage ────────────────────────── + + +@pytest.fixture() +def world(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> dict[str, Any]: + system = tmp_path / "system" + system.mkdir() + write_sample_table(system) + monkeypatch.setattr(store, "FOLDER", system) + monkeypatch.setattr(layers, "SYSTEM_ROOT", system) + store.renumber() + storage = tmp_path / "storage" + storage.mkdir() + monkeypatch.setattr(config_system, "STORAGE_BASE_DIR", str(storage)) + session: dict[str, Any] = {"user_id": 42, "company_id": 7, "role": "USER", "is_master": False} + return {"client": _app(session), "session": session, "storage": storage, "system": system} + + +def _as(world: dict, role: str = "USER", user: int = 42) -> TestClient: + world["session"].update(role=role, user_id=user) + return world["client"] + + +class Tree: + """한 층 나무를 판을 따라가며 고침.""" + + def __init__(self, client: TestClient, layer: str): + self.client, self.layer = client, layer + self.url = f"/api/m02/layers/{layer}/tree" + self.now = client.get(self.url).json() + + def do(self, want: int = 200, **body: Any) -> dict[str, Any]: + res = self.client.post(self.url, json={"판": self.now["판"], **body}) + assert res.status_code == want, res.text + if want == 200: + self.now = res.json() + assert self.now == {**self.client.get(self.url).json(), "알림": self.now["알림"]} + return res.json() + + def item(self, path: list[str]) -> dict[str, Any]: + return next(r for r in self.now["items"] if r["경로"] == path) + + def paths(self) -> list[list[str]]: + return [r["경로"] for r in self.now["items"]] + + +def _numbers(root: Path, cid: str) -> set[str]: + out = set() + for kind, key, _, _ in store.PARTS: + doc = json.loads((root / kind / f"{cid}.json").read_bytes()) + out.add(doc[key][2:]) + return out + + +def test_빈_층에서_폴더_항목_깊이_1_2_5_이름_옮기기_지우기(world): + t = Tree(_as(world), "personal") + root = world["storage"] / "7" / "42" / "templates" + assert (t.now["폴더"], t.now["items"]) == ([], []) # 빈 층 + t.do(op="add-folder", 경로=[], 이름="돌쌓기") + assert t.now["폴더"] == [["돌쌓기"]] and t.now["items"] == [] + t.do(op="add-item", 경로=[], 이름="소단", 단위="m") # 깊이 1 + t.do(op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") # 깊이 2 + t.do(op="add-item", 경로=["a", "b", "c", "d"], 이름="e") # 깊이 5(폴더는 저절로) + assert t.paths() == [["소단"], ["돌쌓기", "찰쌓기"], ["a", "b", "c", "d", "e"]] + deep = t.item(["a", "b", "c", "d", "e"]) + assert deep["이름"] == "a b c d e" + assert (deep["종류"], deep["분류1"], deep["분류2"], deep["분류3"]) == ("a", "b", "c", "d") + assert t.item(["소단"])["단위"] == "m" + table = json.loads((root / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3", "분류4"] # 가장 깊은 경로에 맞춤 + assert all(len(c["머리"]) == 5 for c in table["열"]) + # 번호 = 자리 뜻 없는 일련번호 · 세 파일 같은 번호 + codes = [_numbers(root, t.item(p)["열"]) for p in t.paths()] + assert codes == [{"-000001"}, {"-000002"}, {"-000003"}] + assert ["a", "b", "c", "d"] in t.now["폴더"] and ["돌쌓기"] in t.now["폴더"] + # 겹침 409 · 모양 422 · 없는 것 404 + t.do(409, op="add-item", 경로=["돌쌓기"], 이름="찰쌓기") + t.do(409, op="add-folder", 경로=[], 이름="a") + for bad in ( + {"op": "grow"}, + {"op": "add-item", "이름": " "}, + {"op": "add-item", "이름": "x" * 61}, + ): + t.do(422, **bad) + t.do(404, op="rename", 키="SS999999", 이름="x") + t.do(404, op="delete", 경로=["없는폴더"]) + # 이름 바꾸기 — 항목(키 · 열 · 번호 그대로) · 폴더(아래 항목 모두) + key = t.item(["돌쌓기", "찰쌓기"])["key"] + t.do(op="rename", 키=key, 이름="메쌓기") + moved = t.item(["돌쌓기", "메쌓기"]) + assert moved["key"] == key and _numbers(root, moved["열"]) == {"-000002"} + t.do(op="rename", 경로=["a"], 이름="A") + assert ["A", "b", "c", "d", "e"] in t.paths() and ["a"] not in t.now["폴더"] + t.do(409, op="rename", 경로=["A"], 이름="돌쌓기") # 있는 폴더로 합치기 막음 + # 옮기기 — 항목 · 폴더 · 제 안으로는 422 + t.do(op="move", 키=t.item(["소단"])["key"], 새경로=["돌쌓기"]) + t.do(op="move", 경로=["A", "b"], 새경로=["돌쌓기"]) + assert t.paths() == [["돌쌓기", "메쌓기"], ["돌쌓기", "소단"], ["돌쌓기", "b", "c", "d", "e"]] + t.do(422, op="move", 경로=["돌쌓기"], 새경로=["돌쌓기", "b"]) + # 지우기 — 항목의 세 파일 · 입력 연결 파일도 같이 · 마지막 항목까지 + gone = t.item(["돌쌓기", "소단"]) + (root / "inputs").mkdir(exist_ok=True) + (root / "inputs" / f"{gone['열']}.json").write_text('{"판": 1, "줄": []}', encoding="utf-8") + note = t.do(op="delete", 키=gone["key"])["알림"] + assert note["지운항목"] == [gone["열"]] + for part in ("structure", "area", "material", "inputs"): + assert not (root / part / f"{gone['열']}.json").is_file() + t.do(op="add-item", 경로=["돌쌓기"], 이름="새것") # 지운 번호는 다시 안 줌 + assert _numbers(root, t.item(["돌쌓기", "새것"])["열"]) == {"-000004"} + t.do(op="delete", 경로=["돌쌓기", "b"]) # 깊은 항목이 빠지면 머리 줄 넷으로 + table = json.loads((root / "table/구조물집계표.json").read_bytes()) + assert table["층"] == ["종류", "분류1", "분류2", "분류3"] + t.do(op="delete", 경로=["돌쌓기"]) # 마지막 항목들까지 + assert t.now["items"] == [] and t.now["폴더"] == [] + assert json.loads((root / "table/구조물집계표.json").read_bytes())["열"] == [] + + +def test_판_409_권한_403(world): + admin = Tree(_as(world, "ADMIN"), "company") + stale = admin.now["판"] + admin.do(op="add-folder", 경로=[], 이름="회사폴더") + res = admin.client.post(admin.url, json={"판": stale, "op": "add-folder", "이름": "x"}) + assert res.status_code == 409 and res.json()["detail"]["판"] == admin.now["판"] + user = _as(world) # 같은 회사 일반 사용자 — 읽기 200 · 고침 403 + assert user.get("/api/m02/layers/company/tree").json()["폴더"] == [["회사폴더"]] + body = {"판": admin.now["판"], "op": "add-folder", "이름": "y"} + assert user.post("/api/m02/layers/company/tree", json=body).status_code == 403 + assert user.get("/api/m02/layers/personal/tree?owner=7/9").status_code == 403 # 남의 개인 + system = user.get("/api/m02/layers/system/tree").json() + body = {"판": system["판"], "op": "add-folder", "이름": "y"} + assert user.post("/api/m02/layers/system/tree", json=body).status_code == 403 + sysadmin = Tree(_as(world, "SYSTEM_ADMIN"), "system") + sysadmin.do(op="add-item", 경로=["돌붙임"], 이름="줄붙임") + assert ["돌붙임", "줄붙임"] in sysadmin.paths() + + +def test_표_저장과_나무가_한_줄기(world): + """정본 = 나무(PLAN 26-7e) — 표 [저장]이 보낸 머리 · 층 이름 · 빈 폴더는 안 받음(저장본 경로 + 그대로) · 나무 고침 뒤 표 머리가 경로에서 다시 만들어짐.""" + admin = _as(world, "SYSTEM_ADMIN") + t = Tree(admin, "system") + rv = next(r for r in t.now["items"] if r["열"] == "rv") + path = rv["경로"] + url = f"/api/m02/layers/system/templates/{TABLE}" + got = admin.get(url).json() + col = next(c for c in got["문서"]["열"] if c["id"] == "rv") + col["머리"] = ["돌기슭막이", None, "찰쌓기", None] + col["단위"] = "㎡" # 머리 밖 칸은 받음 + got["문서"]["층"] = ["가", "나", "다", "라"] + got["문서"]["폴더"] = [["표에서 만든 폴더"]] + assert admin.put(url, json=got).status_code == 200 + table = admin.get(url).json()["문서"] + col = next(c for c in table["열"] if c["id"] == "rv") + assert col["경로"] == path and col["단위"] == "㎡" + assert table["층"] == ["종류", "분류1", "분류2", "분류3"] and table["폴더"] == [] + t = Tree(admin, "system") + assert t.item(path)["key"] == rv["key"] + t.do(op="rename", 키=rv["key"], 이름="메쌓기") + table = admin.get(url).json()["문서"] + col = next(c for c in table["열"] if c["id"] == "rv") + assert col["경로"] == [*path[:-1], "메쌓기"] + assert col["머리"] == [*path[:-1], "메쌓기", *[None] * (4 - len(path))] + # (화면엔 단추 없음 · API 호환) 표 문서에서 열을 빼고 저장하면 나무에서도 빠지고 파일도 같이 + table["열"] = [c for c in table["열"] if c["id"] != "rv"] + assert ( + admin.put(url, json={"판": admin.get(url).json()["판"], "문서": table}).status_code == 200 + ) + assert "rv" not in [r["열"] for r in Tree(admin, "system").now["items"]] + assert not (world["system"] / "structure" / "rv.json").is_file() + + +def test_프로젝트에서_새_항목도_경로_그대로(world): + """개인에 저장 · 프로젝트에서 새 항목(`M02_Item_Personal`)도 경로를 넷에 접지 않고 그대로 · + 같은 경로면 「이름 (2)」.""" + from M02_MasterTemplete import M02_Item_Personal as personal + + folder = layers.personal_dir(7, 42) + made = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") + again = personal.create_item(folder, ["가", "나", "다", "라"], "마", "m") + t = Tree(_as(world), "personal") + assert t.item(["가", "나", "다", "라", "마"])["key"] == made["key"] + assert t.item(["가", "나", "다", "라", "마 (2)"])["key"] == again["key"] + assert made["이름"] == "가 나 다 라 마" diff --git a/resources/tester/test_m02_treepanel.py b/resources/tester/test_m02_treepanel.py new file mode 100644 index 00000000..798f1391 --- /dev/null +++ b/resources/tester/test_m02_treepanel.py @@ -0,0 +1,80 @@ +# -*- coding: utf-8 -*- +"""M02 관리 화면 항목 나무(PLAN 26-3a) 자료 셈 회귀검증 — 폴더 나무 · 빈 폴더 · 깊이 5 · 찾기 · 폴더 목록. + +항목 경로 = 폴더 이름 배열 + 마지막 마디가 항목 이름 · 찾는 글이 있으면 맞는 항목과 위 폴더만. +Node 헬퍼(`helper_m02_treepanel.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_m02_treepanel.cjs")], + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_빈_폴더도_나무에_있음(results): + tops = [f["path"] for f in results["all"]["folders"]] + assert ["빈 폴더"] in tops + dol = next(f for f in results["all"]["folders"] if f["path"] == ["돌기슭막이"]) + assert ["돌기슭막이", "빈 아래"] in [f["path"] for f in dol["folders"]] + + +def test_맨_위_항목은_폴더_밖(results): + assert results["all"]["items"] == ["c4"] + assert results["count"] == 4 + + +def test_깊이_5_항목까지_이어짐(results): + node = next(f for f in results["all"]["folders"] if f["path"] == ["다섯"]) + for depth in range(2, 5): + node = node["folders"][0] + assert len(node["path"]) == depth + assert node["items"] == ["c3"] + + +def test_찾기는_맞는_항목과_위_폴더만(results): + assert results["foundCount"] == 1 + assert [f["path"] for f in results["found"]["folders"]] == [["돌기슭막이"]] + assert results["found"]["folders"][0]["items"] == ["c2"] # 빈 폴더는 안 보임 + assert results["none"] == {"path": [], "items": [], "folders": []} + + +def test_키로도_찾음(results): + assert results["byKey"] == 1 + + +def test_폴더_목록과_위_길(results): + assert ["다섯", "둘", "셋", "넷"] in results["folders"] + assert ["빈 폴더"] in results["folders"] + assert results["ancestors"] == [ + ["다섯"], + ["다섯", "둘"], + ["다섯", "둘", "셋"], + ["다섯", "둘", "셋", "넷"], + ] + assert results["ancestorsTop"] == [] + assert results["ancestorsNone"] == [] + + +def test_안_또는_같음_판정(results): + assert results["under"] == [True, True, False, False] + + +def test_잎_이름과_폴더(results): + assert results["leaf"] == "1.2m" + assert results["dir"] == ["돌기슭막이", "메쌓기"] diff --git a/resources/tester/test_route_initial.py b/resources/tester/test_route_initial.py index 6d5300a4..edac8d33 100644 --- a/resources/tester/test_route_initial.py +++ b/resources/tester/test_route_initial.py @@ -25,10 +25,19 @@ from B05_Profile.B05_Profile_Engine_RouteInitial import ( stream_lines, write_initial_choice, ) -from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import grade_line, station_label +from B05_Profile.B05_Profile_Engine_RouteInitial_Metrics import ( + COST_CROSSING, + COST_EARTH_M3, + COST_LENGTH_M, + grade_line, + station_label, +) +from B05_Profile.B05_Profile_Engine_RouteInitial_Pool import GOALS from common_util.common_util_route_polyline import build_planned_polyline SEARCHED = [mode for mode in MODES if mode != "follow"] +# 제약을 지키는 갈래(후보 묶음에서 고름) — 비중은 제약을 벌점으로만(PLAN 23-4a) +BRANCHES = ["gradeline", "earthwork", "gentle", "safe", "crossings", "cost"] GRADE = 0.14 RADIUS = 12.0 @@ -76,7 +85,7 @@ def hill() -> Terrain: ) -@pytest.mark.parametrize("mode", SEARCHED) +@pytest.mark.parametrize("mode", BRANCHES) def test_every_mode_keeps_grade_limit_on_hill(hill: Terrain, mode: str) -> None: result = _run(hill, mode) _kept(result) @@ -105,12 +114,23 @@ def _earth(result: dict) -> float: return result["metrics"]["cut_m3"] + result["metrics"]["fill_m3"] +def _check_branch_goals(runs: dict) -> None: + """갈래마다 제 목표 비교값(`GOALS` — 절성토 · 최대 기울기 · 목표 기울기 편차 · 위험 길이 · + 계류 횡단 · 공사비)이 갈래(같은 후보 묶음) 가운데 1 등 · 새 비교값이 서로 맞음(PLAN 23-5).""" + branches = [runs[mode]["metrics"] for mode in BRANCHES] + for mode, goal in GOALS.items(): + assert all(goal(runs[mode]["metrics"]) <= goal(m) for m in branches), mode + for result in runs.values(): + m = result["metrics"] + assert abs(m["surplus_m3"] - (m["cut_m3"] - m["fill_m3"])) <= 0.11 + assert 0.0 <= m["hazard_m"] <= m["length_m"] + 1e-6 + assert m["side_slope_avg_pct"] <= m["side_slope_max_pct"] + 1e-6 + cost = COST_EARTH_M3 * _earth(result) + COST_LENGTH_M * m["length_m"] + assert abs(m["cost_index"] - cost - COST_CROSSING * m["stream_crossings"]) <= 1.0 + + def test_each_branch_best_at_own_goal(hill: Terrain) -> None: - """토공 최소 = 절성토 가장 적음 · 기울기 순한 = 최대 기울기 가장 낮음(같은 후보 묶음).""" - runs = {mode: _run(hill, mode) for mode in SEARCHED} - assert all(_earth(runs["earthwork"]) <= _earth(r) + 1e-6 for r in runs.values()) - top = runs["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] + 1e-6 for r in runs.values()) + _check_branch_goals({mode: _run(hill, mode) for mode in MODES}) # 비중 끝값 · 단조(PLAN 23-3e) — 예상 · 토공 · 기울기 한쪽 100 이면 그 갈래와 같은 선 @@ -144,7 +164,8 @@ def _check_weight_ends(run) -> None: def _check_weight_monotone(run) -> None: - """비중을 한쪽으로 옮기면 그 목표 값은 나빠지지 않음(후보가 같으니 가중합 최소의 성질).""" + """예상 몫을 줄이면 예상노선 이격이 늘기만 함(비중 탐색 = 가중합 최소의 성질) · 토공 ↔ 기울기 + 끝값(갈래)은 그 사이 어느 비중보다 제 목표에서 좋음(쓸기가 후보 묶음에 들어 있음).""" def metrics(steps): keys = ("expected", "earthwork", "grade") @@ -155,8 +176,8 @@ def _check_weight_monotone(run) -> None: earth = metrics(TOWARD_EARTH) cuts = [m["cut_m3"] + m["fill_m3"] for m in earth] tops = [m["max_grade_pct"] for m in earth] - assert cuts == sorted(cuts, reverse=True), cuts - assert tops == sorted(tops), tops + assert cuts[-1] <= min(cuts) + 1e-6, cuts # 끝 0/100/0 = 토공 최소 + assert tops[0] <= min(tops) + 1e-6, tops # 끝 0/0/100 = 기울기 순한 def test_weight_ends_match_branches(hill: Terrain) -> None: @@ -185,7 +206,7 @@ def test_stream_offset_is_hard() -> None: """평지 · 예상노선 20 m 옆을 나란히 흐르는 소하천 — 모든 갈래가 버퍼를 비켜 감.""" flat = _terrain(lambda xx, yy: np.zeros_like(xx)) stream = [[(0.0, 220.0), (800.0, 220.0)]] - for mode in SEARCHED: + for mode in BRANCHES: result = _run(flat, mode, streams=stream) _kept(result) assert result["metrics"]["over_limit"]["stream"] == 0 @@ -203,7 +224,7 @@ def test_stream_crossing_is_allowed() -> None: def test_terrain_impossible_is_listed() -> None: """복도 전체를 가로지르는 20 m 벽 — 어떤 길도 상한을 넘음 → 목록 · 사유 · 막은 기울기.""" wall = _terrain(lambda xx, yy: np.where(xx > 400.0, 20.0, 0.0)) - result = _run(wall, "weighted") + result = _run(wall, "earthwork") _kept(result) assert result["relaxed"] is True assert result["terrain_bound_pct"] > GRADE * 100.0 @@ -222,7 +243,17 @@ def test_grade_line_stays_within_limit() -> None: def test_weights_and_stream_filter() -> None: assert resolve_weights("weighted", None) == {"expected": 50.0, "earthwork": 25.0, "grade": 25.0} assert resolve_weights("gentle", {"expected": 99})["grade"] == 100.0 - assert set(MODES) == {"follow", "earthwork", "gentle", "weighted"} + assert set(MODES) == { + "follow", + "gradeline", + "earthwork", + "gentle", + "safe", + "crossings", + "cost", + "weighted", + } + assert resolve_weights("cost", {"expected": 99})["expected"] == 0.0 line = {"type": "LineString", "coordinates": [[0, 0], [1, 1]]} features = [ {"properties": {"구분": "세류"}, "geometry": line}, @@ -277,6 +308,10 @@ def test_initial_choice_roundtrip(tmp_path) -> None: "weights": {"expected": 100.0, "earthwork": 0.0, "grade": 0.0}, "criteria": None, } + for logic in ("gradeline", "safe", "crossings", "cost"): # 새 갈래도 적고 다시 읽음(23-5) + write_initial_choice(tmp_path, logic, None, {"target_grade_pct": 10.0}) + assert read_initial_choice(tmp_path)["logic"] == logic + assert read_initial_choice(tmp_path)["criteria"] == {"target_grade_pct": 10.0} write_initial_choice(tmp_path, "weighted", {"expected": 60, "earthwork": 20, "grade": 20}) assert read_initial_choice(tmp_path) == { "logic": "weighted", @@ -302,6 +337,13 @@ def test_request_accepts_logic_name() -> None: assert RouteInitialRequest.model_validate({"logic": "gentle"}).mode == "gentle" assert RouteInitialRequest.model_validate({"mode": "earthwork"}).mode == "earthwork" + for logic in ("gradeline", "safe", "crossings", "cost"): + assert RouteInitialRequest.model_validate({"logic": logic}).mode == logic + for gone in ("short", "balance"): # 23-5 에서 뺀 갈래 — 422 + with pytest.raises(ValueError): + RouteInitialRequest.model_validate({"logic": gone}) + criteria = RouteInitialRequest.model_validate({"criteria": {"target_grade_pct": 10}}).criteria + assert criteria is not None and criteria.target_grade_pct == 10 replan = RouteReplanRequest.model_validate({"vertices": [], "initial": {"logic": "weighted"}}) assert replan.initial is not None and replan.initial.mode == "weighted" @@ -385,20 +427,39 @@ def test_project_follow_is_preprocess_initial(project_runs: dict) -> None: def test_project_branches_differ_by_goal(project_runs: dict) -> None: - """갈래끼리 선이 수 m 이상 갈리고 · 각 갈래가 제 목표 비교값에서 가장 좋음.""" - searched = {mode: project_runs[mode] for mode in SEARCHED} # 비중은 50/25/25 - for first in SEARCHED: - for second in SEARCHED: - if first < second: - gap = LineString(searched[first]["planned"]).hausdorff_distance( - LineString(searched[second]["planned"]) - ) - assert gap >= 3.0, (first, second, gap) - assert all(_earth(searched["earthwork"]) <= _earth(r) for r in searched.values()) - top = searched["gentle"]["metrics"]["max_grade_pct"] - assert all(top <= r["metrics"]["max_grade_pct"] for r in searched.values()) - for result in searched.values(): - assert result["metrics"]["over_limit"]["total"] == len(result["metrics"]["violations"]) + """각 갈래가 제 목표 비교값에서 가장 좋음 · 토공 최소와 기울기 순한은 선이 갈림(6.1 m).""" + _check_branch_goals({mode: project_runs[mode] for mode in MODES}) + assert _gap(project_runs["earthwork"], project_runs["gentle"]) >= 3.0 + for mode in MODES: + metrics = project_runs[mode]["metrics"] + assert metrics["over_limit"]["total"] == len(metrics["violations"]) + + +def test_project_similar_and_band(project_runs: dict) -> None: + """가파른 지형 — 제약 안 길이 한 띠(띠 폭 < 20 m) · 10 m 안인 다른 갈래를 `similar` 로 알림.""" + earthwork = project_runs["earthwork"] + assert earthwork["band_width_m"] < 20.0 + near = {item["logic"]: item["max_gap_m"] for item in earthwork["similar"]} + assert near["gentle"] == round(_gap(earthwork, project_runs["gentle"]), 1) + assert all(gap < 10.0 for gap in near.values()) and "earthwork" not in near + assert "follow" not in near # 추적은 40 m 넘게 떨어짐 + + +def test_project_expected_axis_moves_little_by_little(project_runs: dict) -> None: + """예상 100 → 0(토공 : 기울기 1 : 1) — 99 는 추적에서 몇 m 안 · 이웃 단계마다 노선이 움직임 · + 추적에서 점점 멀어짐(이격 늘기만) · 비중 탐색은 넘은 곳을 「비중 계산」 사유로 알림.""" + run = project_runs["run"] + near = run("weighted", {"expected": 99, "earthwork": 0.5, "grade": 0.5}) + assert 0.0 < _gap(near, project_runs["follow"]) < 10.0 + steps = [ + run("weighted", {"expected": e, "earthwork": (100 - e) / 2, "grade": (100 - e) / 2}) + for e in range(100, -1, -10) + ] + assert all(_gap(a, b) > 0.0 for a, b in zip(steps, steps[1:])) + offsets = [step["metrics"]["offset_avg_m"] for step in steps] + assert offsets == sorted(offsets), offsets + reasons = [v["reason"] for v in steps[5]["metrics"]["violations"] if v["kind"] == "grade"] + assert reasons and all(reason.startswith("비중 계산") for reason in reasons) def test_project_weight_ends_match_branches(project_runs: dict) -> None: @@ -409,3 +470,43 @@ def test_project_weight_ends_match_branches(project_runs: dict) -> None: def test_project_weight_moves_monotone(project_runs: dict) -> None: _check_weight_monotone(project_runs["run"]) + + +def test_stream_crossing_counted_once() -> None: + """평지 · 복도를 가로지르는 소하천 한 줄 — 어느 노선이든 한 번 건넘 · 계류 횡단 최소도 한 번.""" + flat = _terrain(lambda xx, yy: np.zeros_like(xx)) + crossing = [[(400.0, 0.0), (400.0, 400.0)]] + for mode in ("follow", "crossings", "cost"): + metrics = _run(flat, mode, streams=crossing)["metrics"] + assert metrics["stream_crossings"] == 1, mode + assert metrics["stream_offset_avg_m"] is not None + assert _run(flat, "follow")["metrics"]["stream_crossings"] == 0 + + +def test_gradeline_target_grade(hill: Terrain) -> None: + """영선 노선 — 목표 기울기(없으면 상한 · 상한보다 크면 상한).""" + default = _run(hill, "gradeline") + assert default["criteria"]["target_grade_pct"] == 14.0 + capped = generate_initial_route( + _line(200.0), + hill, + [], + mode="gradeline", + weights=None, + max_grade=GRADE, + min_radius_m=RADIUS, + target_grade=0.5, + ) + assert capped["criteria"]["target_grade_pct"] == 14.0 + low = generate_initial_route( + _line(200.0), + hill, + [], + mode="gradeline", + weights=None, + max_grade=GRADE, + min_radius_m=RADIUS, + target_grade=0.02, + ) + assert low["criteria"]["target_grade_pct"] == 2.0 + _kept(low) diff --git a/resources/tester/test_structures_summary_snapshot.py b/resources/tester/test_structures_summary_snapshot.py index 73773801..261206bc 100644 --- a/resources/tester/test_structures_summary_snapshot.py +++ b/resources/tester/test_structures_summary_snapshot.py @@ -24,8 +24,9 @@ from config import config_system from M02_MasterTemplete import M02_Item_Snapshot as snap from M02_MasterTemplete import M02_Table_Fill as fill from M02_MasterTemplete import M02_Template_Layers as layers +from resources.tester.helper_m02_master_sample import SAMPLE -REAL = config_system.PROJECT_ROOT / "resources/master_template" +REAL = SAMPLE # 시험 안 견본(PLAN 26-5a) — 실제 마스터 안 읽음 MS = {"layer": "system", "owner": None, "key": "SS000022", "name": "돌쌓기"} RV = {"layer": "system", "owner": None, "key": "SS000001", "name": "돌기슭막이 메쌓기 1.2m"} PIPE = {"layer": "system", "owner": None, "key": "SS000011", "name": "관공 관매설"} @@ -82,7 +83,8 @@ def _pipe(summary: dict | None) -> dict[str, Any]: def _items(root: Path) -> Path: - return root / "templates" / "items" + """구조물 복사본 뿌리(26-2b) — 구조물마다 폴더(`structure_id` · 관은 `pipe@측점`).""" + return root / "templates" / "structures" # ── 칸 모양 ────────────────────────────────────────── @@ -126,12 +128,8 @@ def test_옛_파일은_칸_없음_그대로_읽고_씀(): def test_지정하면_폴더가_생기고_내용이_원본과_같음(world): root = world["root"] result = _save(root, [_dry(MS), _wet()], [_pipe(PIPE)]) - assert result == { - "만듦": ["system_none_SS000011", "system_none_SS000022"], - "지움": [], - "못만듦": [], - } - folder = _items(root) / "system_none_SS000022" + assert result == {"만듦": ["pipe@40.0", "st0"], "지움": [], "못만듦": [], "옮겨받음": []} + folder = _items(root) / "st0" got = {path.name for path in folder.iterdir()} assert {"집계.json", "도면.json", "면적.json", "재료.json", "manifest.json"} <= got total = json.loads((folder / "집계.json").read_text(encoding="utf-8")) @@ -139,15 +137,12 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world): drawing = json.loads((world["system"] / "structure/ms.json").read_text(encoding="utf-8")) assert json.loads((folder / "도면.json").read_text(encoding="utf-8")) == drawing manifest = json.loads((folder / "manifest.json").read_text(encoding="utf-8")) - assert manifest["원본"] == MS and manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16 - pipe = json.loads( - (_items(root) / "system_none_SS000011/manifest.json").read_text(encoding="utf-8") - ) + assert manifest["원본"] == {**MS, "경로": ["돌쌓기", "(찰·메마다)", "H=(높이마다)"]} + assert manifest["구조물"] == ["st0"] and len(manifest["판"]) == 16 + pipe = json.loads((_items(root) / "pipe@40.0/manifest.json").read_text(encoding="utf-8")) assert pipe["구조물"] == ["pipe@40.0"] # 일위대가 UA 줄 + 담은 로직 줄(M01 시스템) - labor = json.loads( - (_items(root) / "system_none_SS000011/일위대가.json").read_text(encoding="utf-8") - ) + labor = json.loads((_items(root) / "pipe@40.0/일위대가.json").read_text(encoding="utf-8")) assert labor["줄"]["키"] == "UA000036" assert [row["키"] for row in labor["로직"]] + labor["빠진로직"] == [ entry["로직"] for entry in labor["줄"]["담은로직"] @@ -157,7 +152,7 @@ def test_지정하면_폴더가_생기고_내용이_원본과_같음(world): def test_원본을_바꿔도_프로젝트_값은_그대로(world): root, system = world["root"], world["system"] _save(root, [_dry(MS)], []) - before = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") + before = (_items(root) / "st0/집계.json").read_text(encoding="utf-8") table = json.loads((system / "table/구조물집계표.json").read_text(encoding="utf-8")) for column in table["열"]: if column["id"] == "ms": @@ -166,7 +161,7 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world): json.dumps(table, ensure_ascii=False), encoding="utf-8" ) assert _save(root, [_dry(MS)], [])["만듦"] == [] # 다시 저장해도 안 다시 씀 - after = (_items(root) / "system_none_SS000022/집계.json").read_text(encoding="utf-8") + after = (_items(root) / "st0/집계.json").read_text(encoding="utf-8") assert after == before doc = fill.fill_table(root, table) heads = [c["머리"][0] for c in doc["열"] if c["id"].startswith("ms|")] @@ -174,16 +169,20 @@ def test_원본을_바꿔도_프로젝트_값은_그대로(world): def test_지정_바꿈_해제_삭제면_옛_폴더를_지움(world): + """같은 항목을 고른 구조물 둘 = 폴더 둘 · 한쪽만 바꾸면 그쪽만 새로 · 해제 · 삭제면 지움.""" root = world["root"] _save(root, [_dry(MS), _dry(MS)], []) - manifest = _items(root) / "system_none_SS000022/manifest.json" - assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st0", "st1"] - # 한 구조물만 바꿈 — 같은 항목을 쓰는 구조물이 남으면 폴더 유지 · 목록만 줄어듦 + assert sorted(p.name for p in _items(root).iterdir()) == ["st0", "st1"] result = _save(root, [_dry(RV), _dry(MS)], []) - assert result["만듦"] == ["system_none_SS000001"] and result["지움"] == [] - assert json.loads(manifest.read_text(encoding="utf-8"))["구조물"] == ["st1"] + assert result["만듦"] == ["st0"] and result["지움"] == ["st0"] # 안 고쳤으니 옛 것은 지움 + assert json.loads((_items(root) / "st0/manifest.json").read_text("utf-8"))["원본"]["key"] == ( + "SS000001" + ) + assert json.loads((_items(root) / "st1/manifest.json").read_text("utf-8"))["원본"]["key"] == ( + "SS000022" + ) # 해제 · 삭제 - assert _save(root, [_dry(None)], [])["지움"] == ["system_none_SS000001", "system_none_SS000022"] + assert _save(root, [_dry(None)], [])["지움"] == ["st0", "st1"] assert list(_items(root).iterdir()) == [] @@ -197,8 +196,8 @@ def test_회사_층_항목은_회사_폴더에서_복사(world): row["분류3"] = "회사 것" (company / layers.COMBO_FILE).write_text(json.dumps(combo, ensure_ascii=False), "utf-8") owned = {**MS, "layer": "company", "owner": "3"} - assert _save(root, [_dry(owned)], [])["만듦"] == ["company_3_SS000022"] - total = json.loads((_items(root) / "company_3_SS000022/집계.json").read_text("utf-8")) + assert _save(root, [_dry(owned)], [])["만듦"] == ["st0"] + total = json.loads((_items(root) / "st0/집계.json").read_text("utf-8")) assert total["줄"]["분류3"] == "회사 것" # 채움 — 회사 항목은 시스템 열과 안 겹치는 새 열 doc = fill.fill_table( @@ -210,7 +209,7 @@ def test_회사_층_항목은_회사_폴더에서_복사(world): def test_원본에_없는_항목은_못만듦으로_남김(world): gone = {**MS, "key": "SS999999"} - assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["system_none_SS999999"] + assert _save(world["root"], [_dry(gone)], [])["못만듦"] == ["st0"] # ── 채움 ───────────────────────────────────────────── @@ -245,4 +244,4 @@ def test_스냅샷이_없으면_알림(world): def test_초기화는_templates_를_안_건드리고_복제는_통째로(): assert not any(tree.startswith("templates") for tree in initial._FILE_TREES) tool = (config_system.PROJECT_ROOT / "db_management/tools_clone_project.py").read_text("utf-8") - assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates/items 포함) + assert '"robocopy",' in tool and '"/E",' in tool # 프로젝트 폴더 전체(templates 포함) diff --git a/resources/tester/ui/item-picker-data.test.ts b/resources/tester/ui/item-picker-data.test.ts index be8ffa93..37f0c5db 100644 --- a/resources/tester/ui/item-picker-data.test.ts +++ b/resources/tester/ui/item-picker-data.test.ts @@ -10,6 +10,7 @@ import { isCurrent, itemName, pathIds, + pathOf, rowId, rowTip, shown, @@ -89,13 +90,8 @@ assert.equal( "옹벽 H=2m", ); const placeholderTree = buildTree([fresh], labels); -assert.equal(placeholderTree[2].children?.[0].label, "분류 없음"); // 종류 -assert.equal(placeholderTree[2].children?.[0].children?.[0].label, "분류 없음"); // 분류1 -assert.deepEqual(pathIds(fresh, labels), [ - "personal", - "personal|분류 없음", - "personal|분류 없음|분류 없음", -]); +assert.equal(placeholderTree[2].children?.[0].label, "n1"); // 경로가 비면 폴더 없이 그룹 바로 밑 잎 +assert.deepEqual(pathIds(fresh, labels), ["personal"]); // 툴팁 = 층 이름 + 키 assert.equal(rowTip(rows[7], labels), "개인 · SS000201"); @@ -123,14 +119,38 @@ assert.deepEqual( const cat = system.children?.[0].children?.[0]; assert.equal(cat?.label, "메쌓기"); assert.equal(cat?.count, 2); -const leaf = cat?.children?.[0]; +const size = cat?.children?.[0]; // 폴더 깊이 자유 — 경로 앞부분이 모두 폴더 +assert.equal(size?.label, "1.2m"); +assert.deepEqual(size?.folder, { layer: "system", path: ["돌기슭막이", "메쌓기", "1.2m"] }); +assert.deepEqual(tree[2].folder, { layer: "personal", path: [] }); // 그룹 줄 = 빈 경로 +const leaf = size?.children?.[0]; assert.equal(leaf?.label, "돌기슭막이 메쌓기 1.2m 기초유"); assert.equal(leaf?.tip, "마스터 · SS000001"); // 툴팁 = 층 이름 + 키 assert.equal(leaf?.count, null); assert.ok(leaf?.row); -// 분류가 빈 항목은 「분류 없음」 한 칸에 모임 -assert.equal(tree[1].children?.[1].children?.[0].label, "분류 없음"); +// 경로가 짧은 항목은 얕은 폴더에(옹벽 H=2m = 폴더 옹벽 · 잎 「옹벽 H=2m」) +assert.equal(tree[1].children?.[1].label, "옹벽"); +assert.equal(tree[1].children?.[1].children?.[0].label, "옹벽 H=2m"); +assert.equal(tree[1].children?.[1].children?.[0].folder, undefined); // 잎에는 folder 없음 + +// 서버가 준 `경로` 가 종류 · 분류보다 앞 — 이름 = 경로 이음 · 자리표는 뺌 +const deep = row("SS000400", "d1", "무시", ["무시", "", ""], "personal", { + 경로: ["가", "나", "다", "라", "마"], +}); +assert.deepEqual(pathOf(deep), ["가", "나", "다", "라", "마"]); +assert.equal(itemName(deep), "가 나 다 라 마"); +assert.deepEqual(pathIds(deep), [ + "personal", + "personal|가", + "personal|가|나", + "personal|가|나|다", + "personal|가|나|다|라", +]); +assert.deepEqual(pathOf({ key: "k", 열: "x", 경로: ["새 분류1", "옹벽"] }), ["옹벽"]); +const deepTree = buildTree([deep], labels)[2]; +assert.equal(deepTree.children?.[0].children?.[0].children?.[0].children?.[0].label, "라"); +assert.equal(deepTree.count, 1); // 빈 그룹도 한 줄은 남음(회사 없는 사람의 회사 · 개인 그룹) const onlySystem = buildTree( @@ -198,6 +218,7 @@ assert.deepEqual(pathIds(rows[0], labels), [ "system", "system|돌기슭막이", "system|돌기슭막이|메쌓기", + "system|돌기슭막이|메쌓기|1.2m", ]); // 폴더 모양 — 다섯 줄 · 이름은 서버 names · 없으면 키만(작은 글) · 둘 다 없으면 빈 키 diff --git a/ui_template/sheet/ui_template_sheet.ts b/ui_template/sheet/ui_template_sheet.ts index c91e3704..7cab951e 100644 --- a/ui_template/sheet/ui_template_sheet.ts +++ b/ui_template/sheet/ui_template_sheet.ts @@ -10,6 +10,8 @@ * 치지 않고 조회 후 선택. * `readOnly` = 볼 수만 있음 — 단추 줄 · 안내 글 숨김 · 칸 고치기 · 머리 고치기 · 열 너비 끌기 막음 · * 열이 하나도 없으면 [열 더하기] 가 켜져 첫 열을 만듦(머리 글 바로 고치기). + * `lockHead` = 열 · 머리는 밖(나무)이 정함 — 열 더하기 · 지우기 단추 없음 · 머리 · 층 이름 + * 못 고침(단위 · 일위대가 · 조회 후 선택은 그대로 · PLAN 26-7d). * project = 프로젝트 표 — 바인딩 · 계산 열 잠금 · 손 열만 입력 · 「전구간」 줄 맨 위 · 쪽줄마다 쪽. * 칸을 고치면 문서를 그 자리에서 바꾸고 같은 풀이(`_recalc`)로 즉시 다시 그림 — 서버 왕복 없음. * 저장은 부른 쪽 몫(`onChange` 로 문서를 받음 · 자동저장 없음 · [저장] 때 서버가 Node 로 다시 풂). @@ -49,6 +51,8 @@ export interface SheetOptions { mode: SheetMode; /** 볼 수만 있음(단추 · 고치기 없음) */ readOnly?: boolean; + /** master — 열 더하기 · 지우기 · 머리 · 층 이름 고치기 없음(열 · 머리는 나무가 정함) */ + lockHead?: boolean; onChange?: (doc: SheetDoc) => void; /** 도면 줄 칸 글(마스터 첫 쪽 · 열 id) — 없거나 빈 글이면 「없음」 · 글이 바뀌면 `recalc()` 로 다시 그림. */ drawingLabel?: (colId: string) => string; @@ -84,10 +88,15 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio areaLabel: opts.areaLabel, materialLabel: opts.materialLabel, pick: !!opts.onPick, + headLocked: !!opts.lockHead, }; const ro = !!opts.readOnly; + const editCols = opts.mode === "master" && !ro && !opts.lockHead; // 열 · 머리 고치기 const scroll = el("div", { className: "ui-sheet__scroll" }); - const toolbar = el("div", { className: "ui-sheet__toolbar", attrs: ro ? { hidden: "" } : {} }); + const toolbar = el("div", { + className: "ui-sheet__toolbar", + attrs: ro || opts.lockHead ? { hidden: "" } : {}, + }); const root = el("div", { className: `ui-sheet ui-sheet--${opts.mode}`, attrs: { tabindex: "0" }, @@ -133,7 +142,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio state.sel = state.sel && next ? { r: state.sel.r, c: next.id } : null; changed(); }); - if (opts.mode === "master" && !ro) toolbar.append(colDelete); + if (editCols) toolbar.append(colDelete); // 층마다 열 더하기 단추 — 층이 둘 이상이면 둘째 층부터(「분류1 더하기」 …) · 하나면 「열 더하기」 · // 층 이름 · 층 수가 바뀌면 다시 만듦 @@ -159,7 +168,7 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const syncToolbar = (): void => { const col = state.sel ? colOf(state.sel.c) : undefined; - if (opts.mode === "master" && !ro) syncLevels(!!col); + if (editCols) syncLevels(!!col); colDelete.disabled = !col || state.doc.열.length <= 1; }; @@ -295,8 +304,8 @@ export function createSheet(host: HTMLElement, input: SheetDoc, opts: SheetOptio const th = target.closest("th[data-col]"); const layer = target.closest("th[data-layer]"); if (ro) return; - if (th && state.mode === "master") editHead(th); - else if (layer && state.mode === "master") editLayer(layer); + if (th && editCols) editHead(th); + else if (layer && editCols) editLayer(layer); else if (target.closest("td[data-r]")) edit(); }; const onKey = (event: KeyboardEvent): void => { diff --git a/ui_template/sheet/ui_template_sheet_render.ts b/ui_template/sheet/ui_template_sheet_render.ts index 0ae57807..90e4834c 100644 --- a/ui_template/sheet/ui_template_sheet_render.ts +++ b/ui_template/sheet/ui_template_sheet_render.ts @@ -45,6 +45,8 @@ export interface RenderState { materialLabel?: (colId: string) => string; /** 조회 후 선택 줄(`PICK_ROWS`)을 고름으로 받나 — 칸 겉모양만. */ pick?: boolean; + /** 머리 · 층 이름을 못 고침(`lockHead`) — 칸 겉모양만. */ + headLocked?: boolean; } /** 도면 · 면적산출 · 재료산출 줄 칸 글. */ @@ -194,7 +196,8 @@ export function renderSheet(state: RenderState): HTMLElement { const thead = (): HTMLElement => { const rows = head.map((cells, level) => { - const layer = gutter("th", doc.층?.[level] ?? "", mode === "master" ? "is-editable" : ""); + const heads = mode === "master" && !state.headLocked; + const layer = gutter("th", doc.층?.[level] ?? "", heads ? "is-editable" : ""); layer.dataset.layer = String(level); const tr = el("tr", { children: [layer] }); for (const cell of cells) { @@ -208,7 +211,7 @@ export function renderSheet(state: RenderState): HTMLElement { "data-col": cols[cell.col].id, }, }); - if (mode === "master") th.classList.add("is-editable"); + if (heads) th.classList.add("is-editable"); freeze(th, cell.col, cell.colspan); tr.append(th); } diff --git a/ui_template/ui_template_item_picker.css b/ui_template/ui_template_item_picker.css index 4d8d73dd..44852a47 100644 --- a/ui_template/ui_template_item_picker.css +++ b/ui_template/ui_template_item_picker.css @@ -242,3 +242,30 @@ overflow: auto; } } + +/* 구조물 복사본 상태 줄 — 고침 · 마스터가 바뀜 · 원본 없음 표시 */ +.ui-item-picker__badge { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + color: var(--color-text); +} + +.ui-item-picker__badge.is-warn { + border-color: var(--color-warning); + color: var(--color-text); + font-weight: 600; +} + +/* [적용] 뒤 구조물 [저장] 안내 한 줄 — 단추 왼쪽 */ +.ui-item-picker__saveline { + margin-right: auto; + align-self: center; + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +/* 글 묻는 작은 창(개인에 저장 · 새 항목) */ +.ui-modal.ui-item-ask { + width: min(28rem, 92vw); +} diff --git a/ui_template/ui_template_item_picker.ts b/ui_template/ui_template_item_picker.ts index 5baec0eb..4062fbd8 100644 --- a/ui_template/ui_template_item_picker.ts +++ b/ui_template/ui_template_item_picker.ts @@ -3,8 +3,11 @@ * 항목 고르기 모달 — 구조물집계표 항목(SS 한 줄)을 마스터 · 회사 · 개인 세 그룹 나무에서 고름. * 키(SS · UA …)는 앞에 내세우지 않고 이름으로 보임 · 키는 마우스를 올리면(PLAN.md 21-0). * - * const result = await openItemPicker({ current, canCopy }); + * const result = await openItemPicker({ current, canCopy, projectId, structureId }); * result.action = "apply"(result.item = 21-1 `summary_item`) | "clear" | "cancel" + * + * 26-3b — 카드 · 편집 · [재적용] · [개인에 저장] 은 **고르는 구조물의 복사본**(`structureId`) 기준. + * 나무 개인 그룹 · 폴더를 오른쪽 클릭하면 「새 항목」(프로젝트에서 새 항목 → 개인 층에도 만들고 폼에 지정). * ========================================================================== */ import "./ui_template_item_picker.css"; @@ -27,7 +30,9 @@ import { } from "./ui_template_item_picker_data"; import { t } from "./ui_template_locale"; import { openModal } from "./ui_template_modal"; -import { createTreeRow, renderTree, type TreeNode } from "./ui_template_tree"; +import { createTreeRow, renderTree, type TreeMenuItem, type TreeNode } from "./ui_template_tree"; +import { askFields } from "./ui_template_item_picker_ask"; +import { projectItems, type CreatedItem } from "./ui_template_item_picker_project"; import { createItemDetail, type EditRequest } from "./ui_template_item_picker_detail"; import { createEditView, type EditView } from "./ui_template_item_picker_edit"; @@ -40,6 +45,8 @@ export interface ItemPickerOptions { canCopy: boolean; /** 프로젝트 안에서 부르면 그 id — 카드 [수정] 이 프로젝트 복사본을 고침(없으면 원본 보기만 · 25-2d) */ projectId?: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 복사본이 이 구조물 것 · 없으면(추가 전) 원본 보기만 */ + structureId?: string | null; } export type ItemPickerResult = @@ -88,6 +95,23 @@ async function copyToPersonal(row: PickerRow): Promise { return body?.key ?? null; } +const NEW_TEXT = { + menu: "새 항목", + title: "새 항목", + note: "{value} 안에 새 항목을 만듦 — 개인 층에도 저장되고 이 구조물에 지정됨", + name: "이름", + unit: "단위", + ok: "만들기", + cancel: "취소", + empty: "{label} 을(를) 넣을 것", + made: "새 항목을 만들어 지정했음 — 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", + failed: "새 항목을 만들지 못함 — {value}", + noStructure: "구조물을 먼저 추가해야 새 항목을 이 구조물에 지정할 수 있음", + saveLine: "[적용] 뒤 구조물 [저장]을 눌러야 이 구조물 복사본이 남음", +}; + +const fill = (text: string, value: string): string => text.replace("{value}", value); + const message = (error: unknown): string => error instanceof Error ? error.message : String(error); @@ -106,7 +130,10 @@ export function openItemPicker(opts: ItemPickerOptions): Promise openEdit(request), + // 개인에 저장 — 나무를 다시 받고 새 항목을 고름(개인 그룹에 뜸) + onCreated: (created) => void load(created.key), }); const cols = el("div", { className: "ui-item-picker__cols", @@ -147,11 +174,25 @@ export function openItemPicker(opts: ItemPickerOptions): Promise TreeMenuItem[]) | undefined => { + const folder = node.folder; + if (!folder || folder.layer !== "personal" || !opts.projectId) return undefined; + return () => [ + { + label: NEW_TEXT.menu, + run: () => void createNew(folder.path), + disabledReason: opts.structureId ? undefined : NEW_TEXT.noStructure, + }, + ]; + }; + const toTree = (node: PickNode, searching: boolean): TreeNode => ({ id: node.id, label: node.label, count: node.count, tip: node.tip, + menu: menuOf(node), open: searching || opened.has(node.id), run: node.row ? () => choose(node.row as PickerRow) : undefined, children: node.children?.map((child) => toTree(child, searching)), @@ -234,9 +275,48 @@ export function openItemPicker(opts: ItemPickerOptions): Promise { + if (!opts.projectId || !opts.structureId) return; + const where = [labels().layers.personal, ...path].join(" › "); + const got = await askFields({ + title: NEW_TEXT.title, + note: fill(NEW_TEXT.note, where), + fields: [ + { key: "name", label: NEW_TEXT.name, required: true }, + { key: "unit", label: NEW_TEXT.unit, placeholder: "m · ㎥ · 개소" }, + ], + okLabel: NEW_TEXT.ok, + cancelLabel: NEW_TEXT.cancel, + closeLabel: t("M02_Close"), + emptyText: NEW_TEXT.empty, + }); + if (!got) return; + try { + const made: CreatedItem = await projectItems(opts.projectId).newItem( + opts.structureId, + path, + got.name, + got.unit, + ); + showToast(NEW_TEXT.made, "success"); + finish({ action: "apply", item: made.summary_item as SummaryItem }); + } catch (error) { + showToast(fill(NEW_TEXT.failed, message(error)), "error"); // 회사 없음 409 = 「회사부터 등록」 그대로 + } + } + const actions = el("div", { className: "ui-item-picker__actions", - children: [copyButton, manageButton, clearButton, applyButton], + children: [ + ...(opts.projectId + ? [el("span", { className: "ui-item-picker__saveline", text: NEW_TEXT.saveLine })] + : []), + copyButton, + manageButton, + clearButton, + applyButton, + ], }); /** 카드 [수정] — 모달 안에서 편집 화면으로 바뀜(나무 · 카드 · 단추는 숨김) */ @@ -250,12 +330,13 @@ export function openItemPicker(opts: ItemPickerOptions): Promise | null> { + return new Promise((resolve) => { + let done = false; + const finish = (value: Record | null): void => { + if (done) return; + done = true; + resolve(value); + }; + openModal({ + title: opts.title, + closeLabel: opts.closeLabel, + dialogClass: "ui-item-ask", + backdropClass: "ui-item-ask__backdrop", + beforeClose: () => { + finish(null); + return true; + }, + mount: (body, close) => { + const inputs = opts.fields.map((field) => ({ + field, + input: createInputField({ + label: field.label, + value: field.value ?? "", + placeholder: field.placeholder, + }), + })); + const submit = (): void => { + let bad = false; + for (const { field, input } of inputs) { + const empty = field.required && input.input.value.trim() === ""; + input.setError(empty ? opts.emptyText.replace("{label}", field.label) : undefined); + bad ||= !!empty; + } + if (bad) return; + finish( + Object.fromEntries( + inputs.map(({ field, input }) => [field.key, input.input.value.trim()]), + ), + ); + close(); + }; + for (const { input } of inputs) + input.input.addEventListener("keydown", (event) => { + if (event.key === "Enter") submit(); + }); + body.append( + ...(opts.note ? [el("p", { className: "ui-item-picker__where", text: opts.note })] : []), + ...inputs.map(({ input }) => input.root), + el("div", { + className: "ui-item-picker__actions", + children: [ + createButton({ + label: opts.cancelLabel, + variant: "ghost", + onClick: () => { + finish(null); + close(); + }, + }), + createButton({ label: opts.okLabel, variant: "filled", onClick: submit }), + ], + }), + ); + }, + }); + }); +} diff --git a/ui_template/ui_template_item_picker_data.ts b/ui_template/ui_template_item_picker_data.ts index 602adb86..771957a4 100644 --- a/ui_template/ui_template_item_picker_data.ts +++ b/ui_template/ui_template_item_picker_data.ts @@ -32,6 +32,8 @@ export interface PickerRow { 도번?: string; 면적번?: string; 재료번?: string; + /** 폴더 이름 배열(깊이 자유 · 26-1) — 없으면 종류 · 분류1~3 중 있는 것으로 이음 */ + 경로?: string[] | null; /** 위 키 칸의 이름(21-9 서버가 더함) — 없으면 키를 작은 글로 보임 */ names?: Partial>; layer?: PickerLayer; @@ -43,6 +45,8 @@ export const PICKER_LAYERS: readonly PickerLayer[] = ["system", "company", "pers export interface PickNode { id: string; + /** 어느 그룹 · 폴더 이름 배열 — 폴더 줄만(오른쪽 클릭 메뉴가 「누른 폴더」 를 앎) · 그룹 줄 = 빈 배열 */ + folder?: { layer: PickerLayer; path: string[] }; /** 그룹 · 종류 · 분류 이름 — 잎은 항목 이름 */ label: string; /** 마우스를 올렸을 때 — 잎은 키 */ @@ -59,11 +63,15 @@ const PLACEHOLDER = /^새 분류\s*\d*$/; export const shown = (part?: string): string => part && !PLACEHOLDER.test(part.trim()) ? part : ""; -/** 보이는 항목 이름 — 종류 · 분류1~3 을 이음(자리표는 뺌 · 비면 열 id · 키) */ -export const itemName = (row: PickerRow): string => - [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean).join(" ") || - row.열 || - row.key; +/** 폴더 이름 배열 — 서버가 준 `경로` · 없으면 종류 · 분류1~3 중 있는 것(자리표 · 빈 것은 뺌) */ +export function pathOf(row: PickerRow): string[] { + const own = (row.경로 ?? []).map(shown).filter(Boolean); + if (own.length) return own; + return [row.종류, row.분류1, row.분류2, row.분류3].map(shown).filter(Boolean); +} + +/** 보이는 항목 이름 — 경로를 이음(비면 열 id · 키) */ +export const itemName = (row: PickerRow): string => pathOf(row).join(" ") || row.열 || row.key; /** 마우스를 올렸을 때 — 층 이름 + 키 */ export const rowTip = (row: PickerRow, labels: TreeLabels): string => @@ -113,49 +121,58 @@ export interface TreeLabels { unclassified: string; } -/** 그룹 → 종류 → 분류1 → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ +const folderId = (layer: PickerLayer, path: string[]): string => [layer, ...path].join("|"); + +/** 그룹 → 폴더(경로 앞부분 · 깊이 자유) → 항목(잎). 서버가 준 차례를 지킴 · 빈 그룹도 한 줄 남김. */ export function buildTree(rows: PickerRow[], labels: TreeLabels): PickNode[] { return PICKER_LAYERS.map((layer) => { - const mine = rows.filter((row) => layerOf(row) === layer); - const kinds = new Map>(); - for (const row of mine) { - const kind = shown(row.종류) || labels.unclassified; - const cat = shown(row.분류1) || labels.unclassified; - const cats = kinds.get(kind) ?? new Map(); - cats.set(cat, [...(cats.get(cat) ?? []), row]); - kinds.set(kind, cats); + const root: PickNode = { + id: layer, + label: labels.layers[layer], + count: 0, + children: [], + folder: { layer, path: [] }, + }; + const folders = new Map([[layer, root]]); + for (const row of rows.filter((one) => layerOf(one) === layer)) { + let parent = root; + const path = pathOf(row).slice(0, -1); + path.forEach((name, index) => { + const here = path.slice(0, index + 1); + const id = folderId(layer, here); + let node = folders.get(id); + if (!node) { + node = { id, label: name, count: 0, children: [], folder: { layer, path: here } }; + folders.set(id, node); + parent.children?.push(node); + } + parent = node; + }); + parent.children?.push({ + id: rowId(row), + label: itemName(row), + tip: rowTip(row, labels), + count: null, + row, + }); + for (const id of ancestorIds(layer, path)) { + const node = folders.get(id); + if (node) node.count = (node.count ?? 0) + 1; + } } - const kindNodes: PickNode[] = [...kinds].map(([kind, cats]) => { - const catNodes: PickNode[] = [...cats].map(([cat, leaves]) => ({ - id: `${layer}|${kind}|${cat}`, - label: cat, - count: leaves.length, - children: leaves.map((row) => ({ - id: rowId(row), - label: itemName(row), - tip: rowTip(row, labels), - count: null, - row, - })), - })); - return { - id: `${layer}|${kind}`, - label: kind, - count: catNodes.reduce((sum, node) => sum + (node.count ?? 0), 0), - children: catNodes, - }; - }); - return { id: layer, label: labels.layers[layer], count: mine.length, children: kindNodes }; + return root; }); } +/** 잎까지 가는 길의 나무 줄 id(그룹 · 폴더) */ +const ancestorIds = (layer: PickerLayer, path: string[]): string[] => [ + layer, + ...path.map((_, index) => folderId(layer, path.slice(0, index + 1))), +]; + /** 잎까지 가는 길의 나무 줄 id — 처음부터 펼칠 자리 */ -export function pathIds(row: PickerRow, labels: TreeLabels): string[] { - const layer = layerOf(row); - const kind = shown(row.종류) || labels.unclassified; - const cat = shown(row.분류1) || labels.unclassified; - return [layer, `${layer}|${kind}`, `${layer}|${kind}|${cat}`]; -} +export const pathIds = (row: PickerRow, _labels?: TreeLabels): string[] => + ancestorIds(layerOf(row), pathOf(row).slice(0, -1)); export type FolderKind = "drawing" | "area" | "material" | "combo" | "unitPrice"; diff --git a/ui_template/ui_template_item_picker_detail.ts b/ui_template/ui_template_item_picker_detail.ts index 7abeeda4..482a3b23 100644 --- a/ui_template/ui_template_item_picker_detail.ts +++ b/ui_template/ui_template_item_picker_detail.ts @@ -1,12 +1,14 @@ /* ============================================================================= * ui_template_item_picker_detail.ts - * 항목 고르기 모달 오른쪽 상세(계획서 25-2d) — 글 목록이던 것을 **카드 넷**(도면 · 면적산출 · 재료산출 · - * 선택 입력)으로. 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 + * 항목 고르기 모달 오른쪽 상세(계획서 25-2d · 26-3b) — **카드 넷**(도면 · 면적산출 · 재료산출 · 선택 입력). + * 카드는 M02 와 같은 한 벌(`M02_MasterTemplete_ItemCards` · `_Inputs`)을 늦게 받아 씀 * (B05 · B06 번들에 CAD · 스프레드시트가 안 섞이게 `import()`). * - * 자료 자리 — 프로젝트에 가져온 항목은 **프로젝트 복사본**을 읽고, 아직이면 **원본 층**을 읽음(읽기만). - * [수정] 은 복사본만 고침 — 안 가져온 항목은 「프로젝트로 가져와 고침」(미리 가져오기 뒤 편집). - * 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈. 편집 화면은 `ui_template_item_picker_edit.ts`. + * 자료 자리 — **그 구조물의 복사본**(폴더 = 구조물 id · 같은 항목을 고른 구조물도 복사본은 따로)을 읽고, + * 그 구조물에 아직 없으면 **원본 층**을 읽음(읽기만). [수정] 은 복사본만 고침 — 아직이면 「이 구조물로 가져와 고침」 + * (미리 가져오기 뒤 편집). [재적용] = 원본을 다시 가져옴(덮이는 것을 확인 창으로 알림) · + * [개인에 저장] = 이 구조물 복사본을 개인 층 새 항목으로. 마스터 · 회사 · 개인 원본은 이 모달이 안 바꿈 + * (개인에 저장 · 새 항목은 개인 층에 **새 항목**을 더할 뿐). 편집 화면은 `ui_template_item_picker_edit.ts`. * ========================================================================== */ import { createButton, el, showConfirmDialog, showToast } from "./ui_template_elements"; @@ -19,20 +21,23 @@ import type { EditKind, ItemCardsHandle } from "../M02_MasterTemplete/M02_Master import type { ItemSource } from "../M02_MasterTemplete/M02_MasterTemplete_ItemSource"; import type { InputsValueHandle } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs"; import type { ValueDoc } from "../M02_MasterTemplete/M02_MasterTemplete_Inputs_Model"; +import { askFields } from "./ui_template_item_picker_ask"; import { folderLines, itemName, layerOf, + pathOf, rowTip, - shown, type PickerLayer, type PickerRow, } from "./ui_template_item_picker_data"; import { - folderOf, loadInputs, PART_OF, projectItems, + sameItem, + structureFolder, + type CreatedItem, type ProjectItemInfo, type ProjectItems, } from "./ui_template_item_picker_project"; @@ -49,10 +54,14 @@ export interface EditRequest { export interface ItemDetailOptions { /** 프로젝트 안에서 열렸으면 그 id — 없으면 원본 보기만(고침 없음) */ projectId: string | null; + /** 고르는 구조물의 id(`structure_id` · 관 지점 `pipe@측점`) — 아직 추가 전이면 null = 복사본 없음 */ + structureId: string | null; layerLabels: Record; hint: string; unit: string; onEdit: (request: EditRequest) => void; + /** [개인에 저장] 으로 개인 층에 새 항목이 생김 — 나무를 다시 받고 그 항목을 고름 */ + onCreated: (created: CreatedItem) => void; } export interface ItemDetail { @@ -60,24 +69,56 @@ export interface ItemDetail { show: (row: PickerRow | null) => void; /** 편집을 마치고 돌아옴 — 지금 항목을 다시 읽음 */ reload: () => void; + /** 이 구조물 복사본에 고침이 생김 — 목록을 다시 받지 않고 표시만 */ + markEdited: () => void; } const TEXT = { - importEdit: "프로젝트로 가져와 고침", - notImported: "프로젝트에 아직 안 가져옴 — 원본을 보고 있음", - imported: "프로젝트에 가져옴 {value}", - changed: "마스터가 바뀜 — 프로젝트 복사본은 가져온 때 그대로", - refresh: "새로 가져오기", - refreshAsk: "프로젝트 복사본의 고친 내용이 원본으로 덮임 — 새로 가져올까요?", - importFailed: "프로젝트로 가져오지 못함 — {value}", - refreshFailed: "새로 가져오지 못함 — {value}", + importEdit: "이 구조물로 가져와 고침", + noStructure: + "구조물을 먼저 추가하면 이 항목을 그 구조물로 가져와 고칠 수 있음 — 지금은 원본을 보고 있음", + notImported: "이 구조물에는 아직 안 가져옴 — 원본을 보고 있음", + otherItem: "이 구조물의 복사본은 「{value}」 — [수정]하면 이 항목으로 바뀜", + otherEdited: + "이 구조물의 복사본 「{value}」 에서 고친 것이 있음 — 이 항목으로 바꾸면 그 고침은 _지난 폴더로 옮겨짐. 바꿀까요?", + imported: "이 구조물 복사본 · 가져옴 {value}", + edited: "고침 있음", + changed: "마스터가 바뀜 — 복사본은 가져온 때 그대로", + gone: "원본이 없어짐 — 복사본은 그대로 씀", + reapply: "재적용", + reapplyTip: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 것(도면 · 시트 · 선택 입력 자리)이 원본 것으로 덮임", + reapplyAsk: + "원본을 다시 가져옴 — 이 구조물 복사본에서 고친 도면 · 면적산출 · 재료산출 · 선택 입력 자리가 원본 것으로 덮임(넣은 값은 같은 키면 남음). 재적용할까요?", + reapplied: "재적용했음 — 덮인 것: {value}", + reappliedNone: "재적용했음 — 덮인 것 없음(원본과 같았음)", + reapplyFailed: "재적용하지 못함 — {value}", + saveAs: "개인에 저장", + saveAsTip: "이 구조물 복사본을 내 개인 층의 새 항목으로 저장 — 단가 · 넣은 값은 안 가져감", + saveAsTitle: "개인에 저장", + saveAsNote: "이 구조물 복사본을 개인 층 새 항목으로 저장함 — 경로는 폴더 이름을 「/」 로 이음", + pathLabel: "경로(폴더)", + nameLabel: "이름", + saved: "개인 층에 저장했음", + saveFailed: "개인에 저장하지 못함 — {value}", + ok: "확인", + cancel: "취소", + close: "닫기", + empty: "{label} 을(를) 넣을 것", + importFailed: "이 구조물로 가져오지 못함 — {value}", listFailed: "프로젝트 항목을 못 읽음 — {value}", calc: "복사본 계산 — 계 {total}원 · 빠진 입력 {gaps}개", calcStuck: " · 멈춘 로직 {stuck}개", - loading: "읽는 중…", noCopy: "복사본에 없음", combo: "자재조합", price: "일위대가", + parts: { + 도면: "도면", + 면적: "면적산출", + 재료: "재료산출", + 자재조합: "자재조합", + 입력연결: "선택 입력 자리", + } as Record, }; const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); @@ -97,14 +138,19 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { let cards: ItemCardsHandle | null = null; let inputs: InputsValueHandle | null = null; let source: ItemSource = { layer: "system", projectId: null, read: () => Promise.reject() }; + /** 이 구조물에 있는 복사본 — 고른 항목의 것일 때만(다른 항목의 것이면 `other`) */ let info: ProjectItemInfo | null = null; + let other: ProjectItemInfo | null = null; const items: ProjectItems | null = opts.projectId ? projectItems(opts.projectId) : null; + const folder = opts.structureId ? structureFolder(opts.structureId) : null; + /** 복사본을 고칠 수 있나 — 프로젝트 안 · 구조물이 있을 때 */ + const canCopy = !!items && !!folder; const small = (text: string): HTMLElement => el("p", { className: "ui-item-picker__where", text }); const paintHead = (row: PickerRow): void => { - const where = [opts.layerLabels[layerOf(row)], shown(row.종류), shown(row.분류1)] + const where = [opts.layerLabels[layerOf(row)], ...pathOf(row).slice(0, -1)] .filter(Boolean) .join(" › "); const pairs = folderLines(row).filter( @@ -126,7 +172,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); }; - /** 프로젝트 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ + /** 이 구조물 복사본 또는 원본 층에서 열 하나의 문서를 읽음 — 없으면 던짐(카드가 「없음」) */ const makeSource = (row: PickerRow): ItemSource => info && opts.projectId ? { @@ -144,20 +190,28 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { read: (part, column) => readTemplate(layerOf(row), part, column, null), }; - const paintStatus = async (row: PickerRow, mine: number): Promise => { + const badge = (text: string, className = ""): HTMLElement => + el("span", { className: `ui-item-picker__badge ${className}`.trim(), text }); + + const paintStatus = (row: PickerRow, mine: number): void => { if (!items) return void (status.hidden = true); const nodes: (HTMLElement | string)[] = []; - if (!info) nodes.push(TEXT.notImported); + if (!folder) nodes.push(TEXT.noStructure); + else if (other) nodes.push(fill(TEXT.otherItem, other.이름 ?? other.원본.key)); + else if (!info) nodes.push(TEXT.notImported); else { nodes.push(fill(TEXT.imported, dateOf(info.가져온날))); - if (info.마스터바뀜) { - const button = createButton({ label: TEXT.refresh, variant: "ghost", size: "sm" }); - button.addEventListener("click", () => void refresh(row)); - nodes.push( - el("strong", { className: "ui-item-picker__changed", text: TEXT.changed }), - button, - ); - } + if (info.고침) nodes.push(badge(TEXT.edited)); + if (info.원본없음) nodes.push(badge(TEXT.gone, "is-warn")); + else if (info.마스터바뀜) nodes.push(badge(TEXT.changed, "is-warn")); + const reapply = createButton({ label: TEXT.reapply, variant: "ghost", size: "sm" }); + reapply.title = TEXT.reapplyTip; + reapply.disabled = !!info.원본없음; + reapply.addEventListener("click", () => void reapplyNow(row)); + const saveAs = createButton({ label: TEXT.saveAs, variant: "ghost", size: "sm" }); + saveAs.title = TEXT.saveAsTip; + saveAs.addEventListener("click", () => void saveAsNow(row)); + nodes.push(reapply, saveAs); } status.replaceChildren(...nodes); status.hidden = false; @@ -179,25 +233,66 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { ); } - async function refresh(row: PickerRow): Promise { - if (!items || !info || !(await showConfirmDialog(TEXT.refreshAsk))) return; + /** 목록은 모달에서 한 번만 받고 바뀐 줄만 갈아 끼움 */ + const upsert = (next: ProjectItemInfo): void => { + infos = [...(infos ?? []).filter((one) => one.폴더 !== next.폴더), next]; + }; + + async function reapplyNow(row: PickerRow): Promise { + if (!items || !info || !(await showConfirmDialog(TEXT.reapplyAsk, TEXT.reapply))) return; try { - const fresh = await items.refresh(info); - infos = (infos ?? []).map((one) => (one.폴더 === fresh.폴더 ? fresh : one)); + const got = await items.reapply(info); + upsert(got.info); + const names = got.covered.map((part) => TEXT.parts[part] ?? part); + showToast( + names.length ? fill(TEXT.reapplied, names.join(" · ")) : TEXT.reappliedNone, + "success", + ); show(row); } catch (error) { - showToast(fill(TEXT.refreshFailed, why(error)), "error"); + showToast(fill(TEXT.reapplyFailed, why(error)), "error"); } } - /** [수정] — 안 가져온 항목이면 먼저 프로젝트로 가져옴 */ + async function saveAsNow(row: PickerRow): Promise { + if (!items || !info) return; + const got = await askFields({ + title: TEXT.saveAsTitle, + note: TEXT.saveAsNote, + fields: [ + { key: "path", label: TEXT.pathLabel, value: pathOf(row).slice(0, -1).join("/") }, + { key: "name", label: TEXT.nameLabel, value: itemName(row), required: true }, + ], + okLabel: TEXT.ok, + cancelLabel: TEXT.cancel, + closeLabel: TEXT.close, + emptyText: TEXT.empty, + }); + if (!got) return; + try { + const path = got.path + .split("/") + .map((part) => part.trim()) + .filter(Boolean); + const created = await items.saveAs(info.폴더, path, got.name); + showToast(TEXT.saved, "success"); + opts.onCreated(created); + } catch (error) { + showToast(fill(TEXT.saveFailed, why(error)), "error"); // 회사 없음 409 등 서버 까닭 그대로 + } + } + + /** [수정] — 이 구조물에 아직 없으면(또는 다른 항목이면) 먼저 이 구조물로 가져옴 */ async function edit(kind: EditKind | "inputs"): Promise { const row = current; - if (!row || !items || !opts.projectId) return; + if (!row || !items || !opts.projectId || !opts.structureId) return; if (!info) { + const ask = other?.고침 ? fill(TEXT.otherEdited, other.이름 ?? other.원본.key) : null; + if (ask && !(await showConfirmDialog(ask))) return; try { - info = await items.importItem(row); - infos = [...(infos ?? []), info]; + info = await items.importItem(row, opts.structureId); + other = null; + upsert(info); } catch (error) { showToast(fill(TEXT.importFailed, why(error)), "error"); return; @@ -234,7 +329,7 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { inputs = inputModule.createInputsValueCard({ mode: "view", onEdit: () => void edit("inputs") }); cards = cardModule.createItemCards({ source: () => source, - editable: () => !!items, + editable: () => canCopy, onEdit: (kind) => void edit(kind), warnOnFail: () => false, extraCards: [inputs.root], @@ -256,25 +351,34 @@ export function createItemDetail(opts: ItemDetailOptions): ItemDetail { paintHead(row); void (async () => { try { - if (items) infos ??= await items.list(); + if (items && folder) infos ??= await items.list(); } catch (error) { if (mine === seq) showToast(fill(TEXT.listFailed, why(error)), "error"); infos = []; } if (mine !== seq) return; - info = infos?.find((one) => one.폴더 === folderOf(row)) ?? null; + const own = infos?.find((one) => one.폴더 === folder) ?? null; + info = own && sameItem(own, row) ? own : null; + other = own && !info ? own : null; source = makeSource(row); await ensureCards(); if (mine !== seq || !cards || !inputs) return; - const label = info ? null : TEXT.importEdit; + const label = !info && canCopy ? TEXT.importEdit : null; cards.relabel({ structure: label, area: label, material: label }); inputs.setEditLabel(label ?? "선택 입력 수정"); cards.root.hidden = false; - void paintStatus(row, mine); + paintStatus(row, mine); void cards.load({ id: row.열, label: itemName(row) }); void paintInputs(row, mine); })(); } - return { root, show, reload: () => show(current) }; + return { + root, + show, + reload: () => show(current), + markEdited: () => { + if (info) upsert({ ...info, 고침: true }); + }, + }; } diff --git a/ui_template/ui_template_item_picker_edit.ts b/ui_template/ui_template_item_picker_edit.ts index abe7996c..9aa0b4b6 100644 --- a/ui_template/ui_template_item_picker_edit.ts +++ b/ui_template/ui_template_item_picker_edit.ts @@ -48,10 +48,11 @@ const TEXT = { const why = (error: unknown): string => (error instanceof Error ? error.message : String(error)); const fill = (text: string, value: string): string => text.replace("{value}", value); -export function createEditView(req: EditRequest, onBack: () => void): EditView { +export function createEditView(req: EditRequest, onBack: (saved: boolean) => void): EditView { const { kind, row, info, items } = req; let dirty = false; let saving = false; + let saved = false; // 한 번이라도 저장했나 — 돌아갈 때 「고침 있음」 표시 let doc: unknown = null; let drawing: DrawingTemplateHandle | null = null; let sheet: SpreadsheetHandle | null = null; @@ -191,6 +192,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { doc = merged; } setDirty(false); + saved = true; showToast(TEXT.saved, "success"); } catch (error) { showToast(fill(TEXT.saveFailed, why(error)), "error"); @@ -211,7 +213,7 @@ export function createEditView(req: EditRequest, onBack: () => void): EditView { save.addEventListener("click", () => void doSave()); back.addEventListener("click", async () => { - if (await leave()) onBack(); + if (await leave()) onBack(saved); }); return { root, start: () => void start(), isDirty: () => dirty, leave, destroy }; diff --git a/ui_template/ui_template_item_picker_project.ts b/ui_template/ui_template_item_picker_project.ts index 7275b48a..27366224 100644 --- a/ui_template/ui_template_item_picker_project.ts +++ b/ui_template/ui_template_item_picker_project.ts @@ -2,11 +2,13 @@ * ui_template_item_picker_project.ts * 항목 고르기 모달의 **자료 창구**(계획서 25-2d) — 프로젝트 복사본 · 입력 목록 · 계산을 한 곳에서 부름. * - * 프로젝트 복사본(sub6 · 25-2b) - * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, …}]} - * POST /api/projects/{id}/items {layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * 구조물 복사본(sub6 · 26-1 · 26-2b) — 폴더 = 구조물 id(같은 항목을 고른 구조물도 복사본은 따로) + * GET /api/projects/{id}/items → {항목: [{폴더, 이름, 원본, 원본판, 가져온날, 마스터바뀜, 원본없음, …}]} + * POST /api/projects/{id}/items {structure, layer, owner, key} 미리 가져오기 → {폴더, 만듦, 가져온날} + * POST /api/projects/{id}/items/new {structure, 경로, 이름, 단위} 새 항목(개인 층에도) → {…, summary_item} * GET|PUT /api/projects/{id}/items/{폴더}/{부분} → {종류, 판, 내용} · 고침은 {판, 내용}(판이 다르면 409) - * POST …/{폴더}/refresh 새로 가져오기(입력값은 같은 키면 살림) + * POST …/{폴더}/reapply 재적용 → {폴더, 가져온날, 원본판, 덮임: [부분]} + * POST …/{폴더}/save-as {to, 경로, 이름} 개인에 저장 → {key, 열, 이름, summary_item} * POST …/{폴더}/calc 복사본만으로 계산(계 · 빠진 입력) * 입력 목록 · 항목 연결(sub4 · 25-2a) — `M02_MasterTemplete_Api_Fetch` 의 `fetchItemInputs` · `readInputLinks` * ========================================================================== */ @@ -42,9 +44,27 @@ export interface ProjectItemInfo { 고침?: boolean; } -/** `{층}_{주인 슬러그}_{키}` — 서버 `snapshot_folder_name` 과 같은 글(owner 없음 = none · `/` = `-`) */ -export const folderOf = (row: PickerRow): string => - `${layerOf(row)}_${(ownerOf(row) ?? "none").replace(/\//g, "-")}_${row.key}`; +/** 구조물 id → 복사본 폴더 이름 — 서버 `folder_name` 과 같은 글(id 없는 옛 구조물 `#차례` = `n차례`) */ +export const structureFolder = (structureId: string): string => structureId.replace("#", "n"); + +/** 관 지점의 구조물 id — 서버 `pipe@{round(측점, 3)}`(파이썬 float 글: 정수도 `100.0`) */ +export function pipeStructureId(chainageM: number): string { + const rounded = Math.round(chainageM * 1000) / 1000; + return `pipe@${Number.isInteger(rounded) ? rounded.toFixed(1) : String(rounded)}`; +} + +/** 이 복사본이 그 항목의 것인가 — 같은 구조물이 다른 항목으로 바뀌면 원본 참조가 다름 */ +export const sameItem = (info: ProjectItemInfo, row: PickerRow): boolean => + info.원본.layer === layerOf(row) && + (info.원본.owner ?? null) === ownerOf(row) && + info.원본.key === row.key; + +/** 개인 층에 새로 생긴 항목 — `summary_item` 을 폼에 지정하면 됨 */ +export interface CreatedItem { + key: string; + 이름?: string; + summary_item: { layer: string; owner: string | null; key: string; name: string }; +} /** 복사본 계산 결과에서 모달이 보이는 것 — 계 · 빠진 입력 수 · 멈춘 로직 수 */ export interface ProjectCalc { @@ -94,9 +114,18 @@ export interface ProjectItems { /** 복사본 고침 — 못 하면 서버 까닭 그대로 던짐(권한 · 판이 다름 …) */ write: (folder: string, part: ProjectPart, doc: unknown) => Promise; /** 프로젝트로 미리 가져옴 */ - importItem: (row: PickerRow) => Promise; - /** 새로 가져옴 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) */ - refresh: (info: ProjectItemInfo) => Promise; + importItem: (row: PickerRow, structureId: string) => Promise; + /** 재적용 — 복사본을 원본으로 덮음(넣은 값은 같은 키면 서버가 살림) · `covered` = 덮인 부분 */ + reapply: (info: ProjectItemInfo) => Promise<{ info: ProjectItemInfo; covered: string[] }>; + /** 이 구조물 복사본을 개인 층 새 항목으로 — 못 하면 서버 까닭(회사 없음 409 …) 그대로 던짐 */ + saveAs: (folder: string, path: string[], name: string) => Promise; + /** 프로젝트에서 새 항목(개인 층에도 만들고 그 구조물 복사본으로) */ + newItem: ( + structureId: string, + path: string[], + name: string, + unit: string, + ) => Promise; /** 복사본만으로 계산 — 계산할 것이 없거나 못 하면 null */ calc: (folder: string) => Promise; } @@ -109,6 +138,10 @@ export function projectItems(projectId: string): ProjectItems { if (kept) return kept; const base = `/projects/${enc(projectId)}/items`; const versions = new Map(); + /** 그 폴더에서 읽어 둔 판을 버림(다시 가져오면 판이 바뀜) */ + const forget = (folder: string): void => { + for (const key of [...versions.keys()]) if (key.startsWith(`${folder}/`)) versions.delete(key); + }; const at = (folder: string, part: string): string => `${base}/${enc(folder)}/${enc(part)}`; const client: ProjectItems = { @@ -136,27 +169,50 @@ export function projectItems(projectId: string): ProjectItems { if (reply.status >= 400) throw new Error(reasonOf(reply)); versions.set(key, (reply.body as { 판?: string } | null)?.판 ?? null); }, - importItem: async (row) => { + importItem: async (row, structureId) => { const ref = { layer: layerOf(row), owner: ownerOf(row), key: row.key }; - const reply = await call(base, post(ref)); + const reply = await call(base, post({ structure: structureId, ...ref })); if (reply.status >= 300) throw new Error(reasonOf(reply)); const got = reply.body as { 폴더: string; 가져온날?: string | null }; + forget(got.폴더); return { 폴더: got.폴더, 원본: ref, 가져온날: got.가져온날, 마스터바뀜: false }; }, - refresh: async (info) => { - const reply = await call(`${base}/${enc(info.폴더)}/refresh`, post({})); + reapply: async (info) => { + const reply = await call(`${base}/${enc(info.폴더)}/reapply`, post({})); if (reply.status >= 300) throw new Error(reasonOf(reply)); - for (const key of [...versions.keys()]) - if (key.startsWith(`${info.폴더}/`)) versions.delete(key); + forget(info.폴더); inputsCache.clear(); // 원본이 바뀌었을 수 있어 입력 목록도 다시 받음 - const got = reply.body as { 가져온날?: string | null; 원본판?: string | null }; - return { - ...info, - 가져온날: got.가져온날, - 원본판: got.원본판, - 마스터바뀜: false, - 고침: false, + const got = reply.body as { + 가져온날?: string | null; + 원본판?: string | null; + 덮임?: string[]; }; + return { + info: { + ...info, + 가져온날: got.가져온날, + 원본판: got.원본판, + 마스터바뀜: false, + 고침: false, + }, + covered: got.덮임 ?? [], + }; + }, + saveAs: async (folder, path, name) => { + const reply = await call( + `${base}/${enc(folder)}/save-as`, + post({ to: "personal", 경로: path, 이름: name }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; + }, + newItem: async (structureId, path, name, unit) => { + const reply = await call( + `${base}/new`, + post({ structure: structureId, 경로: path, 이름: name, 단위: unit }), + ); + if (reply.status >= 300) throw new Error(reasonOf(reply)); + return reply.body as CreatedItem; }, calc: async (folder) => { const reply = await call(`${base}/${enc(folder)}/calc`, post({})); diff --git a/ui_template/ui_template_locale_m2.ts b/ui_template/ui_template_locale_m2.ts index f9b30d72..595678b6 100644 --- a/ui_template/ui_template_locale_m2.ts +++ b/ui_template/ui_template_locale_m2.ts @@ -6,7 +6,7 @@ export const ui_locales_m2 = { B01_Dashboard_MasterTemplate: ["마스터 템플릿", "Master Templates"], M02_Title: ["마스터 템플릿", "Master Templates"], - M02_KindTable: ["구조물 집계 양식", "Structure summary templates"], + M02_KindTable: ["구조물 조합", "Structure combos"], M02_KindDrawing: ["도면 양식", "Drawing templates"], M02_KindStructure: ["구조물 도면", "Structure drawings"], M02_KindArea: ["면적산출", "Area takeoff"], @@ -66,11 +66,11 @@ export const ui_locales_m2 = { "Company templates are read-only — copy the selected column to your own to edit", ], M02_CopyToPersonal: ["개인용으로 복사", "Copy to personal"], - M02_CopyPickFirst: ["복사할 열을 집계표에서 먼저 고름", "Pick a column in the table first"], + M02_CopyPickFirst: ["복사할 열을 구조물 조합에서 먼저 고름", "Pick a column in the table first"], M02_CopyDone: ["개인용으로 복사함 — 구조물 관리(개인)에서 고침", "Copied — edit it in your own"], M02_CopyFailed: ["복사 안 됨 — {value}", "Copy failed — {value}"], M02_NeedCompany: [ - "회사부터 등록해야 구조물 집계표를 만들 수 있음", + "회사부터 등록해야 구조물 조합을 만들 수 있음", "Register a company first to manage structure templates", ], M02_NeedCompanyGo: ["회사 등록하러 가기", "Go to company registration"], @@ -97,4 +97,42 @@ export const ui_locales_m2 = { M02_PickerManage: ["관리 화면에서 고치기", "Edit in manager"], M02_PickerCopied: ["개인용으로 복사했음", "Copied to personal"], M02_PickerCopyFailed: ["복사 못 함 — {value}", "Copy failed — {value}"], + M02_TreeTitle: ["항목 나무", "Item tree"], + M02_TreeSearch: ["항목 찾기", "Find item"], + M02_TreeEmptyEdit: [ + "항목이 없음 — 맨 위 줄을 오른쪽 클릭해 폴더 · 항목을 더함", + "No items — right-click the top row to add a folder or item", + ], + M02_TreeEmpty: ["항목이 없음", "No items"], + M02_TreeNoHit: ["찾은 항목이 없음", "No matching item"], + M02_TreeAddFolder: ["폴더 더하기", "Add folder"], + M02_TreeAddItem: ["항목 더하기", "Add item"], + M02_TreeRename: ["이름 바꾸기", "Rename"], + M02_TreeMove: ["옮기기", "Move"], + M02_TreeFolderName: ["폴더 이름", "Folder name"], + M02_TreeItemName: ["항목 이름", "Item name"], + M02_TreeUnit: ["단위", "Unit"], + M02_TreeMoveTo: ["옮길 폴더", "Move to"], + M02_TreeTop: ["맨 위 (폴더 밖)", "Top (no folder)"], + M02_TreeOk: ["확인", "OK"], + M02_TreeCancel: ["취소", "Cancel"], + M02_TreeDeleteFolder: [ + "「{value}」 폴더와 안의 항목 {n}개를 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)", + "Delete folder “{value}” and its {n} item(s) — projects already using them are not affected (they keep their own copies)", + ], + M02_TreeDeleteItem: [ + "「{value}」 항목을 지움 — 이미 쓰는 프로젝트에는 영향 없음(프로젝트는 복사본을 씀)", + "Delete item “{value}” — projects already using it are not affected (they keep their own copies)", + ], + M02_TreeStale: [ + "그 사이 나무가 바뀜 — 다시 읽었음, 한 번 더 해 볼 것", + "The tree changed meanwhile — reloaded, try again", + ], + M02_TreeUnsaved: [ + "저장 안 한 표 고침이 있음 — 나무를 고치기 전에 처리", + "Unsaved table edits — settle them before changing the tree", + ], + M02_TreeSaveGo: ["저장하고 계속", "Save and continue"], + M02_TreeDiscardGo: ["버리고 계속", "Discard and continue"], + M02_TreeDone: ["나무를 고침", "Tree updated"], } as const; diff --git a/ui_template/ui_template_tree.css b/ui_template/ui_template_tree.css index a8a47253..18c80691 100644 --- a/ui_template/ui_template_tree.css +++ b/ui_template/ui_template_tree.css @@ -21,3 +21,38 @@ color: var(--color-text-secondary); font-size: var(--text-caption); } + +/* 오른쪽 클릭 메뉴 — 모달(z-index 1000) 위 */ +.ui-tree__menu { + position: fixed; + z-index: 1100; + display: flex; + flex-direction: column; + min-width: 10rem; + padding: var(--spacing-4); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-surface-raised); + box-shadow: var(--shadow-lg); +} + +.ui-tree__menu-item { + padding: var(--spacing-8) var(--spacing-12); + border: 0; + border-radius: var(--radius-md); + background: none; + color: var(--color-text); + font: inherit; + text-align: left; + cursor: pointer; +} + +.ui-tree__menu-item:hover:not(:disabled), +.ui-tree__menu-item:focus-visible { + background: var(--color-surface-sunken); +} + +.ui-tree__menu-item:disabled { + color: var(--color-text-secondary); + cursor: not-allowed; +} diff --git a/ui_template/ui_template_tree.ts b/ui_template/ui_template_tree.ts index b33cbc3b..42eb0c19 100644 --- a/ui_template/ui_template_tree.ts +++ b/ui_template/ui_template_tree.ts @@ -2,6 +2,8 @@ * ui_template_tree.ts * 공용 나무 보기 — 부모(접기/펴기) · 자식(깊이 들임) · 잎 고름. M01 왼쪽 패널과 항목 고르기가 함께 씀. * 줄 모양은 부르는 쪽이 `MakeRow` 로 정함(기본 줄 = `createTreeRow`). + * 오른쪽 클릭 메뉴 = 줄마다 `menu()` 가 **메뉴 항목을 밖에서 줌**(고르기 모달 · 관리 화면 나무가 같은 자리를 씀 · + * 26-3) — 항목이 없으면 브라우저 기본 메뉴 그대로. * ========================================================================== */ import "./ui_template_tree.css"; @@ -19,6 +21,60 @@ export interface TreeNode { open?: boolean; /** 마우스를 올렸을 때 글(기본 줄) */ tip?: string; + /** 오른쪽 클릭 메뉴 항목 — 부를 때마다 새로 받음 · 빈 목록 · 없음 = 메뉴 없음(기본 줄) */ + menu?: () => TreeMenuItem[]; +} + +export interface TreeMenuItem { + label: string; + run: () => void; + /** 못 누르는 까닭 — 있으면 흐리고 마우스를 올리면 까닭이 보임 */ + disabledReason?: string; +} + +/** 오른쪽 클릭 자리에 작은 메뉴 — 바깥 누르기 · Esc · 스크롤 = 닫힘 */ +export function openTreeMenu(event: MouseEvent, items: TreeMenuItem[]): void { + document.querySelector(".ui-tree__menu")?.remove(); + const menu = el("div", { className: "ui-tree__menu", attrs: { role: "menu" } }); + const close = (): void => { + menu.remove(); + document.removeEventListener("pointerdown", onOutside, true); + document.removeEventListener("keydown", onKey, true); + document.removeEventListener("scroll", close, true); + }; + const onOutside = (down: Event): void => { + if (!menu.contains(down.target as Node)) close(); + }; + const onKey = (key: KeyboardEvent): void => { + if (key.key === "Escape") { + key.stopPropagation(); // 모달의 Esc(취소)까지 가지 않게 + close(); + } + }; + for (const item of items) { + const button = el("button", { + className: "ui-tree__menu-item", + text: item.label, + attrs: { type: "button", role: "menuitem" }, + }); + if (item.disabledReason) { + button.disabled = true; + button.title = item.disabledReason; + } else { + button.addEventListener("click", () => { + close(); + item.run(); + }); + } + menu.append(button); + } + document.body.append(menu); + const room = menu.getBoundingClientRect(); + menu.style.left = `${Math.max(4, Math.min(event.clientX, innerWidth - room.width - 4))}px`; + menu.style.top = `${Math.max(4, Math.min(event.clientY, innerHeight - room.height - 4))}px`; + document.addEventListener("pointerdown", onOutside, true); + document.addEventListener("keydown", onKey, true); + document.addEventListener("scroll", close, true); } /** 줄(버튼) 만들기 — 돌려받은 것 = 그 버튼 · click 은 부르는 쪽이 이음 */ @@ -72,5 +128,11 @@ export function createTreeRow( button.style.paddingLeft = `calc(var(--spacing-8) + ${depth} * var(--spacing-16))`; if (node.tip) button.title = node.tip; button.addEventListener("click", onClick); + button.addEventListener("contextmenu", (event) => { + const items = node.menu?.() ?? []; + if (!items.length) return; + event.preventDefault(); + openTreeMenu(event, items); + }); return button; }