Merge remote-tracking branch 'origin/main_laptop_1' into main_desktop_1
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"""B03 원본 입력 파일 메타데이터 분석."""
|
||||
|
||||
import base64
|
||||
import codecs
|
||||
import csv
|
||||
import io
|
||||
import logging
|
||||
import math
|
||||
import re
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from threading import get_ident
|
||||
from typing import Any
|
||||
@@ -255,11 +257,43 @@ def analyze_prj_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"authority": crs.to_authority(),
|
||||
"custom_authority_codes": custom_authority_codes,
|
||||
"is_valid": True,
|
||||
# 카드에 보일 값 — 좌표계 이름만으로는 어느 원점인지 안 보인다(2026-09-04 지시).
|
||||
**_prj_facts(crs),
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
|
||||
|
||||
def _prj_facts(crs: Any) -> dict[str, Any]:
|
||||
"""좌표계에서 카드에 보일 값 — 중앙자오선(원점 경도)·길이 단위."""
|
||||
from common_util.common_util_crs import strip_bound
|
||||
|
||||
flattened = strip_bound(crs)
|
||||
horizontal = next(
|
||||
(
|
||||
strip_bound(item)
|
||||
for item in (flattened.sub_crs_list or [flattened])
|
||||
if strip_bound(item).is_projected or strip_bound(item).is_geographic
|
||||
),
|
||||
None,
|
||||
)
|
||||
if horizontal is None:
|
||||
return {}
|
||||
facts: dict[str, Any] = {}
|
||||
# 원점 경도는 투영 매개변수에서 곧장 읽는다 — proj 문자열로 바꾸면 정보가 깎인다.
|
||||
operation = horizontal.coordinate_operation
|
||||
for param in operation.params if operation is not None else []:
|
||||
label = (param.name or "").lower()
|
||||
if "longitude" in label and ("origin" in label or "meridian" in label):
|
||||
if isinstance(param.value, (int, float)):
|
||||
facts["central_meridian_deg"] = float(param.value)
|
||||
break
|
||||
axes = list(horizontal.axis_info or [])
|
||||
if axes and axes[0].unit_name:
|
||||
facts["unit_name"] = axes[0].unit_name
|
||||
return facts
|
||||
|
||||
|
||||
def analyze_tfw_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""TFW의 affine 변환 계수와 유효성을 분석한다."""
|
||||
source = Path(path)
|
||||
@@ -467,13 +501,32 @@ def analyze_planned_route_csv(path: str | Path) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""DBF 머리글만 읽어 레코드 수·속성 수를 낸다 (2026-09-04 카드 표시용).
|
||||
# DBF 머리글 최대 크기 — 32바이트 고정 + 속성 255개 × 32바이트 + 끝 표시.
|
||||
_DBF_HEADER_MAX = 32 + 255 * 32 + 1
|
||||
|
||||
머리글 32바이트가 전부라 파일 크기와 무관하게 즉시 끝난다.
|
||||
|
||||
def _dbf_field_defs(head: bytes) -> list[tuple[str, str, int]]:
|
||||
"""머리글의 속성 서술자에서 (이름, 형식, 길이)를 뽑는다."""
|
||||
fields: list[tuple[str, str, int]] = []
|
||||
for start in range(32, len(head) - 31, 32):
|
||||
block = head[start : start + 32]
|
||||
if block[0] in (0x0D, 0x00):
|
||||
break
|
||||
name = block[:11].partition(bytes(1))[0].decode("cp949", errors="replace").strip()
|
||||
if name:
|
||||
fields.append((name, chr(block[11]), block[16]))
|
||||
return fields
|
||||
|
||||
|
||||
def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""DBF 머리글만 읽어 레코드 수·속성 수·**속성 이름**을 낸다 (2026-09-04 카드 표시용).
|
||||
|
||||
머리글은 최대 8KB라 파일 크기와 무관하게 즉시 끝난다. 속성 이름은 카드에서
|
||||
「이 표에 무엇이 들었나」를 한눈에 보이는 값이다(2026-09-04 사용자 지시).
|
||||
"""
|
||||
source = Path(path)
|
||||
head = source.read_bytes()[:32]
|
||||
with source.open("rb") as handle:
|
||||
head = handle.read(_DBF_HEADER_MAX)
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"extension": "dbf",
|
||||
@@ -485,6 +538,7 @@ def analyze_dbf_metadata(path: str | Path) -> dict[str, Any]:
|
||||
metadata["record_count"] = record_count
|
||||
# 머리글 = 32바이트 고정 + 속성마다 32바이트 + 끝 표시 1바이트.
|
||||
metadata["field_count"] = max(0, (header_length - 33) // 32)
|
||||
metadata["field_names"] = [name for name, _type, _length in _dbf_field_defs(head)]
|
||||
return metadata
|
||||
|
||||
|
||||
@@ -504,19 +558,67 @@ def analyze_shx_metadata(path: str | Path) -> dict[str, Any]:
|
||||
# 24~27바이트: 파일 길이(16비트 워드 단위, 빅엔디안).
|
||||
words = int.from_bytes(head[24:28], "big")
|
||||
metadata["shape_count"] = max(0, (words * 2 - 100) // 8)
|
||||
if len(head) >= 68:
|
||||
# 32~35바이트: 도형 종류. 36~67바이트: 범위(Xmin, Ymin, Xmax, Ymax).
|
||||
metadata["shape_type"] = int.from_bytes(head[32:36], "little")
|
||||
x_min, y_min, x_max, y_max = struct.unpack("<4d", head[36:68])
|
||||
if x_max > x_min and y_max > y_min:
|
||||
metadata["extent_width_m"] = x_max - x_min
|
||||
metadata["extent_height_m"] = y_max - y_min
|
||||
return metadata
|
||||
|
||||
|
||||
def _dbf_text_sample(dbf: Path, encoding: str) -> str | None:
|
||||
"""같은 세트 DBF 의 첫 글자 속성 값을 그 인코딩으로 읽어 본다.
|
||||
|
||||
CPG 는 이름 한 줄이 전부라, 그 이름이 맞는지는 **글자가 깨지는지**로만 보인다
|
||||
(2026-09-04 사용자 지시). 머리글 + 레코드 한 줄만 읽는다.
|
||||
"""
|
||||
try:
|
||||
with dbf.open("rb") as handle:
|
||||
head = handle.read(_DBF_HEADER_MAX)
|
||||
if len(head) < 32:
|
||||
return None
|
||||
header_length = int.from_bytes(head[8:10], "little")
|
||||
fields = _dbf_field_defs(head)
|
||||
offset = 1 # 레코드 첫 바이트는 삭제 표시
|
||||
for _name, kind, length in fields:
|
||||
if kind == "C":
|
||||
handle.seek(header_length + offset)
|
||||
raw = handle.read(length)
|
||||
return raw.decode(encoding, errors="replace").strip() or None
|
||||
offset += length
|
||||
except (OSError, LookupError, ValueError):
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _codec_name(label: str) -> str | None:
|
||||
"""CPG 가 적은 이름을 파이썬 코덱 이름으로 바꾼다 (숫자만 적힌 것은 코드페이지)."""
|
||||
candidate = label.strip()
|
||||
if candidate.isdigit():
|
||||
candidate = f"cp{candidate}"
|
||||
try:
|
||||
return codecs.lookup(candidate).name
|
||||
except LookupError:
|
||||
return None
|
||||
|
||||
|
||||
def analyze_cpg_metadata(path: str | Path) -> dict[str, Any]:
|
||||
"""CPG 는 인코딩 이름 한 줄이 전부다 (2026-09-04 카드 표시용)."""
|
||||
"""CPG 는 인코딩 이름 한 줄이 전부다 — 같은 세트 DBF 의 글자 견본을 함께 낸다."""
|
||||
source = Path(path)
|
||||
text = source.read_text(encoding="utf-8", errors="replace").strip()
|
||||
return {
|
||||
metadata: dict[str, Any] = {
|
||||
"file": source.name,
|
||||
"extension": "cpg",
|
||||
"size_bytes": source.stat().st_size,
|
||||
"encoding": text or None,
|
||||
}
|
||||
codec = _codec_name(text) if text else None
|
||||
dbf = source.with_suffix(".dbf")
|
||||
if codec and dbf.is_file():
|
||||
metadata["encoding_sample"] = _dbf_text_sample(dbf, codec)
|
||||
return metadata
|
||||
|
||||
|
||||
def analyze_input_metadata(path: str | Path) -> dict[str, Any]:
|
||||
|
||||
@@ -270,6 +270,16 @@ async def run_auto_design_chain(
|
||||
route_id,
|
||||
)
|
||||
|
||||
# 3D 예상형상(코리도) — 브라우저가 쓰는 TS 빌더를 서버에서 한 번 돌려 영구저장한다
|
||||
# (2026-09-04 사용자 확정). 이걸 빼면 사용자가 B05에 처음 들어간 그 순간 브라우저가
|
||||
# 만들어 첫 진입이 느리다. 실패는 비치명적 — 저장본이 없으면 브라우저가 예전처럼 만든다.
|
||||
try:
|
||||
from B05_Profile.B05_Profile_Corridor_Prebuild import prebuild_corridor
|
||||
|
||||
await prebuild_corridor(project_id, route_id, project_root)
|
||||
except Exception: # noqa: BLE001 — 코리도 실패가 체인을 막지는 않는다
|
||||
logger.exception("코리도 사전 생성 실패: project_id=%s", project_id)
|
||||
|
||||
# 초기값 스냅샷 — 여기가 [초기화]가 되돌릴 기준선이다(CLAUDE.md 5장).
|
||||
# 체인 규약대로 실패는 비치명적이다: 계산 결과는 그대로 두고 실패 마커만 남겨
|
||||
# [초기화]가 재계산으로 얼버무리지 않게 한다(2026-09-02 사용자 확정).
|
||||
|
||||
@@ -99,9 +99,33 @@ export function readThumbnail(metadata: Record<string, unknown> | undefined): st
|
||||
return typeof raw === "string" && raw.startsWith("data:image/") ? raw : null;
|
||||
}
|
||||
|
||||
/** SHP/SHX 머리글의 도형 종류 코드 — 카드에 「무엇이 들었나」로 보인다. */
|
||||
const SHAPE_TYPE_LABELS: Record<number, string> = {
|
||||
0: "빈 도형",
|
||||
1: "점",
|
||||
3: "폴리라인",
|
||||
5: "폴리곤",
|
||||
8: "다중점",
|
||||
11: "점(3D)",
|
||||
13: "폴리라인(3D)",
|
||||
15: "폴리곤(3D)",
|
||||
18: "다중점(3D)",
|
||||
21: "점(M)",
|
||||
23: "폴리라인(M)",
|
||||
25: "폴리곤(M)",
|
||||
28: "다중점(M)",
|
||||
31: "복합면",
|
||||
};
|
||||
|
||||
/** 129.002890277778 → 「129.0029°E」. */
|
||||
function meridianLabel(degrees: number): string {
|
||||
const rounded = Math.abs(Math.round(degrees * 10000) / 10000);
|
||||
return `${rounded}°${degrees < 0 ? "W" : "E"}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **한눈에 구분되는 값**을 뽑는다
|
||||
* (2026-09-04 사용자 지시). 그림 대신 이 값을 큰 글씨로 보인다.
|
||||
* 그릴 도형이 없는 부속 파일(shx·dbf·cpg·prj·tfw)에서 **파일 안의 중요한 값**을 뽑는다
|
||||
* (2026-09-04 사용자 지시 — 개수만이 아니라 내용이 보이게). 첫 줄은 굵게, 나머지는 작게.
|
||||
*/
|
||||
export function readFacts(metadata: Record<string, unknown> | undefined): string[] {
|
||||
if (!metadata) return [];
|
||||
@@ -109,15 +133,38 @@ export function readFacts(metadata: Record<string, unknown> | undefined): string
|
||||
const number = (value: unknown): number | null => finite(value);
|
||||
const text = (value: unknown): string | null =>
|
||||
typeof value === "string" && value.trim() ? value.trim() : null;
|
||||
const round = (value: number): string => Math.round(value).toLocaleString();
|
||||
|
||||
// SHX — 도형 종류·개수와 그 도형이 덮는 범위.
|
||||
const shapes = number(metadata.shape_count);
|
||||
if (shapes !== null) facts.push(`도형 ${shapes.toLocaleString()}개`);
|
||||
if (shapes !== null) {
|
||||
const kind = SHAPE_TYPE_LABELS[number(metadata.shape_type) ?? -1];
|
||||
const count = `도형 ${shapes.toLocaleString()}개`;
|
||||
facts.push(kind ? `${kind} · ${count}` : count);
|
||||
const width = number(metadata.extent_width_m);
|
||||
const height = number(metadata.extent_height_m);
|
||||
if (width !== null && height !== null) facts.push(`범위 ${round(width)}×${round(height)} m`);
|
||||
}
|
||||
|
||||
// DBF — 레코드·속성 개수와 속성 이름(무엇이 든 표인지).
|
||||
const records = number(metadata.record_count);
|
||||
if (records !== null) facts.push(`레코드 ${records.toLocaleString()}개`);
|
||||
const fields = number(metadata.field_count);
|
||||
if (fields !== null) facts.push(`속성 ${fields}개`);
|
||||
if (records !== null) {
|
||||
const count = `레코드 ${records.toLocaleString()}개`;
|
||||
facts.push(fields !== null ? `${count} · 속성 ${fields}개` : count);
|
||||
}
|
||||
const names = Array.isArray(metadata.field_names)
|
||||
? metadata.field_names.filter((name): name is string => typeof name === "string")
|
||||
: [];
|
||||
if (names.length) facts.push(names.slice(0, 4).join(", ") + (names.length > 4 ? " …" : ""));
|
||||
|
||||
// CPG — 인코딩 이름과 그 인코딩으로 읽은 글자 견본(깨지면 눈에 보인다).
|
||||
const encoding = text(metadata.encoding);
|
||||
if (encoding) facts.push(`인코딩 ${encoding}`);
|
||||
const sample = text(metadata.encoding_sample);
|
||||
if (sample) facts.push(`견본 「${sample}」`);
|
||||
|
||||
// TFW — 픽셀 크기와 회전.
|
||||
const pixelX = number(metadata.pixel_size_x);
|
||||
const pixelY = number(metadata.pixel_size_y);
|
||||
if (pixelX !== null && pixelY !== null) {
|
||||
@@ -126,12 +173,26 @@ export function readFacts(metadata: Record<string, unknown> | undefined): string
|
||||
(number(metadata.rotation_x) ?? 0) !== 0 || (number(metadata.rotation_y) ?? 0) !== 0;
|
||||
facts.push(rotated ? "회전 있음" : "회전 없음");
|
||||
}
|
||||
// PRJ 는 좌표계 이름이 곧 구분값이다.
|
||||
|
||||
// PRJ — 좌표계 이름·EPSG 와 원점(중앙자오선)·길이 단위.
|
||||
const name = text(metadata.name);
|
||||
const epsgLabel = readCrsLabel(metadata);
|
||||
if (name) facts.push(epsgLabel ? `${name} (EPSG:${epsgLabel})` : name);
|
||||
else if (epsgLabel && facts.length === 0) facts.push(`EPSG:${epsgLabel}`);
|
||||
return facts.slice(0, 2);
|
||||
const meridian = number(metadata.central_meridian_deg);
|
||||
const unit = text(metadata.unit_name);
|
||||
const origin = [
|
||||
meridian === null ? null : `중앙자오선 ${meridianLabel(meridian)}`,
|
||||
unit ? `단위 ${/^met(er|re)s?$/i.test(unit) ? "m" : unit}` : null,
|
||||
].filter((part): part is string => part !== null);
|
||||
if (origin.length) facts.push(origin.join(" · "));
|
||||
const vertical = metadata.vertical_crs as Record<string, unknown> | null | undefined;
|
||||
if (vertical && typeof vertical === "object") {
|
||||
const verticalName = text(vertical.name);
|
||||
if (verticalName) facts.push(`높이 기준 ${verticalName}`);
|
||||
}
|
||||
|
||||
return facts.slice(0, 3);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -241,16 +302,19 @@ export function renderSlotPreview(host: HTMLElement, options: PreviewOptions): v
|
||||
svg.setAttribute("role", "img");
|
||||
const project = projector(extent);
|
||||
|
||||
// 자료가 덮는 땅 범위 — 어느 자료든 늘 그린다.
|
||||
const [rectX, rectTop] = project(extent.xMin, extent.yMax);
|
||||
const [rectRight, rectBottom] = project(extent.xMax, extent.yMin);
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("class", "b03-file__preview-extent");
|
||||
rect.setAttribute("x", String(rectX));
|
||||
rect.setAttribute("y", String(rectTop));
|
||||
rect.setAttribute("width", String(rectRight - rectX));
|
||||
rect.setAttribute("height", String(rectBottom - rectTop));
|
||||
svg.append(rect);
|
||||
// 자료가 덮는 땅 범위 — 그릴 도형이 없는 자료에만 그린다. 계획노선은 선 자체가
|
||||
// 범위를 보여 주므로 점선 상자를 빼고 선만 남긴다(2026-09-04 사용자 지시).
|
||||
if (!path) {
|
||||
const [rectX, rectTop] = project(extent.xMin, extent.yMax);
|
||||
const [rectRight, rectBottom] = project(extent.xMax, extent.yMin);
|
||||
const rect = document.createElementNS(SVG_NS, "rect");
|
||||
rect.setAttribute("class", "b03-file__preview-extent");
|
||||
rect.setAttribute("x", String(rectX));
|
||||
rect.setAttribute("y", String(rectTop));
|
||||
rect.setAttribute("width", String(rectRight - rectX));
|
||||
rect.setAttribute("height", String(rectBottom - rectTop));
|
||||
svg.append(rect);
|
||||
}
|
||||
|
||||
let label = L("B03_File_Preview_Extent");
|
||||
// 라이다 탑뷰 점 그림 — 분석기가 훑는 길에 주워 둔 XY 를 그대로 찍는다.
|
||||
|
||||
@@ -633,10 +633,13 @@
|
||||
stroke-width: 0.7;
|
||||
}
|
||||
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. */
|
||||
/* GeoTIFF 저해상 썸네일 — 원본 비율을 지켜 상자 안에 맞춘다. 썸네일이 곧 자료 범위라
|
||||
다른 그림의 점선 사각형과 같은 테두리를 둘러 셋이 같은 모양으로 보이게 한다
|
||||
(2026-09-04 사용자 지적). */
|
||||
.b03-file__preview-thumb {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
border: 1px dashed var(--color-border, #9aa);
|
||||
image-rendering: pixelated;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -676,7 +679,9 @@
|
||||
|
||||
.b03-file__preview-route {
|
||||
fill: none;
|
||||
stroke: var(--color-primary, #4a3aff);
|
||||
/* 계획노선 선 색 — 녹색(2026-09-04 사용자 지시). 지형 범위를 벗어나면 아래에서
|
||||
붉은색으로 덮어써 경고로 쓴다. */
|
||||
stroke: var(--color-success, #16a34a);
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-width: 1.5;
|
||||
@@ -696,8 +701,9 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.b03-file__card--uploading .b03-file__card-select,
|
||||
.b03-file__card--completed .b03-file__card-select {
|
||||
/* 올리는 중에만 [파일 선택]을 감춘다. 끝난 카드에도 남겨 두어야 파일 하나를 갈아
|
||||
끼우고 [파일 업로드]로 다시 돌릴 수 있다(2026-09-04 사용자 지시). */
|
||||
.b03-file__card--uploading .b03-file__card-select {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_Corridor_Node.ts
|
||||
* 코리도(3D 예상형상)를 **서버에서 한 번** 만들어 저장본 형식으로 내놓는 진입점.
|
||||
*
|
||||
* 왜 있나(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤
|
||||
* 전처리 체인 마지막에 계산해 영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 →
|
||||
* [저장]·[확정] 때 저장. 예전에는 사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어
|
||||
* 첫 진입이 느렸다.
|
||||
*
|
||||
* **계산을 다시 짜지 않는다.** 브라우저가 쓰는 빌더(`_UI_Corridor_Build`)와 저장 형식
|
||||
* (`_UI_Corridor_Envelope`)을 그대로 부른다 — 두 벌이 되면 화면과 서버가 다른 그림을
|
||||
* 만든다. 빌더 계통에는 Three.js·화면 API가 없어 Node에서 그대로 돈다.
|
||||
*
|
||||
* 실행: node <번들> <입력.json> <출력.json>
|
||||
* 입력 { detail: 종횡단 상세(API와 같은 꼴), route_points: 노선 폴리라인 }
|
||||
* 출력 브라우저가 GET으로 받는 저장본 그대로(버전·해시 포함)
|
||||
* 끝 코드: 0 성공 / 2 인자 오류 / 3 측점 부족(만들 것 없음)
|
||||
* ========================================================================== */
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import { buildCorridor } from "./B05_Profile_UI_Corridor_Build";
|
||||
import { corridorHash, serialize } from "./B05_Profile_UI_Corridor_Envelope";
|
||||
|
||||
interface CorridorNodeInput {
|
||||
detail: SectionDetailResponse;
|
||||
route_points: RoutePoint[];
|
||||
}
|
||||
|
||||
const [inputPath, outputPath] = process.argv.slice(2);
|
||||
if (!inputPath || !outputPath) {
|
||||
console.error("사용법: node <번들> <입력.json> <출력.json>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const input = JSON.parse(readFileSync(inputPath, "utf8")) as CorridorNodeInput;
|
||||
const detail = input.detail;
|
||||
const routePoints = input.route_points ?? [];
|
||||
// 편집 전이므로 계획선 샘플은 정본 그대로다 — 브라우저의 폴백 경로와 같은 값이다.
|
||||
const samples = detail.longitudinal?.design_profiles?.[0]?.samples;
|
||||
const build = buildCorridor(detail.cross_sections, routePoints, samples);
|
||||
if (!build) {
|
||||
console.error("코리도 없음 — 분류 가능한 측점이 2개 미만.");
|
||||
process.exit(3);
|
||||
}
|
||||
writeFileSync(outputPath, JSON.stringify(serialize(build, corridorHash(detail, routePoints))));
|
||||
@@ -0,0 +1,151 @@
|
||||
"""코리도(3D 예상형상) 사전 생성 — 전처리 체인 마지막에 서버가 한 번 만들어 영구저장한다.
|
||||
|
||||
왜(2026-09-04 사용자 확정) — 다른 데이터와 같은 흐름으로 맞춘다: 파일 입력 뒤 계산해
|
||||
영구저장 → 사용자는 진입 시 로딩만 → 조작은 캐시 → [저장]·[확정] 때 저장. 예전에는
|
||||
사용자가 B05에 처음 들어간 그 순간 브라우저가 만들어(실측 17MB 규모) 첫 진입이 느렸다.
|
||||
|
||||
**계산을 다시 짜지 않는다.** 브라우저가 쓰는 TS 빌더를 Node로 그대로 돌린다
|
||||
(`B05_Profile_Corridor_Node.ts` → 번들). 파이썬으로 포팅하면 같은 기하가 두 벌이 되어
|
||||
화면과 3D가 갈라진다.
|
||||
|
||||
실패는 비치명적이다 — 저장본이 없으면 브라우저가 예전처럼 직접 만든다(폴백 유지).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from B05_Profile.B05_Profile_Repository import get_route_points
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
BUNDLE = ROOT / "config" / "corridor_node" / "B05_Profile_Corridor_Node.js"
|
||||
# 번들이 낡았는지 재는 대상 — 빌더 계통이 걸쳐 있는 폴더.
|
||||
_SOURCE_DIRS = ("B05_Profile", "B06_Section", "common_util")
|
||||
# 번들 만들기·실행 상한(초). 실측 번들 0.1초, 빌드 65측점 3초 수준이라 넉넉하다.
|
||||
_BUILD_TIMEOUT_S = 300
|
||||
_RUN_TIMEOUT_S = 600
|
||||
|
||||
|
||||
def _node_env() -> dict[str, str]:
|
||||
"""config/node_modules를 쓰는 프론트엔드 프로세스 환경(main.py와 같은 규약)."""
|
||||
env = os.environ.copy()
|
||||
node_modules = ROOT / "config" / "node_modules"
|
||||
env["PATH"] = f"{node_modules / '.bin'}{os.pathsep}{env.get('PATH', '')}"
|
||||
env["NODE_PATH"] = str(node_modules)
|
||||
return env
|
||||
|
||||
|
||||
def _bundle_stale() -> bool:
|
||||
"""번들이 없거나 TS 원본보다 오래됐으면 참.
|
||||
|
||||
번들이 낡으면 서버와 화면이 **다른 기하**를 만든다 — 이 판정이 그것을 막는 유일한
|
||||
장치다. 개발 중에는 `npm run build`를 따로 돌리지 않으므로 여기서 스스로 갱신한다.
|
||||
"""
|
||||
if not BUNDLE.is_file():
|
||||
return True
|
||||
built_at = BUNDLE.stat().st_mtime
|
||||
for directory in _SOURCE_DIRS:
|
||||
for path in (ROOT / directory).rglob("*.ts"):
|
||||
if path.stat().st_mtime > built_at:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _build_bundle() -> bool:
|
||||
result = subprocess.run( # noqa: S602 — 고정 명령, 사용자 입력 없음
|
||||
"npm run build:corridor",
|
||||
shell=True,
|
||||
cwd=str(ROOT),
|
||||
env=_node_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_BUILD_TIMEOUT_S,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.error("코리도 번들 빌드 실패:\n%s", result.stderr)
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _run_node(input_path: Path, output_path: Path) -> int:
|
||||
result = subprocess.run( # noqa: S603 — 고정 실행 파일, 인자는 임시 파일 경로뿐
|
||||
["node", str(BUNDLE), str(input_path), str(output_path)],
|
||||
cwd=str(ROOT),
|
||||
env=_node_env(),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=_RUN_TIMEOUT_S,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
logger.warning("코리도 사전 생성 실패(끝 코드 %s): %s", result.returncode, result.stderr)
|
||||
return result.returncode
|
||||
|
||||
|
||||
async def _section_detail(project_id: UUID | str, route_id: int) -> dict[str, Any] | None:
|
||||
"""브라우저가 받는 것과 **같은** 종횡단 상세를 만든다 — 같은 라우터 함수를 부른다."""
|
||||
from B06_Section.B06_Section_Router import get_section_detail
|
||||
|
||||
response = await get_section_detail(UUID(str(project_id)), route_id)
|
||||
payload = getattr(response, "model_dump", None)
|
||||
if payload is None: # JSONResponse = 실패
|
||||
logger.warning("코리도 사전 생성: 종횡단 상세를 못 받음 (route_id=%s)", route_id)
|
||||
return None
|
||||
return payload(mode="json")
|
||||
|
||||
|
||||
async def prebuild_corridor(project_id: UUID | str, route_id: int, project_root: Path) -> bool:
|
||||
"""코리도를 만들어 영구저장소에 남긴다. 성공하면 참."""
|
||||
detail = await _section_detail(project_id, route_id)
|
||||
if detail is None:
|
||||
return False
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
points = await get_route_points(connection, route_id)
|
||||
if len(points) < 2:
|
||||
logger.info("코리도 사전 생성 건너뜀 — 노선 점이 부족함 (route_id=%s)", route_id)
|
||||
return False
|
||||
|
||||
if _bundle_stale() and not await asyncio.to_thread(_build_bundle):
|
||||
return False
|
||||
|
||||
target = corridor_path(Path(project_root), route_id)
|
||||
with tempfile.TemporaryDirectory(prefix="corridor_") as workdir:
|
||||
source = Path(workdir) / "input.json"
|
||||
built = Path(workdir) / "corridor.json"
|
||||
await asyncio.to_thread(
|
||||
source.write_text,
|
||||
json.dumps({"detail": detail, "route_points": points}, default=float),
|
||||
"utf-8",
|
||||
)
|
||||
if await asyncio.to_thread(_run_node, source, built) != 0:
|
||||
return False
|
||||
await asyncio.to_thread(target.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(_replace, built, target)
|
||||
logger.info(
|
||||
"코리도 사전 생성 완료: route_id=%s 크기=%.1fMB",
|
||||
route_id,
|
||||
target.stat().st_size / (1024 * 1024),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _replace(source: Path, target: Path) -> None:
|
||||
"""임시 폴더가 다른 드라이브일 수 있어 os.replace 대신 복사 후 지운다."""
|
||||
target.write_bytes(source.read_bytes())
|
||||
source.unlink(missing_ok=True)
|
||||
@@ -28,7 +28,8 @@ router = APIRouter(prefix="/api/projects", tags=["B05 Corridor"])
|
||||
MAX_CORRIDOR_BYTES = 64 * 1024 * 1024
|
||||
|
||||
|
||||
def _corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
def corridor_path(project_root: Path, route_id: int) -> Path:
|
||||
"""코리도 저장본 자리 — 사전 생성(체인)·초기값 복원도 같은 자리를 쓴다."""
|
||||
return project_root / "B05_Profile" / "corridor" / f"corridor_{route_id:04d}.json"
|
||||
|
||||
|
||||
@@ -72,7 +73,7 @@ async def get_corridor(project_id: UUID, route_id: int) -> Response:
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
path = corridor_path(project_root, route_id)
|
||||
if not path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
@@ -109,7 +110,7 @@ async def put_corridor(project_id: UUID, route_id: int, payload: dict = Body(...
|
||||
return JSONResponse(
|
||||
status_code=404, content={"status": "error", "message": "프로젝트가 없습니다."}
|
||||
)
|
||||
path = _corridor_path(project_root, route_id)
|
||||
path = corridor_path(project_root, route_id)
|
||||
await asyncio.to_thread(path.parent.mkdir, parents=True, exist_ok=True)
|
||||
await asyncio.to_thread(atomic_write_json, path, payload)
|
||||
return JSONResponse(content={"status": "ok"})
|
||||
|
||||
@@ -198,6 +198,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
},
|
||||
)
|
||||
|
||||
# 복원이 만든 새 route id — 코리도 초기값을 그 번호로 되돌리는 데 쓴다.
|
||||
restored_route_id: int | None = None
|
||||
async with pool.acquire() as connection:
|
||||
# 확정 지표면 모델을 초기 체인과 **같은 기준**으로 다시 찾는다 — 체인은 stage 1
|
||||
# 확정 선택값을 쓴다(2026-08-30). config 기본값을 쓰면 그 선택과 어긋난 모델을
|
||||
@@ -219,7 +221,9 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
deleted = cursor.rowcount
|
||||
# 복원은 같은 트랜잭션 안에서 끝낸다 — 지우기만 하고 실패하면 경로가 없다.
|
||||
if restored and project_root:
|
||||
await restore_initial_snapshot(connection, project_root, str(project_id))
|
||||
restored_route_id = await restore_initial_snapshot(
|
||||
connection, project_root, str(project_id)
|
||||
)
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -227,8 +231,8 @@ async def reset_route_design(project_id: UUID) -> JSONResponse:
|
||||
|
||||
if restored and project_root:
|
||||
# 정본 파일도 스냅샷본으로 되돌린다 — 이것을 빼면 구조물·관 편집분이 남아
|
||||
# 초기값이 오염된다(2026-08-29).
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root)
|
||||
# 초기값이 오염된다(2026-08-29). 코리도는 새 route id 이름으로 되돌아간다.
|
||||
await asyncio.to_thread(restore_snapshot_files, project_root, restored_route_id)
|
||||
else:
|
||||
# 스냅샷이 없어 재계산으로 초기값을 만드는 경로 — 편집 정본을 먼저 걷어내야
|
||||
# 진짜 초기값이 나온다. 남기면 구조물 측점이 사용자 편집분에서 다시 파생된다
|
||||
|
||||
@@ -16,12 +16,14 @@
|
||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||
import {
|
||||
buildCorridor,
|
||||
type CorridorBuildResult,
|
||||
type CorridorRibbon,
|
||||
} from "./B05_Profile_UI_Corridor_Build";
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures";
|
||||
corridorHash,
|
||||
ENVELOPE_VERSION,
|
||||
deserialize,
|
||||
serialize,
|
||||
type CorridorEnvelope,
|
||||
} from "./B05_Profile_UI_Corridor_Envelope";
|
||||
|
||||
/** 구조물별 서피스 대체/삽입 훅 — 별도 지침 후 구현 예정(2026-08-23 범위 제외). */
|
||||
export interface CorridorStructureHook {
|
||||
@@ -29,58 +31,6 @@ export interface CorridorStructureHook {
|
||||
applies: (chainageStart: number, chainageEnd: number) => boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다.
|
||||
*
|
||||
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
|
||||
*
|
||||
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
|
||||
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
|
||||
* 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자).
|
||||
* 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가
|
||||
* 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02).
|
||||
*/
|
||||
const ENVELOPE_VERSION = 6;
|
||||
|
||||
interface CorridorEnvelope {
|
||||
version: number;
|
||||
hash: string;
|
||||
ribbons: Array<{
|
||||
kind: CorridorRibbon["kind"];
|
||||
side: CorridorRibbon["side"];
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positionsBase64: string;
|
||||
/**
|
||||
* 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도
|
||||
* 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연
|
||||
* 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다.
|
||||
*/
|
||||
patch?: boolean;
|
||||
patchClip?: boolean;
|
||||
/** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */
|
||||
cellMaskBase64?: string;
|
||||
/** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */
|
||||
trimTrisBase64?: string;
|
||||
trimUvsBase64?: string;
|
||||
}>;
|
||||
outline: CorridorBuildResult["outline"];
|
||||
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
|
||||
caps?: CorridorBuildResult["caps"];
|
||||
/** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */
|
||||
structures?: CorridorBuildResult["structures"];
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면
|
||||
* 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다.
|
||||
*/
|
||||
planCurves?: CorridorBuildResult["planCurves"];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */
|
||||
cutWalls?: Array<{
|
||||
side: "left" | "right";
|
||||
positionsBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
hash: string;
|
||||
build: CorridorBuildResult;
|
||||
@@ -93,267 +43,6 @@ function keyOf(projectId: string, routeId: number): string {
|
||||
return `${projectId}:${routeId}`;
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.**
|
||||
*
|
||||
* 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아
|
||||
* 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정·
|
||||
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
|
||||
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
|
||||
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
|
||||
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
|
||||
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
|
||||
// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) +
|
||||
// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정),
|
||||
// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토)
|
||||
// (2026-08-27 사용자).
|
||||
// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인)
|
||||
// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반
|
||||
// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계
|
||||
// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형
|
||||
// 성토면이 있어야 한다 — 삭제가 아니다).
|
||||
// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서
|
||||
// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이
|
||||
// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복").
|
||||
// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다
|
||||
// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다),
|
||||
// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`,
|
||||
// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로.
|
||||
// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토
|
||||
// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자).
|
||||
// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라
|
||||
// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서
|
||||
// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자:
|
||||
// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림").
|
||||
// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`,
|
||||
// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로
|
||||
// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다
|
||||
// (2026-08-27 사용자: "좌측 스윕 더 가야 함").
|
||||
// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에
|
||||
// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.)
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니
|
||||
// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로
|
||||
// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다
|
||||
// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지").
|
||||
// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거,
|
||||
// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고
|
||||
// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다
|
||||
// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다).
|
||||
// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은
|
||||
// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 —
|
||||
// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측).
|
||||
// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자).
|
||||
// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열
|
||||
// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는
|
||||
// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다**
|
||||
// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선
|
||||
// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다.
|
||||
// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥
|
||||
// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이
|
||||
// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해
|
||||
// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히
|
||||
// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이
|
||||
// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다").
|
||||
// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로
|
||||
// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지
|
||||
// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m).
|
||||
// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취
|
||||
// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다
|
||||
// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출").
|
||||
// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용).
|
||||
// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로
|
||||
// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선
|
||||
// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고
|
||||
// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음
|
||||
// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이
|
||||
// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가
|
||||
// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은
|
||||
// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다.
|
||||
// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로
|
||||
// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을
|
||||
// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다
|
||||
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
|
||||
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
|
||||
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
|
||||
// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어
|
||||
// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**.
|
||||
// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다
|
||||
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
|
||||
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
|
||||
// 가르고 **닫힌 고리만** 담는다.
|
||||
// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견
|
||||
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
|
||||
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
|
||||
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
|
||||
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
|
||||
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
|
||||
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
|
||||
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
|
||||
// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만
|
||||
// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치
|
||||
// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취
|
||||
// 영역을 내므로 같은 반대 규칙이 성립한다.
|
||||
// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가
|
||||
// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆
|
||||
// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 —
|
||||
// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다.
|
||||
// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를
|
||||
// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길
|
||||
// 부재를 못 정하므로 만료한다.
|
||||
// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로
|
||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||
const BUILD_VERSION = 92;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
const parts: Array<string | number> = [`v${BUILD_VERSION}`, routePoints.length];
|
||||
routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2)));
|
||||
detail.cross_sections.forEach((section) => {
|
||||
const design = section.design;
|
||||
parts.push(
|
||||
section.chainage_m.toFixed(3),
|
||||
section.center_x.toFixed(2),
|
||||
section.center_y.toFixed(2),
|
||||
);
|
||||
if (!design) {
|
||||
parts.push("nodesign");
|
||||
return;
|
||||
}
|
||||
parts.push(
|
||||
design.design_elevation_m.toFixed(3),
|
||||
design.section_mode,
|
||||
design.ditch_side ?? "",
|
||||
design.ditch_type ?? "",
|
||||
String(design.ditch_enabled ?? ""),
|
||||
design.design_line.length,
|
||||
);
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
// 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다.
|
||||
structureHashParts(section).forEach((part) => parts.push(part));
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloats(values: Float64Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(encoded: string): Uint8Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function floatsFromBase64(encoded: string): Float64Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float64Array(bytes.buffer);
|
||||
}
|
||||
|
||||
function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
return {
|
||||
version: ENVELOPE_VERSION,
|
||||
hash,
|
||||
ribbons: build.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloats(ribbon.positions),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}),
|
||||
...(ribbon.trimTris && ribbon.trimUvs
|
||||
? {
|
||||
trimTrisBase64: base64FromFloats(ribbon.trimTris),
|
||||
trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
structures: build.structures,
|
||||
planCurves: build.planCurves,
|
||||
cutWalls: (build.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positionsBase64: base64FromFloats(wall.positions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
return {
|
||||
ribbons: envelope.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: floatsFromBase64(ribbon.positionsBase64),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}),
|
||||
...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64
|
||||
? {
|
||||
trimTris: floatsFromBase64(ribbon.trimTrisBase64),
|
||||
trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
structures: envelope.structures ?? [],
|
||||
planCurves: envelope.planCurves ?? [],
|
||||
cutWalls: (envelope.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positions: floatsFromBase64(wall.positionsBase64),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function requestCorridor(path: string, init: RequestInit): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
|
||||
@@ -412,6 +101,19 @@ export type ProfileSamples = Array<{
|
||||
ground_elevation_m?: number;
|
||||
}>;
|
||||
|
||||
/** 마지막 코리도가 **어디서 왔는지** — 저장본을 그대로 썼나, 브라우저가 다시 만들었나.
|
||||
* 서버 사전 생성(2026-09-04)이 실제로 먹었는지 화면 밖에서 수치로 확인하는 창구다
|
||||
* (`__corridorScene`·`__corridorSkirt`와 같은 디버그 훅). */
|
||||
declare global {
|
||||
interface Window {
|
||||
__corridorSource?: { source: "stored" | "built"; hash: string; at: number };
|
||||
}
|
||||
}
|
||||
|
||||
function markSource(source: "stored" | "built", hash: string): void {
|
||||
window.__corridorSource = { source, hash, at: Date.now() };
|
||||
}
|
||||
|
||||
export async function ensureCorridor(
|
||||
projectId: string,
|
||||
routeId: number,
|
||||
@@ -429,6 +131,7 @@ export async function ensureCorridor(
|
||||
try {
|
||||
const build = deserialize(stored);
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
markSource("stored", hash);
|
||||
return build;
|
||||
} catch {
|
||||
// 손상 저장본 — 빌드로 폴백.
|
||||
@@ -447,6 +150,7 @@ export async function ensureCorridor(
|
||||
cache.delete(key);
|
||||
return null;
|
||||
}
|
||||
markSource("built", hash);
|
||||
if (stored === null) {
|
||||
// 최초 생성 — 계획 확정 흐름대로 즉시 영구저장(실패해도 표시는 진행).
|
||||
cache.set(key, { hash, build, dirty: false });
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Corridor_Envelope.ts
|
||||
* 코리도 저장본 형식 — 버전·해시·직렬화 한 벌. **브라우저와 서버가 같은 것을 쓴다.**
|
||||
*
|
||||
* 서버(전처리 체인 끝)가 같은 빌더로 코리도를 미리 만들어 저장하므로(2026-09-04 사용자
|
||||
* 확정), 저장 형식과 버전 해시가 두 벌이 되면 브라우저가 서버 저장본을 남으로 보고
|
||||
* 통째로 다시 만든다. 그래서 이 파일 하나만 둔다 — 화면 API 를 쓰지 않아 Node 에서도
|
||||
* 그대로 돈다(`btoa`·`atob` 는 Node 16+ 전역).
|
||||
*
|
||||
* 원래 `_UI_Corridor.ts` 안에 있던 것을 그대로 옮겼다(2026-09-04 분리, 내용 불변).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||
import type { CorridorBuildResult, CorridorRibbon } from "./B05_Profile_UI_Corridor_Build_Types";
|
||||
import { structureHashParts } from "./B05_Profile_UI_Corridor_Structures";
|
||||
|
||||
/**
|
||||
* 저장본 **형식(스키마)** 버전 — 담는 필드가 바뀔 때만 올린다.
|
||||
*
|
||||
* 기하 계산이 바뀐 것은 여기가 아니라 아래 `BUILD_VERSION`으로 만료시킨다.
|
||||
*
|
||||
* 3 = 리본에 셀 마스크·축 고정 커브 조각 삼각형 추가 — 2026-08-26.
|
||||
* 4 = 절취 측벽 서피스(`cutWalls`) 추가 — 2026-08-27.
|
||||
* 5 = 측벽에서 빗금(UV)을 뺀다 — 벽은 방향성을 표현하지 않는다(2026-08-27 사용자).
|
||||
* 6 = 리본의 `patch`·`patchClip` 표식을 담는다 — 안 담아서 저장본으로 다시 연 코리도가
|
||||
* 패치인 줄 모르고 지형 스냅에 끌려갔다(2026-09-02).
|
||||
*/
|
||||
export const ENVELOPE_VERSION = 6;
|
||||
|
||||
export interface CorridorEnvelope {
|
||||
version: number;
|
||||
hash: string;
|
||||
ribbons: Array<{
|
||||
kind: CorridorRibbon["kind"];
|
||||
side: CorridorRibbon["side"];
|
||||
colCount: number;
|
||||
chainages: number[];
|
||||
positionsBase64: string;
|
||||
/**
|
||||
* 구조물 패치 리본 표식(2026-09-02) — 빌드 때만 쓰는 값이 아니라 **뷰어가 그릴 때도
|
||||
* 본다**(`B05_Profile_UI_Viewer.ts` 지형 스냅 제외). 안 담으면 저장본으로 다시 연
|
||||
* 코리도가 패치인 줄 몰라 바깥 끝이 원지반까지 끌려 내려간다.
|
||||
*/
|
||||
patch?: boolean;
|
||||
patchClip?: boolean;
|
||||
/** 셀 마스크(2026-08-26 투영 커브 안쪽 지우기) — 없으면 구멍 없는 리본. */
|
||||
cellMaskBase64?: string;
|
||||
/** 축 고정 커브가 걸친 셀의 조각 삼각형·UV(2026-08-26). */
|
||||
trimTrisBase64?: string;
|
||||
trimUvsBase64?: string;
|
||||
}>;
|
||||
outline: CorridorBuildResult["outline"];
|
||||
/** 시·종점 마구리(구 저장본엔 없음 — 없으면 빈 배열로 복원). */
|
||||
caps?: CorridorBuildResult["caps"];
|
||||
/** 배수관 세트 구조물(2026-08-23 — 구 저장본엔 없음, 없으면 빈 배열). */
|
||||
structures?: CorridorBuildResult["structures"];
|
||||
/**
|
||||
* 구조물 세트별 탑뷰 투영 커브(2026-08-26) — 저장본에도 담는다. 안 담으면
|
||||
* 저장본으로 복원한 코리도에서는 커브가 통째로 사라진다.
|
||||
*/
|
||||
planCurves?: CorridorBuildResult["planCurves"];
|
||||
/** 절취 측벽 서피스(2026-08-27) — 구 저장본엔 없다. 없으면 빈 배열로 복원. */
|
||||
cutWalls?: Array<{
|
||||
side: "left" | "right";
|
||||
positionsBase64: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** FNV-1a 32bit — 설계 입력 요약 문자열의 버전 해시. */
|
||||
function fnv1a(text: string): string {
|
||||
let hash = 0x811c9dc5;
|
||||
for (let i = 0; i < text.length; i += 1) {
|
||||
hash ^= text.charCodeAt(i);
|
||||
hash = Math.imul(hash, 0x01000193);
|
||||
}
|
||||
return (hash >>> 0).toString(16).padStart(8, "0");
|
||||
}
|
||||
|
||||
/**
|
||||
* 빌드 로직 판번호 — **기하를 바꾸는 수정을 하면 반드시 올린다.**
|
||||
*
|
||||
* 해시는 종횡단 입력만 요약하므로, 빌드 코드를 고쳐도 입력이 그대로면 해시가 같아
|
||||
* 옛 저장본을 그대로 다시 쓴다 — 코드를 고쳤는데 화면이 안 바뀌는 일이 생긴다
|
||||
* (2026-08-23 사용자 보고: "원복이 안 된 것 같다가 갑자기 반영됨"). 판번호를 해시에
|
||||
* 섞어 두면 배포와 동시에 저장본이 만료된다.
|
||||
*/
|
||||
// 42 = 탑뷰 투영선(빨간 선)·구조물 구간 경계 행·파란 스케치 안쪽 기준 정정·
|
||||
// 투영 커브 안쪽 성토면 절단 — 커브 다각형 기준(2026-08-26).
|
||||
// ※ 40에 머물러 있는 동안 기하 수정 3회가 통째로 묻혔다 — 저장본이 그대로
|
||||
// 복원되어 화면·데이터가 하나도 안 바뀌었다. 기하를 고치면 **반드시** 올릴 것.
|
||||
// 56 = 측벽에서 **노견 쪽·성토 끝단 쪽 리본 경계 벽을 뺀다** — 거기서 잘린 게
|
||||
// 아니라 리본이 끝난 자리다(2026-08-27 사용자).
|
||||
// 64 = 변형 성토면(패치) 마감 3종 — ① 행별 지반 절단(빨간 교차선에서 끝냄) +
|
||||
// 바깥 끝 수직 스커트, ② 날개 사다리꼴 몫만큼 패치 행 범위 확장(BOX는 축 고정),
|
||||
// ③ 구조물 안착 자리 원지반 재노출(원본 성토 스케치 − 구조물 ∪ 변형 성토)
|
||||
// (2026-08-27 사용자).
|
||||
// 65 = 날개벽 평면 스케치까지 변형 성토면에 포함 — 늘린 행에 벽 트림을 걸지 않는다
|
||||
// (2026-08-27 사용자).
|
||||
// 66 = 패치 행을 그 행 지반선과 직접 교차시켜 **자르고 연장**한다 — 날개 사다리꼴
|
||||
// 바깥변(원본 성토 끝선)까지 성토면이 이어진다(2026-08-27 사용자).
|
||||
// 67 = (주석만 앞서 나갔던 번호 — 실제로 들어간 것은 66까지다. 2026-08-27 확인)
|
||||
// 68 = 패치 스윕 범위를 **주황 날개 평면 스케치(`wing-box`)까지** 넓힌다 — 행별 지반
|
||||
// 핀(절단·연장)과 스커트는 그대로 두고, 스케치가 더 바깥일 때만 그 행을 설계
|
||||
// 성토 물매로 스케치 경계까지 덧이어 낸다(2026-08-27 사용자: 그 자리에 변형
|
||||
// 성토면이 있어야 한다 — 삭제가 아니다).
|
||||
// 69 = 날개 스케치 확장에서 **BOX암거는 제외**한다 — 날개벽은 세월교·BOX 두 곳에서
|
||||
// 쓰이지만 로직이 분리돼 있고, BOX는 이미 채워져 있어(구멍 5~12%) 손대면 행 폭이
|
||||
// 0.74 → 4.53m로 뛴다(2026-08-27 사용자: "박스 암거는 로직 원복").
|
||||
// 70 = 세월교 날개 사다리꼴 A안 — ① 종방향 범위를 **노선 실투영**으로 잰다
|
||||
// (`wingSpanOnRoute`, 직선 투영은 곡선+먼 편거리에서 3m 어긋났다),
|
||||
// ② 행 편거리를 스케치의 **안쪽·바깥쪽 끝**까지 늘린다(`wingRangeAt`,
|
||||
// 좌측 고리는 패치보다 1.5m 안쪽에 있었다). BOX암거는 둘 다 제외 — 종전 그대로.
|
||||
// 71 = 안쪽 확장을 **세월교 바닥판 상단 Z(`slabTopElevation`)로 트림**한다 — 성토
|
||||
// 물매 역연장이 유로 위로 솟아 바닥 상단과 안 만나는 자리로 튀었다(2026-08-27 사용자).
|
||||
// 72 = 날개 스윕 범위를 **날개벽 끝점 횡단선**까지로 바꾼다 — 고리 전체가 아니라
|
||||
// 날개벽 선 두 끝을 노선에 실투영해 재고, 구조물 점유 구간 ±5m 여유 안에서
|
||||
// 트림한다. 고리와 안 만나는 중간 행도 더는 끊지 않는다(2026-08-27 사용자:
|
||||
// "5m 늘려 서피스 만들고 날개벽 끝점 횡단선으로 트림").
|
||||
// 73 = ① 패치 점 중 **세월교 바닥판 상단 Z를 넘는 것은 지운다**(`clipRunBelow`,
|
||||
// 종단 shift 반영·천장 교차점 삽입), ② 날개 스팬을 다시 **고리 전체 점**으로
|
||||
// 잰다 — 벽 끝점만 쓰면 좌측이 143.2~157.3 → 144.4~155.9로 줄었다
|
||||
// (2026-08-27 사용자: "좌측 스윕 더 가야 함").
|
||||
// 74 = (날개 몫 행 트림 시도 — 사용자 지시로 **원복**. 안쪽까지 자르면 구조물 구간과
|
||||
// 날개 사이가 벌어졌고, 바깥선만 자르는 판은 버전을 안 올려 화면에 못 올라갔다.)
|
||||
// 75 = 73 상태로 원복 — 트림 없음. 날개 몫 행은 지반 핀 + 스케치 확장 + 바닥 상단
|
||||
// Z 초과 삭제까지만 한다(2026-08-27 사용자: "2번 전으로").
|
||||
// (76~78의 트림 도구 `wingOuterLines`·`wingTrimAt`·`clipRunToRange` 는 2026-09-02에
|
||||
// 제거했다 — 원복 뒤 다시 배선되지 않아 참조가 0이었다. 아래는 이력으로만 남긴다.)
|
||||
// 76 = 날개 몫 행을 **주황 고리의 바깥선**에서 끝낸다(`wingOuterLines`+`wingTrimAt`).
|
||||
// 고리가 그 행까지 모자라면 **끝 마디를 복사해 20m 연장**해서 만나게 한다
|
||||
// (2026-08-27 사용자). 안쪽은 안 자른다.
|
||||
// 77 = 주황 바깥선 트림을 **모든 행**에 건다 — 구조물 점유 구간 안 행을 빼놨더니
|
||||
// 그 구간이 커브 밖으로 삐져나왔다. 자르는 게 아니라 커브를 새 바깥 끝으로
|
||||
// 삼아 줄을 다시 잡고 `resample`이 열을 그 위에 다시 깔아 **메시가 재배치**된다
|
||||
// (2026-08-27 사용자: "메시의 재배치가 필요하잖아, 그래야 잘리지").
|
||||
// 78 = 날개 몫 로직을 **구조물 종류로 가른다**(2026-08-27 사용자: "세월교, 박스암거,
|
||||
// 기슭막이") — `section.ford`일 때만 실투영 스팬·스케치 확장·바깥선 트림을 태우고
|
||||
// BOX암거·기슭막이는 종전 경로 그대로. 트림선은 **날개 하나 단위**로 나눠 행마다
|
||||
// 누가거리가 가장 가까운 날개 것만 쓴다(20m 연장선이 서로 먹던 것을 막는다).
|
||||
// 79 = 세월교 패치를 **절취 영역 셀 마스크의 반대 규칙**으로 클립한다 — 영역 밖 셀은
|
||||
// 지우고 걸친 셀은 안쪽 조각만 남긴다(`patchClip`). 행 단위 바깥선 트림은 폐기 —
|
||||
// 비스듬한 사다리꼴 경계를 행 끝 하나로는 못 맞춰 74셀이 삐져나왔다(실측).
|
||||
// 지우는 면과 채우는 면이 같은 경계를 공유한다(2026-08-27 사용자).
|
||||
// 80 = 배수관 세트(기슭막이) **노견 → 벽 이음선 상단 성토 사면**을 되메움 리본 배열
|
||||
// (`fillRuns`)에도 담는다 — 78/79의 포켓은 노견까지 파는데 79까지의 배열에는
|
||||
// 벽 아래 다단·집수정 성토선만 있어 **기슭막이 상단부가 통째로 비었다**
|
||||
// (2026-08-27 사용자). 새 면이 아니라 실루엣이 이미 쓰던 `designTrim` 사면선
|
||||
// 그대로다. 벽 루프·집수정 분기가 같은 선을 담으므로 측당 한 번만 등록한다.
|
||||
// 81 = `slope-deformed` 커브를 **패치 리본과 같은 규칙**으로 끝낸다 — 실루엣 바깥
|
||||
// 끝이 그 측점 지반에 안 닿으면(BOX암거: 구체 최상단) 외삽하지 않고 실루엣이
|
||||
// 끝나는 자리를 바깥 끝으로 쓴다. 종전에는 이 커브만 무조건 최대 30m 외삽해
|
||||
// 성토 끝선까지 갔고, BOX는 기본값에서 변형 성토선이 원본 성토선과 정확히
|
||||
// collinear가 되어 `원본 − (구조물 ∪ 변형)` = ∅ — **구체 밖 원지반 재노출이
|
||||
// 통째로 미실행**이었다(2026-08-27 사용자 확정: "구체 밖은 원지반이어야 한다").
|
||||
// 실루엣이 구조물에서 끝나는지는 기하로 재지 않고 `endsAtStructure` 표식으로
|
||||
// 가른다 — 낙차로 재면 기슭막이 유출측처럼 지반보다 바깥으로 나간 실루엣까지
|
||||
// 걸려 원래 짧게 잘리던 것이 안 잘린다(실측 84.3 우측 5.10 → 5.69m).
|
||||
// 아울러 BOX 날개 사다리꼴(`wing-box`)을 원지반 재노출 후보에 넣는다 — 절취
|
||||
// 소스에는 있고 되살릴 목록에는 없어 그 자리가 성토면도 지반도 없는 구멍이었다
|
||||
// (2026-08-27 사용자: "날개부의 평면 스케치 영역까지 원지반이 같이 노출").
|
||||
// 세월교는 제외 — 그 자리는 되메움 패치가 덮는다(`wingRangeAt`, isFord 전용).
|
||||
// 82 = BOX암거 `structure` 탑뷰 커브를 부재별 7고리가 아니라 **아령 외곽선 한 줄**로
|
||||
// 낸다(2026-08-27 사용자: "바닥을 위에서 보면 아령형상이고 제일 외곽에 있는 선
|
||||
// 성분을 평면 투영하면 경계"). 상판+에이프런에 `planOutline` 표식을 달고
|
||||
// `_Plan_Footprint.ts`가 링 자리마다 편거리 창을 합친다 — 기하 불리언 없음
|
||||
// (BOX 부재는 전부 같은 고정축 위 평행 링이다). 종전 부재별 고리에는 날개벽이
|
||||
// **패널 두께 슬리버**로 섞여 있었고(실측 0.40㎡ / bbox 1.69×1.40 ×2), 그 슬리버가
|
||||
// "덮인 자리"로 빠져 그 자리만 성토면도 지형도 없는 검은 줄이 됐다. 에이프런은
|
||||
// 날개벽 발치를 얹으려고 넓혀 둔 부재라 외곽선이 날개를 이미 품는다.
|
||||
// 83 = 원지반 재노출 영역을 **`cut-merged`**(성토면에서 실제로 도려낸 셀의 윤곽)로
|
||||
// 바꾼다. 종전에는 `원본 성토 스케치 − (구조물 ∪ 변형 성토 스케치)`로 영역을
|
||||
// **다시 조립**해, 지우는 쪽과 되살리는 쪽이 다른 커브를 쓰며 경계가 어긋났다
|
||||
// (날개벽 패널 두께 슬리버 자리에 검은 줄). 이제 지우는 쪽이 남긴 자국을 그대로
|
||||
// 쓰므로 **원본 지형 메시가 그 자리에 그대로 남는다** — 새 서피스를 만들지 않는다
|
||||
// (2026-08-27 사용자: "원지반 오리지널 데이터에서 가져오라니까").
|
||||
// 84 = 잘린 자리 윤곽(`cut-merged`) 추적을 고친다 — 갈림목에서 아무 변이나 집어
|
||||
// walk가 남의 고리로 새 나가고, 막다른 길에서 끊긴 **열린 사슬이 고리로 담겼다**.
|
||||
// 표시용일 땐 안 보였지만 이제 이 커브가 되살릴 영역의 근거라 그대로 구멍이 된다
|
||||
// (실측: 도려낸 셀의 18% — 성토 끝단 쪽 col 40~46 — 이 영역에서 빠졌다.
|
||||
// 2026-08-27 사용자: "유출구에 일부 성토부에 터짐 있음"). 오른손 규칙으로 갈림목을
|
||||
// 가르고 **닫힌 고리만** 담는다.
|
||||
// 85 = 물넘이포장 자리 노면을 판다 — 횡단도와 같은 산식(fordDeckElevationAt)으로 노면·노견
|
||||
// 조각 표고를 내린다. 노면 밖 비탈은 그대로라 노견 끝에 수직 단차가 선다(2026-08-28 사용자).
|
||||
// 86 = 독립 기슭막이(구조물 정본 D군)를 3D에 세운다 — 횡단 카드와 같은 폴리곤
|
||||
// (computeRevetmentLayout)을 기준측점 전/후 길이만큼 스윕한다(2026-08-28 사용자).
|
||||
// 87 = 독립 기슭막이 **다단** — 단마다 솔리드를 세운다. 단 사이 성토는 1:1.2,
|
||||
// 다음 단 시작점은 윗단 하단 수평선 +근입과 전면 경사선의 교차점(2026-08-22 규칙 승계).
|
||||
// 88 = 독립 기슭막이 기준을 **맨 위 단**으로 되돌리고 추가 단은 아래로 붙인다(배관 세트와
|
||||
// 같은 규칙). 기준 자리는 사용자 이동값(기준 올림·좌우)을 탄다(2026-08-28 사용자).
|
||||
// 89 = BOX암거 패치도 **절취 영역 안쪽으로 자른다**(2026-09-02). 그 규칙이 세월교에만
|
||||
// 걸려 있어 BOX 패치는 한 번도 안 잘리고 영역 밖으로 넘쳤다 — 실측에서 BOX 패치
|
||||
// 리본 두 벌(198.4~203.5m)에 셀 마스크가 없었다. BOX도 `wing-box` 커브로 절취
|
||||
// 영역을 내므로 같은 반대 규칙이 성립한다.
|
||||
// 90 = 잘린 자리 윤곽(`cut-merged`)의 **시작 모서리 유실**을 고친다 — `dropCollinear`가
|
||||
// 고리의 닫힘 중복점(머리=꼬리)을 순환 이웃으로 셈해 머리 모서리를 지우고, 그 양옆
|
||||
// 일직선 점까지 빠져 첫 행이 대각선으로 잘렸다(실측: 8고리 전부, 63셀 구멍 —
|
||||
// 우 197.6~198.2m BOX 앞 쐐기 33셀이 가장 큼). 되살릴 영역이 이 커브라 저장본을 만료한다.
|
||||
// 91 = 구조물 솔리드에 **부재키**(`key`)를 싣는다 — 3D 개별 선택이 그 표로 부재를
|
||||
// 가려낸다(2026-09-04). 옛 저장본엔 키가 없어 3D에서 골라도 B06 조정창으로 넘길
|
||||
// 부재를 못 정하므로 만료한다.
|
||||
// 92 = 세월교·BOX암거 부재까지 **부재키를 일원화**한다 — 횡단도가 부르는 이름 그대로
|
||||
// (세월교 유입·유출, BOX 좌·우, 본체는 `body`). 91 저장본엔 배수관 세트 키만 있다.
|
||||
const BUILD_VERSION = 92;
|
||||
|
||||
/** 종횡단 정본에서 코리도에 영향을 주는 입력만 요약해 해시 — 갱신 감지 기준. */
|
||||
export function corridorHash(detail: SectionDetailResponse, routePoints: RoutePoint[]): string {
|
||||
const parts: Array<string | number> = [`v${BUILD_VERSION}`, routePoints.length];
|
||||
routePoints.forEach((p) => parts.push(p.x.toFixed(2), p.y.toFixed(2)));
|
||||
detail.cross_sections.forEach((section) => {
|
||||
const design = section.design;
|
||||
parts.push(
|
||||
section.chainage_m.toFixed(3),
|
||||
section.center_x.toFixed(2),
|
||||
section.center_y.toFixed(2),
|
||||
);
|
||||
if (!design) {
|
||||
parts.push("nodesign");
|
||||
return;
|
||||
}
|
||||
parts.push(
|
||||
design.design_elevation_m.toFixed(3),
|
||||
design.section_mode,
|
||||
design.ditch_side ?? "",
|
||||
design.ditch_type ?? "",
|
||||
String(design.ditch_enabled ?? ""),
|
||||
design.design_line.length,
|
||||
);
|
||||
design.design_line.forEach((point) =>
|
||||
parts.push(point.offset_m.toFixed(3), point.elevation_m.toFixed(3)),
|
||||
);
|
||||
// 구조물(배수관 세트) 입력 — 길이·전후·형식이 바뀌면 재빌드되어야 한다.
|
||||
structureHashParts(section).forEach((part) => parts.push(part));
|
||||
});
|
||||
return fnv1a(parts.join("|"));
|
||||
}
|
||||
|
||||
function base64FromFloats(values: Float64Array): string {
|
||||
const bytes = new Uint8Array(values.buffer, values.byteOffset, values.byteLength);
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64FromBytes(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function bytesFromBase64(encoded: string): Uint8Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function floatsFromBase64(encoded: string): Float64Array {
|
||||
const binary = atob(encoded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Float64Array(bytes.buffer);
|
||||
}
|
||||
|
||||
export function serialize(build: CorridorBuildResult, hash: string): CorridorEnvelope {
|
||||
return {
|
||||
version: ENVELOPE_VERSION,
|
||||
hash,
|
||||
ribbons: build.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positionsBase64: base64FromFloats(ribbon.positions),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMask ? { cellMaskBase64: base64FromBytes(ribbon.cellMask) } : {}),
|
||||
...(ribbon.trimTris && ribbon.trimUvs
|
||||
? {
|
||||
trimTrisBase64: base64FromFloats(ribbon.trimTris),
|
||||
trimUvsBase64: base64FromBytes(new Uint8Array(ribbon.trimUvs.buffer.slice(0))),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: build.outline,
|
||||
caps: build.caps,
|
||||
structures: build.structures,
|
||||
planCurves: build.planCurves,
|
||||
cutWalls: (build.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positionsBase64: base64FromFloats(wall.positions),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function deserialize(envelope: CorridorEnvelope): CorridorBuildResult {
|
||||
return {
|
||||
ribbons: envelope.ribbons.map((ribbon) => ({
|
||||
kind: ribbon.kind,
|
||||
side: ribbon.side,
|
||||
colCount: ribbon.colCount,
|
||||
chainages: ribbon.chainages,
|
||||
positions: floatsFromBase64(ribbon.positionsBase64),
|
||||
...(ribbon.patch ? { patch: true } : {}),
|
||||
...(ribbon.patchClip ? { patchClip: true } : {}),
|
||||
...(ribbon.cellMaskBase64 ? { cellMask: bytesFromBase64(ribbon.cellMaskBase64) } : {}),
|
||||
...(ribbon.trimTrisBase64 && ribbon.trimUvsBase64
|
||||
? {
|
||||
trimTris: floatsFromBase64(ribbon.trimTrisBase64),
|
||||
trimUvs: new Float32Array(bytesFromBase64(ribbon.trimUvsBase64).buffer),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
outline: envelope.outline,
|
||||
caps: envelope.caps ?? [],
|
||||
structures: envelope.structures ?? [],
|
||||
planCurves: envelope.planCurves ?? [],
|
||||
cutWalls: (envelope.cutWalls ?? []).map((wall) => ({
|
||||
side: wall.side,
|
||||
positions: floatsFromBase64(wall.positionsBase64),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -45,12 +45,16 @@ import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
createMassHaulWindowState,
|
||||
MASS_HAUL_MIN_HEIGHT,
|
||||
scheduleMassHaulSettle,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import "@util/common_util_mass_haul.css";
|
||||
import "./B05_Profile_UI_Style_MassHaul.css";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
|
||||
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
|
||||
const OPEN_KEY = "b05-route-profile-masshaul-open";
|
||||
@@ -101,6 +105,9 @@ export function buildStickyYAxis(
|
||||
const tick = document.createElement("span");
|
||||
tick.className = "b05-profile__yaxis-tick";
|
||||
tick.style.top = `${y}px`;
|
||||
// 종단 그래프의 세로 창 변환이 이 눈금도 같이 옮긴다 — 그릴 때의 y를 남겨 둔다
|
||||
// (`_UI_Profile_YWindow`, 2026-09-04). 유토곡선 축에는 갱신이 오지 않아 무해하다.
|
||||
tick.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
|
||||
tick.textContent = label;
|
||||
inner.append(tick);
|
||||
});
|
||||
@@ -216,7 +223,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
});
|
||||
},
|
||||
});
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer);
|
||||
// 재계산 대기 표시 — 곡선 영역 우측 상단에 **떠 있는** 알림이다. 예전에는 요약 막대에
|
||||
// 붙였는데 막대가 줄바꿈되며 곡선을 아래로 밀어냈다(2026-09-04 사용자 보고: 「창 밀림」).
|
||||
const pendingChip = document.createElement("span");
|
||||
pendingChip.className = "b05-profile__masshaul-pending";
|
||||
pendingChip.textContent = "다시 계산 중…";
|
||||
pendingChip.hidden = true;
|
||||
overlay.append(resizer.root, bar, scroll, legendLayer, pendingChip);
|
||||
|
||||
// 측점선이 아닌 빈 곳 클릭 = 선택 해제 — 종단 그래프와 같은 규칙(2026-08-04 사용자 지시).
|
||||
let clearSelection: (() => void) | undefined;
|
||||
@@ -227,20 +240,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
|
||||
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
|
||||
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
|
||||
scroll.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.shiftKey || event.deltaY === 0) return;
|
||||
const limit = scroll.scrollWidth - scroll.clientWidth;
|
||||
if (limit <= 0) return;
|
||||
const delta = event.deltaY;
|
||||
if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit))
|
||||
return;
|
||||
scroll.scrollLeft += delta;
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/**
|
||||
* 손잡이를 오버레이 **위 경계**에 태운다(2026-08-04 사용자 지시 — 펼치면 같이 올라와
|
||||
@@ -298,6 +298,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
|
||||
function note(message: string): void {
|
||||
drawnOnce = false;
|
||||
pendingChip.hidden = true;
|
||||
bar.replaceChildren();
|
||||
scroll.replaceChildren();
|
||||
legendLayer.replaceChildren();
|
||||
@@ -307,7 +308,13 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
bar.append(empty);
|
||||
}
|
||||
|
||||
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
|
||||
const windowState = createMassHaulWindowState();
|
||||
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
|
||||
let lastParams: RouteMassHaulDrawParams | null = null;
|
||||
|
||||
function draw(params: RouteMassHaulDrawParams): void {
|
||||
lastParams = params;
|
||||
clearSelection = params.onClearSelection;
|
||||
overlay.hidden = !open;
|
||||
syncHandlePosition();
|
||||
@@ -324,20 +331,14 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
// 안에서 끝나 수십 ms면 끝나므로, 그동안 **직전 곡선을 그대로 두고** 상태줄로만
|
||||
// 알린다. 아직 한 번도 못 그렸을 때만 문구로 자리를 채운다.
|
||||
if (params.pendingRecalc) {
|
||||
if (drawnOnce) {
|
||||
const pending = bar.querySelector(".b05-profile__masshaul-pending");
|
||||
if (!pending) {
|
||||
const chip = document.createElement("span");
|
||||
chip.className = "b05-profile__masshaul-pending";
|
||||
chip.textContent = "다시 계산 중…";
|
||||
bar.append(chip);
|
||||
}
|
||||
} else {
|
||||
note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
||||
}
|
||||
if (drawnOnce) pendingChip.hidden = false;
|
||||
else note("횡단을 지금 계획선에 맞춰 다시 계산하는 중입니다.");
|
||||
return;
|
||||
}
|
||||
pendingChip.hidden = true;
|
||||
// B06과 같은 계산 — 횡단 기준(정식)과 종단 기준(개략 비교)을 함께 낸다.
|
||||
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
|
||||
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
|
||||
const series: MassHaulSeries[] = computeMassHaulSeries(
|
||||
params.longitudinal,
|
||||
params.crossSections,
|
||||
@@ -374,7 +375,7 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
series,
|
||||
visible,
|
||||
params.stationSource,
|
||||
params.axis,
|
||||
{ ...params.axis, window: windowState },
|
||||
params.selectedStationId,
|
||||
params.stationInterval,
|
||||
params.widthPx,
|
||||
@@ -405,6 +406,10 @@ export function createRouteMassHaulPanel(onChanged: () => void): RouteMassHaulPa
|
||||
}),
|
||||
);
|
||||
drawnOnce = true;
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
|
||||
scheduleMassHaulSettle(windowState, () => {
|
||||
if (lastParams) draw(lastParams);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -85,6 +85,9 @@ const MAX_PANEL_HEIGHT_RATIO = 0.9;
|
||||
* 계산이 브라우저 안에서 끝나면서(2026-09-03) 서버 왕복이 사라져 250→60ms 로 줄였다 —
|
||||
* 실측 전 측점(128) 재계산 7.5ms 라 손을 떼는 즉시 유토곡선이 따라온다. */
|
||||
const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
/** 구조물 측점을 종단 정본 측점으로 맞춰 주는 허용 오차(m). 층마다 반올림이 달라 생기는
|
||||
* mm~cm 어긋남만 흡수할 크기다 — 이보다 멀면 서로 다른 측점이다. */
|
||||
const STATION_SNAP_TOLERANCE_M = 0.1;
|
||||
|
||||
/**
|
||||
* 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**.
|
||||
@@ -97,6 +100,8 @@ const CROSS_PREVIEW_DEBOUNCE_MS = 60;
|
||||
/** 콜백 타입은 700줄 제한으로 `_Panel_Types` 로 옮겼다 — 옛 임포트 경로가 그대로
|
||||
* 동작하도록 여기서 다시 내보낸다(2026-09-04). */
|
||||
import type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
import { needsFullRedraw, type ElevationWindowResult } from "@util/common_util_chart_ywindow";
|
||||
export type { RouteProfilePanelCallbacks } from "./B05_Profile_UI_Profile_Panel_Types";
|
||||
|
||||
export function createRouteProfilePanel(
|
||||
@@ -339,8 +344,40 @@ export function createRouteProfilePanel(
|
||||
세로는 보이는 구간에 맞춰 자동이라 사람이 맞출 것이 없다(2026-09-04 사용자 확정). */
|
||||
const profileZoom = createProfileZoom(() => draw());
|
||||
|
||||
/**
|
||||
* 구조물 측점의 누가거리를 **종단 정본 값으로 갈아 끼운다**(2026-09-04).
|
||||
*
|
||||
* 같은 구조물이 층마다 다른 누가거리를 들고 있었다 — 횡단 측점 `264.054626`,
|
||||
* 종단 정본 변화점 `264.055`, 배수 정본 파일 `264.06`(cm 반올림). 사이드 목록이 든
|
||||
* 값으로 계획고를 편집하면 정본 옆 mm 자리에 **가짜 변화점**이 하나 더 서고, 둘 사이
|
||||
* 종단곡선이 mm 로 쭈그러들어 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
*
|
||||
* 2026-09-03 에 ▲▼ 버튼 경로만 막았는데 측점 테이블·도구 경로가 남아 다시 터졌다.
|
||||
* 값이 화면에 들어오는 **이 한 자리**에서 정본으로 맞춰 모든 경로를 함께 막는다.
|
||||
*/
|
||||
function snapToMasterStations(stations: IrregularStation[]): IrregularStation[] {
|
||||
const master = alignment?.stations;
|
||||
if (!master?.length) return stations;
|
||||
return stations.map((station) => {
|
||||
let best: number | null = null;
|
||||
let gap = STATION_SNAP_TOLERANCE_M;
|
||||
for (const node of master) {
|
||||
const distance = Math.abs(node.chainage_m - station.chainage_m);
|
||||
if (distance <= gap) {
|
||||
gap = distance;
|
||||
best = node.chainage_m;
|
||||
}
|
||||
}
|
||||
return best === null || best === station.chainage_m
|
||||
? station
|
||||
: { ...station, chainage_m: best };
|
||||
});
|
||||
}
|
||||
|
||||
/** 세로 자동 맞춤이 지금 쓰는 Y 창. 계획고를 끄는 동안에는 이 값을 붙잡는다. */
|
||||
let elevationWindow: { min: number; max: number } | undefined;
|
||||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기 — 그릴 때마다 렌더러가 새로 준다. */
|
||||
let updateElevationWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
/** 계획고 편집 버튼(▲▼)을 누르고 있는 중인가 — 그동안 Y 축을 고정한다. */
|
||||
let heightEditing = false;
|
||||
const holdElevationRange = (
|
||||
@@ -505,17 +542,25 @@ export function createRouteProfilePanel(
|
||||
selectedRuns: () => tools.selectedRuns(),
|
||||
stationIdAtStructure,
|
||||
redraw: draw,
|
||||
setElevationWindowUpdater: (update) => {
|
||||
updateElevationWindow = update;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */
|
||||
const SCROLL_SETTLE_MS = 160;
|
||||
/** 마지막으로 세로를 맞춘 가로 위치 — 같은 자리면 다시 그리지 않는다(재구성 되먹임 차단). */
|
||||
/** 그래프를 통째로 다시 만들 때까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
/** 마지막으로 통째로 다시 만든 가로 위치 — 같은 자리면 다시 그리지 않는다. */
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 스크롤하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤에 한 번만 다시 맞춘다(2026-09-04).
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정) — 스크롤마다 겹 하나의
|
||||
// 변환만 갈아 끼우므로 실시간으로 따라온다. 눈금 간격이 어긋나면 눈금층(요소 20개 안팎)만
|
||||
// 새로 만든다. 그래프를 통째로 다시 만드는 34ms 짜리 재구성은 그래도 안 될 때만 남는다.
|
||||
body.addEventListener("scroll", () => {
|
||||
if (heightEditing) return;
|
||||
const fitted = updateElevationWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (heightEditing || Math.abs(body.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = body.scrollLeft;
|
||||
@@ -525,19 +570,7 @@ export function createRouteProfilePanel(
|
||||
|
||||
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
|
||||
// 노선을 훑을 수 있게 한다 (Shift+휠은 브라우저 기본 가로 스크롤이라 그대로 둔다).
|
||||
body.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.shiftKey || event.deltaY === 0) return;
|
||||
const delta = event.deltaY;
|
||||
const limit = body.scrollWidth - body.clientWidth;
|
||||
if (limit <= 0) return;
|
||||
if ((delta < 0 && body.scrollLeft <= 0) || (delta > 0 && body.scrollLeft >= limit)) return;
|
||||
body.scrollLeft += delta;
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
attachWheelHorizontalScroll(body);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (
|
||||
@@ -652,7 +685,7 @@ export function createRouteProfilePanel(
|
||||
},
|
||||
/** 비정규 측점 목록을 반영해 그래프(세로선+라벨)·테이블(주석)을 다시 그린다. */
|
||||
setIrregularStations(stations: IrregularStation[]) {
|
||||
irregularStations = stations;
|
||||
irregularStations = snapToMasterStations(stations);
|
||||
draw();
|
||||
},
|
||||
/** 구조물 타입 레지스트리를 받아 마크 색·약호·우클릭 메뉴에 쓴다(최초 1회). */
|
||||
|
||||
@@ -28,6 +28,11 @@ import {
|
||||
} from "./B05_Profile_UI_Profile_Alignment";
|
||||
import { createEditOverlay } from "./B05_Profile_UI_Profile_Edit";
|
||||
import { createRunHighlight } from "./B05_Profile_UI_Profile_RunHighlight";
|
||||
import {
|
||||
applyElevationWindow,
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import type { StraightRun } from "./B05_Profile_UI_Profile_Straighten";
|
||||
import type { MinCoverPoint } from "./B05_Profile_UI_Profile_MinCover";
|
||||
import { buildStickyYAxis, type RouteMassHaulDrawParams } from "./B05_Profile_UI_Profile_MassHaul";
|
||||
@@ -104,6 +109,9 @@ export interface ProfileRenderContext {
|
||||
stationIdAtStructure: (structureId: string | null) => string | null;
|
||||
/** 알약을 골랐을 때처럼 그리는 도중 다시 그려야 하는 자리. */
|
||||
redraw: () => void;
|
||||
/** 세로 창을 **다시 그리지 않고** 옮기는 갱신기를 패널에 넘긴다(2026-09-04).
|
||||
* 가로 스크롤마다 이것만 부르면 그래프 재구성(실측 34ms)이 사라진다. */
|
||||
setElevationWindowUpdater: (update: (() => ElevationWindowResult | null) | null) => void;
|
||||
}
|
||||
|
||||
/** 본문을 통째로 다시 그린다. 상세가 없거나 본문이 아직 0크기면 아무것도 하지 않는다. */
|
||||
@@ -323,10 +331,33 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
},
|
||||
onMove: (structureId, toChainage) => callbacks?.onStructureMarkMove?.(structureId, toChainage),
|
||||
});
|
||||
// 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고
|
||||
// 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정).
|
||||
// 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다).
|
||||
let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null;
|
||||
ctx.setElevationWindowUpdater(() => {
|
||||
const from = Math.max(0, toChainage(body.scrollLeft));
|
||||
const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth));
|
||||
const next = ctx.holdElevationRange(
|
||||
windowElevationRange(
|
||||
[graphLongitudinal.samples, ...designProfiles.map((profile) => profile.samples)],
|
||||
from,
|
||||
to,
|
||||
) ?? null,
|
||||
);
|
||||
massHaulUpdater?.(from, to);
|
||||
return applyElevationWindow(chartWrap, next);
|
||||
});
|
||||
|
||||
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
|
||||
// 앵커(0크기 sticky)는 **첫 자식**이어야 한다 — SVG 뒤에 붙이면 흐름 위치가 차트
|
||||
// 아래로 밀려 스크롤 시 축이 화면에 안 보인다(2026-08-04 확인, B06과 같은 규칙).
|
||||
if (yAxis) chartWrap.prepend(buildStickyYAxis(yAxis, chartHeight));
|
||||
if (yAxis) {
|
||||
const axisOverlay = buildStickyYAxis(yAxis, chartHeight);
|
||||
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
|
||||
axisOverlay.classList.add(Y_AXIS_WINDOW_CLASS);
|
||||
chartWrap.prepend(axisOverlay);
|
||||
}
|
||||
// 쉬프트로 고른 구간 강조 — 편집 버튼층보다 아래에 깔아 버튼을 가리지 않는다.
|
||||
if (alignment) {
|
||||
const highlight = createRunHighlight({
|
||||
@@ -373,7 +404,7 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
// 유토곡선 오버레이 — 종단도·테이블 위를 덮는 서브패널(2026-08-04 사용자 확정).
|
||||
// X 매핑(누가거리 최댓값·여백·폭)을 종단 그래프와 똑같이 넘겨야 측점 세로선이 맞물린다.
|
||||
if (alignment && designProfiles[0]) {
|
||||
massHaul.draw({
|
||||
const massHaulParams: RouteMassHaulDrawParams = {
|
||||
stationSource: graphLongitudinal,
|
||||
// 종단 개략 곡선은 편집이 반영된 현재 계획선을, 정식 곡선은 상세 조회가 내려준
|
||||
// 측점별 횡단 설계(기본 프리뷰 포함)를 입력으로 쓴다 — B06과 같은 재료다.
|
||||
@@ -410,6 +441,17 @@ export function renderProfile(ctx: ProfileRenderContext): void {
|
||||
onClearSelection: () => {
|
||||
if (ctx.selectedStationId() !== null) ctx.selectStation(null);
|
||||
},
|
||||
});
|
||||
};
|
||||
massHaul.draw(massHaulParams);
|
||||
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
|
||||
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
|
||||
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
|
||||
// 값보다 크다(요소 수가 종단의 1/3).
|
||||
massHaulUpdater = (fromM, toM) => {
|
||||
massHaul.draw({
|
||||
...massHaulParams,
|
||||
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import { createPanelResizer } from "@ui/ui_template_resizer";
|
||||
import { attachWheelHorizontalScroll } from "./B05_Profile_UI_Profile_Wheel";
|
||||
|
||||
const OPEN_KEY = "b05:profile:table:open";
|
||||
const HEIGHT_KEY = "b05:profile:table:height";
|
||||
@@ -87,20 +88,7 @@ export function createProfileTableOverlay(onChanged: () => void): RouteProfileTa
|
||||
scroll.addEventListener("contextmenu", (event) => event.preventDefault());
|
||||
|
||||
// 유토곡선 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(종단 스크롤도 함께 움직인다).
|
||||
scroll.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.shiftKey || event.deltaY === 0) return;
|
||||
const limit = scroll.scrollWidth - scroll.clientWidth;
|
||||
if (limit <= 0) return;
|
||||
const delta = event.deltaY;
|
||||
if ((delta < 0 && scroll.scrollLeft <= 0) || (delta > 0 && scroll.scrollLeft >= limit))
|
||||
return;
|
||||
scroll.scrollLeft += delta;
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
attachWheelHorizontalScroll(scroll);
|
||||
|
||||
/** 손잡이는 오버레이 위 경계를, 접히면 열린 유토곡선의 위 경계(bottomOffset)를 따라간다
|
||||
* — 접힌 버튼이 펼쳐진 패널 수평선에 놓인다(2026-08-05 사용자 지시). */
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_Profile_Wheel.ts
|
||||
* 세로 휠 → 가로 이동. 종단 그래프·유토곡선·측점 테이블 세 스크롤러가 같은 문법을 쓴다
|
||||
* (2026-08-04 사용자 지시). 셋에 똑같이 복사돼 있던 것을 한 벌로 모았다(2026-09-04).
|
||||
*
|
||||
* **부드럽게 이동**(2026-09-04 사용자 지시) — 예전처럼 `scrollLeft`에 곧바로 더하면 휠
|
||||
* 한 칸(120px)이 통째로 점프해 툭툭 끊긴다.
|
||||
*
|
||||
* 브라우저 기본 `scrollTo({ behavior: "smooth" })`는 쓰지 않는다. 휠을 굴리는 동안 매번
|
||||
* 애니메이션이 **처음부터 다시 시작**돼 느린 구간만 반복하기 때문이다 — 실측으로 1.6초
|
||||
* 동안 1,200px 중 17px 만 움직였다(2026-09-04). 대신 목표만 쌓아 두고 프레임마다 남은
|
||||
* 거리의 일정 비율씩 좁힌다. 목표가 늘어나도 속도가 이어지므로 굴릴수록 뒤처지지 않는다.
|
||||
*
|
||||
* Shift+휠은 브라우저 기본 가로 스크롤이라 건드리지 않는다.
|
||||
* ========================================================================== */
|
||||
|
||||
/** 프레임마다 남은 거리에서 좁히는 비율. 0.35면 60fps에서 약 0.13초에 도착한다 —
|
||||
* 눈으로는 이어져 보이면서, 멈춘 뒤 세로 눈금이 따라오는 시간도 예전만큼 짧다. */
|
||||
const EASE = 0.35;
|
||||
/** 이보다 가까우면 도착으로 보고 딱 맞춘다(px). */
|
||||
const SNAP_PX = 0.5;
|
||||
/** EASE의 기준 프레임 간격(ms) — 60Hz 한 칸. */
|
||||
const BASE_FRAME_MS = 1000 / 60;
|
||||
/** 한 프레임으로 인정하는 최대 간격(ms). 탭이 잠들었다 깨면 간격이 수백 ms라 그대로
|
||||
* 쓰면 한 번에 목표까지 튄다. */
|
||||
const MAX_FRAME_MS = 64;
|
||||
|
||||
export function attachWheelHorizontalScroll(scroller: HTMLElement): void {
|
||||
let target = -1;
|
||||
/** 우리가 마지막으로 써 넣은 값 — 남이 자리를 옮겼는지 가리는 데 쓴다. */
|
||||
let applied = -1;
|
||||
let frame = 0;
|
||||
/** 직전 프레임 시각 — 좁히는 양을 **흐른 시간**에 맞추는 데 쓴다. */
|
||||
let lastFrameAt = 0;
|
||||
|
||||
function stop(): void {
|
||||
if (frame) cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
target = -1;
|
||||
applied = -1;
|
||||
lastFrameAt = 0;
|
||||
}
|
||||
|
||||
function step(now: number): void {
|
||||
frame = 0;
|
||||
// 스크롤바 끌기·스크롤 동기화처럼 다른 경로가 자리를 옮겼으면 이동을 접는다.
|
||||
if (applied >= 0 && Math.abs(scroller.scrollLeft - applied) > 1) return stop();
|
||||
const limit = scroller.scrollWidth - scroller.clientWidth;
|
||||
const goal = Math.min(limit, Math.max(0, target));
|
||||
const rest = goal - scroller.scrollLeft;
|
||||
if (Math.abs(rest) <= SNAP_PX) {
|
||||
scroller.scrollLeft = goal;
|
||||
return stop();
|
||||
}
|
||||
// 프레임당 고정 비율로 좁히면 화면 주사율에 따라 속도가 달라진다 — 120Hz에서는 두 배
|
||||
// 빨라 툭 끊겨 보이고, 프레임을 한 번 놓치면 그만큼 덜컥 뛴다. 흐른 시간으로 환산해
|
||||
// **어느 화면에서나 같은 시간**에 도착하게 한다(2026-09-04).
|
||||
const elapsed = lastFrameAt ? Math.min(MAX_FRAME_MS, now - lastFrameAt) : BASE_FRAME_MS;
|
||||
lastFrameAt = now;
|
||||
const ratio = 1 - Math.pow(1 - EASE, elapsed / BASE_FRAME_MS);
|
||||
scroller.scrollLeft = scroller.scrollLeft + rest * ratio;
|
||||
applied = scroller.scrollLeft;
|
||||
frame = requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
scroller.addEventListener(
|
||||
"wheel",
|
||||
(event) => {
|
||||
if (event.shiftKey || event.deltaY === 0) return;
|
||||
const limit = scroller.scrollWidth - scroller.clientWidth;
|
||||
if (limit <= 0) return;
|
||||
// 이동 중이면 **직전 목표**에 이어 더한다 — 현재 위치에 더하면 아직 못 간 거리를
|
||||
// 잃어 굴릴수록 뒤처진다.
|
||||
const from = frame ? target : scroller.scrollLeft;
|
||||
const next = Math.min(limit, Math.max(0, from + event.deltaY));
|
||||
if (next === from && (next <= 0 || next >= limit)) return;
|
||||
target = next;
|
||||
if (!frame) {
|
||||
applied = scroller.scrollLeft;
|
||||
frame = requestAnimationFrame(step);
|
||||
}
|
||||
event.preventDefault();
|
||||
},
|
||||
{ passive: false },
|
||||
);
|
||||
}
|
||||
@@ -112,12 +112,22 @@
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
/* 재계산 대기 표시 — 곡선을 지우지 않고 요약줄 끝에만 붙인다(2026-09-03 깜빡임 제거). */
|
||||
/* 재계산 대기 표시 — 곡선을 지우지 않고(2026-09-03 깜빡임 제거) 곡선 영역 우측 상단에
|
||||
**떠 있는** 알림으로 둔다. 요약 막대에 붙이면 막대가 줄바꿈되며 곡선을 밀어냈다
|
||||
(2026-09-04 사용자 보고). 범례(top: 34px) 아래 줄에 앉혀 서로 가리지 않는다. */
|
||||
.b05-profile__masshaul-pending {
|
||||
margin-left: auto;
|
||||
position: absolute;
|
||||
top: 62px;
|
||||
right: var(--spacing-8);
|
||||
z-index: 4;
|
||||
padding: 2px var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-pills);
|
||||
background: var(--color-surface-raised);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
opacity: 0.8;
|
||||
line-height: 1.2;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-profile__masshaul-empty {
|
||||
|
||||
@@ -25,6 +25,63 @@ import {
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
|
||||
/**
|
||||
* 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다.
|
||||
*
|
||||
* 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다
|
||||
* (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로
|
||||
* 창을 옮길 수 있다(2026-09-04 사용자 확정).
|
||||
*/
|
||||
/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */
|
||||
export const Y_WINDOW_SPAN_PAD = 1.2;
|
||||
/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */
|
||||
export const Y_WINDOW_CLASS = "b06-chart__ywindow";
|
||||
/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */
|
||||
export const Y_GRID_CLASS = "b06-chart__ygrid";
|
||||
export const Y_TICK_CLASS = "b06-chart__yticks";
|
||||
/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */
|
||||
export const Y_WINDOW_BAKED_ATTR = "data-yw-y";
|
||||
/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */
|
||||
export const Y_WINDOW_ATTRS = {
|
||||
/** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */
|
||||
auto: "data-yw-auto",
|
||||
/** 그릴 때 쓴 창 중심 표고(m). */
|
||||
center: "data-yw-center",
|
||||
/** 표고 1m당 화면 px. */
|
||||
pxPerM: "data-yw-pxm",
|
||||
/** 창 중심 표고가 놓인 y(px). */
|
||||
zero: "data-yw-y0",
|
||||
/** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */
|
||||
top: "data-yw-top",
|
||||
height: "data-yw-height",
|
||||
/** 세로 과장(B06 조절값). 새 창의 px 환산에 그대로 곱한다. */
|
||||
exaggeration: "data-yw-exag",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다.
|
||||
* 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로
|
||||
* 내려가지 않는다(2026-08-23 사용자 지시).
|
||||
*
|
||||
* 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가
|
||||
* **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04).
|
||||
*/
|
||||
export function yWindowTickValues(
|
||||
center: number,
|
||||
span: number,
|
||||
plotHeight: number,
|
||||
): Array<{ value: number; label: string }> {
|
||||
const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1);
|
||||
const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9));
|
||||
const ticks: Array<{ value: number; label: string }> = [];
|
||||
const topValue = center + span / 2;
|
||||
for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) {
|
||||
ticks.push({ value, label: `${value.toFixed(decimals)}m` });
|
||||
value += step;
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string {
|
||||
const base = stationLabel(chainageM, interval);
|
||||
if (!stationOffset) return base;
|
||||
@@ -220,7 +277,7 @@ export function createLongitudinalProfile(
|
||||
? plotHeight / yScaleOptions.pixelsPerMeter
|
||||
: // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다
|
||||
// (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다.
|
||||
Math.max(rawMax - rawMin, 1) * 1.2;
|
||||
Math.max(rawMax - rawMin, 1) * Y_WINDOW_SPAN_PAD;
|
||||
// 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다.
|
||||
const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration);
|
||||
const x = (chainage: number) =>
|
||||
@@ -228,47 +285,24 @@ export function createLongitudinalProfile(
|
||||
const xInverse = inverseOf(x, maxChainage);
|
||||
const y = (elevation: number) =>
|
||||
LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
|
||||
// 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것.
|
||||
// 매핑은 1차식이다: y = zero − (표고 − center) × pxPerM.
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter));
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan));
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2));
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top));
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight));
|
||||
svg.setAttribute(Y_WINDOW_ATTRS.exaggeration, String(exaggeration));
|
||||
// 고정 배율(B06 공통 Y스케일)에 묶인 그래프만 창을 옮기지 않는다 — 세로 과장은 새 창의
|
||||
// px 환산에 그대로 곱하면 되므로 대상이다(B06 종단, 2026-09-04).
|
||||
if (!yScaleOptions) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1");
|
||||
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
|
||||
|
||||
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
|
||||
// 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은
|
||||
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
|
||||
const yAxisTicks: Array<{ y: number; label: string }> = [];
|
||||
const rawSpan = elevationSpan / exaggeration;
|
||||
const rawTop = viewCenter + rawSpan / 2;
|
||||
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
|
||||
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
|
||||
const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1);
|
||||
const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9));
|
||||
for (
|
||||
let value = Math.ceil((viewCenter - rawSpan / 2) / tickStep) * tickStep;
|
||||
value <= rawTop + 1e-9;
|
||||
value += tickStep
|
||||
) {
|
||||
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
|
||||
const label = `${value.toFixed(tickDecimals)}m`;
|
||||
yAxisTicks.push({ y: gridY, label });
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: gridY,
|
||||
x2: widthPx - LONG_PAD.right,
|
||||
y2: gridY,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(label, {
|
||||
x: LONG_PAD.left - 9,
|
||||
y: gridY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
|
||||
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
|
||||
|
||||
// 표시 표고창(세로 배율·중심 이동) 밖으로 나간 지반선·계획선은 그리지 않는다 —
|
||||
// 안 자르면 축 라벨·측점 라벨 띠 위로 선이 흘러나온다(2026-09-04).
|
||||
// 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다.
|
||||
// (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.)
|
||||
const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const clipPath = svgElement("clipPath", { id: clipId });
|
||||
clipPath.append(
|
||||
@@ -281,15 +315,60 @@ export function createLongitudinalProfile(
|
||||
);
|
||||
const defs = svgElement("defs", {});
|
||||
defs.append(clipPath);
|
||||
// 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04
|
||||
// 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라
|
||||
// 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다.
|
||||
const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` });
|
||||
gridLayer.append(gridWindow);
|
||||
const tickLayer = svgElement("g", { class: Y_TICK_CLASS });
|
||||
svg.append(defs, gridLayer, tickLayer);
|
||||
|
||||
const yAxisTicks: Array<{ y: number; label: string }> = [];
|
||||
const rawSpan = elevationSpan / exaggeration;
|
||||
const rawTop = viewCenter + rawSpan / 2;
|
||||
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
|
||||
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
|
||||
for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) {
|
||||
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
|
||||
yAxisTicks.push({ y: gridY, label });
|
||||
gridWindow.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: gridY,
|
||||
x2: widthPx - LONG_PAD.right,
|
||||
y2: gridY,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
);
|
||||
tickLayer.append(
|
||||
svgText(label, {
|
||||
x: LONG_PAD.left - 9,
|
||||
y: gridY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
// 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다.
|
||||
// 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이
|
||||
// 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남).
|
||||
[Y_WINDOW_BAKED_ATTR]: String(gridY),
|
||||
}),
|
||||
);
|
||||
}
|
||||
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
|
||||
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
|
||||
|
||||
const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
svg.append(defs, bandLayer);
|
||||
// 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다.
|
||||
const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS });
|
||||
bandLayer.append(bandWindow);
|
||||
svg.append(bandLayer);
|
||||
|
||||
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
|
||||
const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration);
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
appendCutFillBands(
|
||||
bandLayer,
|
||||
bandWindow,
|
||||
profile,
|
||||
x,
|
||||
(index) => toY(profile.samples[index].elevation_m),
|
||||
@@ -380,9 +459,11 @@ export function createLongitudinalProfile(
|
||||
})
|
||||
.join(" ");
|
||||
const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS });
|
||||
lineLayer.append(lineWindow);
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
lineLayer.append(
|
||||
lineWindow.append(
|
||||
svgElement("polyline", {
|
||||
points: profile.samples
|
||||
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
|
||||
@@ -391,7 +472,7 @@ export function createLongitudinalProfile(
|
||||
}),
|
||||
);
|
||||
}
|
||||
lineLayer.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
|
||||
lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
|
||||
svg.append(
|
||||
lineLayer,
|
||||
svgElement("line", {
|
||||
|
||||
@@ -40,11 +40,22 @@ import {
|
||||
} from "./B06_Section_UI_Cross_View";
|
||||
import { culvertLinkFor as culvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import type { CulvertLink } from "./B06_Section_UI_Cross_Culvert_Wire";
|
||||
import { createLongitudinalProfile, longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
|
||||
import { longitudinalMinimumWidth } from "./B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
buildLongitudinalChart,
|
||||
visibleChainageRange,
|
||||
visibleElevationRange,
|
||||
} from "./B06_Section_UI_Section_View_Chart";
|
||||
import { applyLegendToggle } from "@util/common_util_mass_haul";
|
||||
import { configureBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
|
||||
import { MASS_HAUL_MIN_HEIGHT } from "@util/common_util_mass_haul_view";
|
||||
import { buildMassHaulPanel } from "./B06_Section_UI_Section_View_MassHaul";
|
||||
import { createMassHaulRenderer } from "./B06_Section_UI_Section_View_MassHaul";
|
||||
import {
|
||||
applyElevationWindow,
|
||||
needsFullRedraw,
|
||||
Y_AXIS_WINDOW_CLASS,
|
||||
type ElevationWindowResult,
|
||||
} from "@util/common_util_chart_ywindow";
|
||||
import {
|
||||
BASE_PANEL_HEIGHT,
|
||||
chartHeights,
|
||||
@@ -56,7 +67,6 @@ import {
|
||||
PANEL_COLLAPSED_KEY,
|
||||
PANEL_HEIGHT_KEY,
|
||||
readVisibleSeries,
|
||||
unwrapChart,
|
||||
} from "./B06_Section_UI_Section_View_Panel";
|
||||
import {
|
||||
CROSS_GRID_GAP,
|
||||
@@ -68,9 +78,6 @@ import {
|
||||
emptyView,
|
||||
inferStationInterval,
|
||||
L,
|
||||
LONG_PAD,
|
||||
longitudinalMaxChainage,
|
||||
windowElevationRange,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
|
||||
export type { CrossDesignChange, DesignChangeHandler, RockBoundaryControl };
|
||||
@@ -455,6 +462,11 @@ export function createSectionView(
|
||||
drawPanel();
|
||||
};
|
||||
|
||||
/** 유토곡선만 새 창으로 다시 그리는 함수 — 그릴 때마다 새로 만든다(붙인 노드는 그 안). */
|
||||
let renderMassHaul: ((fromM: number, toM: number) => void) | null = null;
|
||||
/** 세로 창 갱신기 — 종단은 변환, 유토곡선은 곡선만. */
|
||||
let updateChartWindow: (() => ElevationWindowResult | null) | null = null;
|
||||
|
||||
/** 상단 패널 내용(종단면도 + 유토곡선 + 요약)만 다시 그린다. */
|
||||
function drawPanel(): void {
|
||||
if (!currentDetail || !Number.isFinite(renderWidth) || renderWidth <= 0) return;
|
||||
@@ -473,93 +485,69 @@ export function createSectionView(
|
||||
const heights = chartHeights(lastChartAvailable);
|
||||
const minWidth = longitudinalMinimumWidth(detail.longitudinal, cachedStationInterval);
|
||||
const chartWidth = Math.max(renderWidth, minWidth);
|
||||
// 종단 그래프의 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정,
|
||||
// B05 와 같은 규칙·같은 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다.
|
||||
const maxChainageM = longitudinalMaxChainage(detail.longitudinal);
|
||||
const plotWidth = Math.max(1, chartWidth - LONG_PAD.left - LONG_PAD.right);
|
||||
const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM;
|
||||
const viewFromM = Math.max(0, toChainage(keepScrollLeft));
|
||||
const viewToM = Math.min(
|
||||
maxChainageM,
|
||||
toChainage(keepScrollLeft + (chartWrap.clientWidth || chartWidth)),
|
||||
);
|
||||
const longElevationRange = windowElevationRange(
|
||||
[
|
||||
detail.longitudinal.samples,
|
||||
...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples),
|
||||
],
|
||||
viewFromM,
|
||||
viewToM,
|
||||
);
|
||||
|
||||
// Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자
|
||||
// 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식).
|
||||
let longAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
const nodes: Element[] = [
|
||||
unwrapChart(
|
||||
createLongitudinalProfile(
|
||||
detail.longitudinal,
|
||||
selectedStationId,
|
||||
currentExaggeration,
|
||||
// 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다.
|
||||
undefined,
|
||||
(stationId) => selectStation(stationId, true),
|
||||
cachedStationInterval,
|
||||
chartWidth,
|
||||
heights.long,
|
||||
minWidth,
|
||||
detail.longitudinal.design_profiles ?? [],
|
||||
0,
|
||||
(axis) => {
|
||||
longAxis = axis;
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
longElevationRange,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
// 유토곡선 몫은 전용 모듈이 만든다(2026-09-03 · 700줄 제한) — 곡선 계산부터
|
||||
// 범례·요약줄까지 한 덩어리라 붙이는 자리만 여기서 정한다.
|
||||
const massHaul = buildMassHaulPanel({
|
||||
const chart = buildLongitudinalChart({
|
||||
detail,
|
||||
conversion: currentConversion,
|
||||
haulLimits: currentHaulLimits,
|
||||
naturalSpoilSlope: currentNaturalSpoilSlope,
|
||||
visibleSeries,
|
||||
selectedStationId,
|
||||
exaggeration: currentExaggeration,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
stationInterval: cachedStationInterval,
|
||||
chartWidth,
|
||||
chartHeight: heights.long,
|
||||
minWidth,
|
||||
massHeight: heights.mass,
|
||||
longHeight: heights.long,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
toggleSeries,
|
||||
redraw: drawPanel,
|
||||
// 유토곡선도 종단과 같은 창을 본다(2026-09-04).
|
||||
viewRange: { fromM: viewFromM, toM: viewToM },
|
||||
scrollLeft: keepScrollLeft,
|
||||
viewportWidth: chartWrap.clientWidth || chartWidth,
|
||||
});
|
||||
if (massHaul.chart) nodes.push(massHaul.chart);
|
||||
const longAxis = chart.axis;
|
||||
const nodes: Element[] = [chart.node];
|
||||
const { viewFromM, viewToM } = chart;
|
||||
|
||||
chartWrap.replaceChildren(...nodes);
|
||||
// 고정 Y축 오버레이 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
|
||||
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다). 유토곡선 축은 종단 높이만큼
|
||||
// 안쪽(inner)을 내려 자기 그래프 구간만 덮는다 — 불투명 배경이 지나간 눈금 노출을 막는다.
|
||||
if (massHaul.axis) {
|
||||
const overlay = buildStickyYAxis(massHaul.axis, heights.mass);
|
||||
(overlay.firstElementChild as HTMLElement).style.top = `${heights.long}px`;
|
||||
// 유토곡선 몫은 전용 모듈이 만들어 붙인다(2026-09-03 · 700줄 제한). 가로로 스크롤할
|
||||
// 때는 **이 함수만** 다시 불러 곡선의 세로 창을 따라오게 한다 — 종단면도는 건드리지
|
||||
// 않는다(2026-09-04: 상단 패널을 통째로 다시 그리면 화면이 한 번 끊긴다).
|
||||
renderMassHaul = createMassHaulRenderer(
|
||||
{
|
||||
detail,
|
||||
conversion: currentConversion,
|
||||
haulLimits: currentHaulLimits,
|
||||
naturalSpoilSlope: currentNaturalSpoilSlope,
|
||||
visibleSeries,
|
||||
selectedStationId,
|
||||
stationInterval: cachedStationInterval,
|
||||
chartWidth,
|
||||
minWidth,
|
||||
massHeight: heights.mass,
|
||||
longHeight: heights.long,
|
||||
selectStation: (stationId) => selectStation(stationId, true),
|
||||
toggleSeries,
|
||||
redraw: drawPanel,
|
||||
},
|
||||
{ chartWrap, panel, panelBody, statusNode: panelCount },
|
||||
);
|
||||
renderMassHaul(viewFromM, viewToM);
|
||||
// 종단 고정 Y축 — 0크기 sticky 앵커라 **첫 자식**으로 넣어야 세로 기준이 컨테이너
|
||||
// 상단이 된다(SVG 뒤에 넣으면 앵커가 차트 아래로 밀린다).
|
||||
if (longAxis) {
|
||||
const overlay = buildStickyYAxis(longAxis, heights.long);
|
||||
// 세로 창을 따라 움직이는 축임을 표시한다(유토곡선 축과 구분).
|
||||
overlay.classList.add(Y_AXIS_WINDOW_CLASS);
|
||||
chartWrap.prepend(overlay);
|
||||
}
|
||||
if (longAxis) chartWrap.prepend(buildStickyYAxis(longAxis, heights.long));
|
||||
|
||||
panel.querySelector(".b06-masshaul__legend")?.remove();
|
||||
panel.querySelector(".b06-masshaul__summary")?.remove();
|
||||
// 범례는 스크롤 컨테이너 **밖**에 절대 위치로 띄운다 — 안에 넣으면 컨테이너 폭 계산에
|
||||
// 끼어들어 두 그래프의 측점 세로선이 어긋난다(1차 수정에서 겪은 문제).
|
||||
panelBody.append(...massHaul.overlays);
|
||||
panelCount.textContent = massHaul.statusText;
|
||||
// 세로 창 갱신기 — 스크롤마다 종단은 변환으로, 유토곡선은 곡선만 다시 그려 따라온다.
|
||||
updateChartWindow = () => {
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
chart.toChainage,
|
||||
chart.maxChainageM,
|
||||
chartWrap.scrollLeft,
|
||||
chartWrap.clientWidth || chartWidth,
|
||||
);
|
||||
renderMassHaul?.(fromM, toM);
|
||||
return applyElevationWindow(
|
||||
chartWrap,
|
||||
visibleElevationRange(detail, fromM, toM) ?? undefined,
|
||||
);
|
||||
};
|
||||
chartWrap.scrollLeft = keepScrollLeft;
|
||||
// 상단 패널이 sticky라 선택 카드가 그 아래로 숨는다 — 패널 높이만큼 스크롤 여백을 잡아 준다.
|
||||
syncScrollMargin();
|
||||
@@ -577,13 +565,16 @@ export function createSectionView(
|
||||
}
|
||||
}
|
||||
|
||||
/** 가로 스크롤이 이만큼 멈춰 있으면 보이는 구간이 정해진 것으로 보고 세로를 다시 맞춘다. */
|
||||
const SCROLL_SETTLE_MS = 160;
|
||||
/** 상단 패널을 통째로 다시 그리기까지 기다리는 시간(ms) — 눈금 갱신으로도 못 살릴 때만. */
|
||||
const SCROLL_SETTLE_MS = 80;
|
||||
let settledScrollLeft = 0;
|
||||
let scrollSettleTimer = 0;
|
||||
// 스크롤·팬 하는 내내 축이 출렁이면 어지럽다 — 멈춘 뒤 한 번만 다시 그린다(2026-09-04).
|
||||
// 세로 맞춤은 **그리기가 아니라 변환**이다(2026-09-04 사용자 확정, B05와 같은 규칙) —
|
||||
// 스크롤마다 겹 하나의 변환만 갈아 끼우므로 실시간으로 따라온다.
|
||||
chartWrap.addEventListener("scroll", () => {
|
||||
const fitted = updateChartWindow?.() ?? null;
|
||||
window.clearTimeout(scrollSettleTimer);
|
||||
if (!needsFullRedraw(fitted)) return;
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
if (Math.abs(chartWrap.scrollLeft - settledScrollLeft) < 1) return;
|
||||
settledScrollLeft = chartWrap.scrollLeft;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
/* =============================================================================
|
||||
* B06_Section_UI_Section_View_Chart.ts
|
||||
* B06 상단 패널의 **종단면도 몫**만 떼어 낸 조립기 (2026-09-04 · 700줄 제한).
|
||||
*
|
||||
* 세로는 **보이는 구간에 자동으로 맞춘다**(2026-09-04 사용자 확정, B05 와 같은 규칙·같은
|
||||
* 함수). 가로 스크롤 위치와 컨테이너 폭이 곧 보이는 구간이다. 스크롤할 때 그래프를 다시
|
||||
* 만들지 않고 세로 창을 **변환으로** 옮기는 것도 B05 와 같다
|
||||
* (`common_util_chart_ywindow`).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import { createLongitudinalProfile } from "./B06_Section_UI_Longitudinal";
|
||||
import {
|
||||
LONG_PAD,
|
||||
longitudinalMaxChainage,
|
||||
windowElevationRange,
|
||||
} from "./B06_Section_UI_Section_Common";
|
||||
import { unwrapChart } from "./B06_Section_UI_Section_View_Panel";
|
||||
|
||||
export interface LongitudinalChartInput {
|
||||
detail: SectionDetailResponse;
|
||||
selectedStationId: string | null;
|
||||
/** 세로 과장(사용자 조절값). */
|
||||
exaggeration: number;
|
||||
selectStation: (stationId: string) => void;
|
||||
stationInterval: number;
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
minWidth: number;
|
||||
/** 지금 보이는 구간을 정하는 값 — 가로 스크롤 위치와 컨테이너 안쪽 폭. */
|
||||
scrollLeft: number;
|
||||
viewportWidth: number;
|
||||
}
|
||||
|
||||
export interface LongitudinalChartResult {
|
||||
node: Element;
|
||||
axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null;
|
||||
/** 화면 x(px) → 누가거리(m). 스크롤 갱신이 보이는 구간을 다시 잴 때 쓴다. */
|
||||
toChainage: (px: number) => number;
|
||||
maxChainageM: number;
|
||||
viewFromM: number;
|
||||
viewToM: number;
|
||||
}
|
||||
|
||||
/** 지금 보이는 구간(누가거리 m)을 잰다 — 그릴 때와 스크롤 갱신 때가 **같은 식**을 쓴다. */
|
||||
export function visibleChainageRange(
|
||||
toChainage: (px: number) => number,
|
||||
maxChainageM: number,
|
||||
scrollLeft: number,
|
||||
viewportWidth: number,
|
||||
): { fromM: number; toM: number } {
|
||||
return {
|
||||
fromM: Math.max(0, toChainage(scrollLeft)),
|
||||
toM: Math.min(maxChainageM, toChainage(scrollLeft + viewportWidth)),
|
||||
};
|
||||
}
|
||||
|
||||
/** 보이는 구간의 표고 범위 — 종단 세로 창의 기준. */
|
||||
export function visibleElevationRange(
|
||||
detail: SectionDetailResponse,
|
||||
fromM: number,
|
||||
toM: number,
|
||||
): { min: number; max: number } | null {
|
||||
return (
|
||||
windowElevationRange(
|
||||
[
|
||||
detail.longitudinal.samples,
|
||||
...(detail.longitudinal.design_profiles ?? []).map((profile) => profile.samples),
|
||||
],
|
||||
fromM,
|
||||
toM,
|
||||
) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
export function buildLongitudinalChart(input: LongitudinalChartInput): LongitudinalChartResult {
|
||||
const { detail } = input;
|
||||
const maxChainageM = longitudinalMaxChainage(detail.longitudinal);
|
||||
const plotWidth = Math.max(1, input.chartWidth - LONG_PAD.left - LONG_PAD.right);
|
||||
const toChainage = (px: number): number => ((px - LONG_PAD.left) / plotWidth) * maxChainageM;
|
||||
const { fromM, toM } = visibleChainageRange(
|
||||
toChainage,
|
||||
maxChainageM,
|
||||
input.scrollLeft,
|
||||
input.viewportWidth,
|
||||
);
|
||||
|
||||
// Y축 눈금을 렌더러에서 받아 가로 스크롤 고정 오버레이로 얹는다(2026-08-04 사용자
|
||||
// 지시 — 테이블 행 이름표처럼 스크롤해도 계속 보이게, B05와 같은 방식).
|
||||
let axis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
|
||||
const node = unwrapChart(
|
||||
createLongitudinalProfile(
|
||||
detail.longitudinal,
|
||||
input.selectedStationId,
|
||||
input.exaggeration,
|
||||
// 공통 Y 스케일(횡단 카드 몫)을 여기 넘기면 종단이 전 구간 축에 묶여 눌린다.
|
||||
undefined,
|
||||
input.selectStation,
|
||||
input.stationInterval,
|
||||
input.chartWidth,
|
||||
input.chartHeight,
|
||||
input.minWidth,
|
||||
detail.longitudinal.design_profiles ?? [],
|
||||
0,
|
||||
(next) => {
|
||||
axis = next;
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
0,
|
||||
0,
|
||||
visibleElevationRange(detail, fromM, toM) ?? undefined,
|
||||
),
|
||||
);
|
||||
return { node, axis, toChainage, maxChainageM, viewFromM: fromM, viewToM: toM };
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
* 사용자가 옛 그림을 본다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { buildStickyYAxis } from "../B05_Profile/B05_Profile_UI_Profile_MassHaul";
|
||||
import type { EarthworkConversion, HaulEquipmentLimit } from "./B06_Section_Api_Fetch";
|
||||
import type { SectionDetailResponse } from "./B06_Section_Api_Fetch";
|
||||
import {
|
||||
@@ -25,6 +26,9 @@ import {
|
||||
createMassHaulChart,
|
||||
createMassHaulLegend,
|
||||
createMassHaulSummary,
|
||||
createMassHaulWindowState,
|
||||
scheduleMassHaulSettle,
|
||||
type MassHaulWindowState,
|
||||
} from "@util/common_util_mass_haul_view";
|
||||
import {
|
||||
L,
|
||||
@@ -58,6 +62,8 @@ export interface MassHaulPanelInput {
|
||||
redraw: () => void;
|
||||
/** 화면에 보이는 누가거리 구간(m) — Y 를 이 구간의 누계 토량으로 잡는다(2026-09-04). */
|
||||
viewRange?: { fromM: number; toM: number };
|
||||
/** 세로창 버티기·부드러운 이동 상태(2026-09-04). */
|
||||
windowState?: MassHaulWindowState;
|
||||
}
|
||||
|
||||
export interface MassHaulPanelResult {
|
||||
@@ -79,6 +85,9 @@ export interface MassHaulPanelResult {
|
||||
export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResult {
|
||||
const { detail, visibleSeries } = input;
|
||||
const pendingRecalc = hasStaleDesigns(detail);
|
||||
// 계산 결과를 아껴 두지 **않는다**. 횡단 설계는 같은 객체를 제자리에서 고치므로
|
||||
// (`refreshCrossDesigns`) 객체가 같은지로는 바뀐 것을 못 잰다 — 2026-09-04 에 캐시를
|
||||
// 넣었다가 계획고를 조절해도 횡단 기준 곡선이 그대로였다(사용자 보고).
|
||||
const series: MassHaulSeries[] =
|
||||
input.conversion && !pendingRecalc
|
||||
? computeMassHaulSeries(
|
||||
@@ -107,6 +116,7 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu
|
||||
padLeft: LONG_PAD.left,
|
||||
padRight: LONG_PAD.right,
|
||||
viewRange: input.viewRange,
|
||||
window: input.windowState,
|
||||
},
|
||||
input.selectedStationId,
|
||||
input.stationInterval,
|
||||
@@ -142,3 +152,85 @@ export function buildMassHaulPanel(input: MassHaulPanelInput): MassHaulPanelResu
|
||||
: L(series.length ? "B06_MassHaul_AllHidden" : "B06_MassHaul_Empty");
|
||||
return { chart, axis, overlays, statusText };
|
||||
}
|
||||
|
||||
/** 유토곡선을 붙일 자리 — 뷰 컨트롤러가 들고 있는 DOM 이다. */
|
||||
export interface MassHaulMountTargets {
|
||||
/** 차트가 들어가는 가로 스크롤 컨테이너. 종단면도와 **같은 부모의 형제**여야 한다. */
|
||||
chartWrap: HTMLElement;
|
||||
/** 옛 범례·요약줄을 찾아 지울 뿌리. */
|
||||
panel: HTMLElement;
|
||||
/** 새 범례·요약줄을 붙일 자리(스크롤 컨테이너 밖). */
|
||||
panelBody: HTMLElement;
|
||||
/** 곡선을 못 그린 이유를 적는 상태줄. */
|
||||
statusNode: HTMLElement;
|
||||
}
|
||||
|
||||
/** 붙여 둔 유토곡선 노드 — 다음 갱신에서 **제자리 교체**하는 데 쓴다. */
|
||||
export interface MountedMassHaul {
|
||||
chart: Element | null;
|
||||
axis: HTMLElement | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 유토곡선을 만들어 붙인다. 이미 붙어 있으면 **그 자리에서 갈아 끼운다** — 종단면도는
|
||||
* 건드리지 않는다(2026-09-04). 가로로 스크롤할 때마다 곡선의 세로 창이 따라와야 하는데,
|
||||
* 상단 패널을 통째로 다시 그리면 종단 그래프까지 새로 만들어져 화면이 한 번 끊긴다.
|
||||
*/
|
||||
export function mountMassHaulPanel(
|
||||
input: MassHaulPanelInput,
|
||||
targets: MassHaulMountTargets,
|
||||
previous: MountedMassHaul,
|
||||
): MountedMassHaul {
|
||||
const built = buildMassHaulPanel(input);
|
||||
let chart = previous.chart;
|
||||
if (built.chart) {
|
||||
if (chart?.isConnected) chart.replaceWith(built.chart);
|
||||
else targets.chartWrap.append(built.chart);
|
||||
chart = built.chart;
|
||||
} else if (chart?.isConnected) {
|
||||
chart.remove();
|
||||
chart = null;
|
||||
}
|
||||
|
||||
let axis = previous.axis;
|
||||
if (built.axis) {
|
||||
// 고정 Y축은 0크기 sticky 앵커라 **첫 자식**이어야 세로 기준이 컨테이너 상단이 된다.
|
||||
// 안쪽(inner)은 종단 높이만큼 내려 자기 그래프 구간만 덮는다.
|
||||
const overlay = buildStickyYAxis(built.axis, input.massHeight);
|
||||
(overlay.firstElementChild as HTMLElement).style.top = `${input.longHeight}px`;
|
||||
if (axis?.isConnected) axis.replaceWith(overlay);
|
||||
else targets.chartWrap.prepend(overlay);
|
||||
axis = overlay;
|
||||
} else if (axis?.isConnected) {
|
||||
axis.remove();
|
||||
axis = null;
|
||||
}
|
||||
|
||||
targets.panel.querySelector(".b06-masshaul__legend")?.remove();
|
||||
targets.panel.querySelector(".b06-masshaul__summary")?.remove();
|
||||
targets.panelBody.append(...built.overlays);
|
||||
targets.statusNode.textContent = built.statusText;
|
||||
return { chart, axis };
|
||||
}
|
||||
|
||||
/**
|
||||
* 「보이는 구간만 바꿔 다시 그리는」 함수를 만든다 — 스크롤 갱신이 부를 것이다.
|
||||
* 붙여 둔 노드는 이 함수가 스스로 들고 있으므로 부르는 쪽은 구간만 넘기면 된다.
|
||||
*/
|
||||
export function createMassHaulRenderer(
|
||||
base: Omit<MassHaulPanelInput, "viewRange">,
|
||||
targets: MassHaulMountTargets,
|
||||
): (fromM: number, toM: number) => void {
|
||||
let mounted: MountedMassHaul = { chart: null, axis: null };
|
||||
const windowState = createMassHaulWindowState();
|
||||
const render = (fromM: number, toM: number): void => {
|
||||
mounted = mountMassHaulPanel(
|
||||
{ ...base, viewRange: { fromM, toM }, windowState },
|
||||
targets,
|
||||
mounted,
|
||||
);
|
||||
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더(2026-09-04 「부드럽게」).
|
||||
scheduleMassHaulSettle(windowState, () => render(fromM, toM));
|
||||
};
|
||||
return render;
|
||||
}
|
||||
|
||||
@@ -306,6 +306,12 @@
|
||||
fill: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
/* 세로 창을 변환으로 옮기는 겹(2026-09-04) — 세로로 눌리거나 늘어나도 선 굵기·점선 간격은
|
||||
그대로여야 한다. 변환이 걸려 있을 때만 적용해 B06 종단·횡단은 예전 그대로 그려진다. */
|
||||
.b06-chart__ywindow[transform] * {
|
||||
vector-effect: non-scaling-stroke;
|
||||
}
|
||||
|
||||
.b06-chart__grid {
|
||||
stroke: var(--color-border);
|
||||
stroke-width: 1;
|
||||
|
||||
@@ -41,7 +41,8 @@ from config.config_system import DRAWING_SCALE_CROSS
|
||||
CROSS_SHEET_ID = re.compile(r"cross_s(\d{2,})m?")
|
||||
|
||||
# 블록 사이 여백(mm)과 테두리 여유 — build_cross_drawing의 테두리 값과 맞춘다.
|
||||
_BLOCK_GAP_MM = 8.0
|
||||
# 여백 0 = 이웃 칸의 테두리가 맞닿는다(2026-09-04 사용자 지시 — 박스 사이 간격 제거).
|
||||
_BLOCK_GAP_MM = 0.0
|
||||
_BORDER_PAD_MM = 4.0
|
||||
|
||||
|
||||
|
||||
@@ -136,17 +136,18 @@ def _drawing_list(
|
||||
except (FileNotFoundError, ValueError, OSError) as exc:
|
||||
logger.warning("B07 횡단 장 계획 실패 — 나머지 도면만 싣는다: %s", exc)
|
||||
sheets = []
|
||||
# 장 라벨은 누가거리 범위가 아니라 **그 장의 시작 측점**으로 짓는다(2026-09-04 사용자
|
||||
# 지시) — 「1060~1070m」식 범위는 좌측 패널 2열 단추를 넘칠 만큼 길었다.
|
||||
interval = infer_station_interval(longitudinal.get("stations") or [])
|
||||
for sheet in sheets:
|
||||
chainages = sheet["chainages"]
|
||||
first = station_by_chainage.get(chainages[0], {})
|
||||
span = f"{chainages[0]}m"
|
||||
if len(chainages) > 1:
|
||||
span = f"{chainages[0]}~{chainages[-1]}m"
|
||||
station = station_no_label(float(first.get("chainage_m", chainages[0])), interval)
|
||||
drawings.append(
|
||||
DesignDrawingItem(
|
||||
id=sheet["id"],
|
||||
kind="cross",
|
||||
label=f"{sheet['number']}장 ({span})",
|
||||
label=f"{sheet['number']}장 ({station})",
|
||||
chainage_m=float(first.get("chainage_m", chainages[0])),
|
||||
confirmed=bool(manifest_drawings.get(sheet["id"], {}).get("confirmed")),
|
||||
)
|
||||
|
||||
@@ -84,6 +84,9 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
/* 격자 칸의 기본 최소폭은 글자 길이라, 이름이 길면 칸이 패널을 밀어내 단추가
|
||||
오른쪽으로 튀어나갔다(2026-09-04 실측 14px). 0으로 풀면 이름이 `…`로 잘린다. */
|
||||
min-width: 0;
|
||||
min-height: 34px;
|
||||
padding: var(--spacing-4) var(--spacing-8);
|
||||
/* 경계가 뚜렷하도록 배경색과 유사하지만 조금 짙은 톤 + 은은한 테두리 */
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
|
||||
:root {
|
||||
--cad-title-height: 42px;
|
||||
--cad-ribbon-height: 116px;
|
||||
/* 116px 에서는 리본 줄의 내용(85px)이 보이는 높이(81px)를 넘어 세로 스크롤바가
|
||||
생겼다(2026-09-04 실측) — 모자란 4px + 여유 2px 를 더한다. */
|
||||
--cad-ribbon-height: 122px;
|
||||
/* 명령행 높이 — 상태막대의 [명령행] 토글이 0px로 바꾼다 */
|
||||
--cad-command-height: 86px;
|
||||
--cad-status-height: 28px;
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
/* =============================================================================
|
||||
* common_util_chart_ywindow.ts
|
||||
* 종단 그래프의 **세로 창을 변환으로 갈아 끼운다** — 다시 그리지 않는다(2026-09-04 확정).
|
||||
*
|
||||
* 예전에는 스크롤을 멈출 때마다 그래프를 통째로 새로 만들었다(SVG 요소 457개, 실측
|
||||
* 34ms = 60Hz 두 프레임 누락). 사용자에게는 「화면을 리프레시하는 느낌」으로 왔다.
|
||||
*
|
||||
* 세로 매핑은 1차식이라(`y = zero − (표고 − center) × pxPerM`) 옛 창과 새 창의 관계도
|
||||
* 1차식이다. 그래서 도형을 건드리지 않고 겹 하나에 `translate`+`scale` 만 걸면 정확히
|
||||
* 겹친다. 글자는 늘어나므로 변환에 넣지 않고 자리만 옮긴다(눈금 10개 안팎).
|
||||
*
|
||||
* 창이 많이 벌어져 눈금 간격이 원래 의도(1·2·5 계열)와 어긋나면 **눈금만** 다시 만든다.
|
||||
* 그때도 눈금은 **그릴 때의 좌표계**에 놓아 같은 변환이 그대로 먹게 한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
Y_GRID_CLASS,
|
||||
Y_TICK_CLASS,
|
||||
Y_WINDOW_ATTRS,
|
||||
Y_WINDOW_BAKED_ATTR,
|
||||
Y_WINDOW_CLASS,
|
||||
Y_WINDOW_SPAN_PAD,
|
||||
yWindowTickValues,
|
||||
} from "../B06_Section/B06_Section_UI_Longitudinal";
|
||||
|
||||
/** 눈금을 다시 만드는 문턱 — 그릴 때 대비 세로 배율이 이 밖으로 나가면 간격이 어긋난다. */
|
||||
const TICK_KEEP_MIN = 0.8;
|
||||
const TICK_KEEP_MAX = 1.25;
|
||||
/** 플롯 안에 남아야 하는 최소 눈금 수. */
|
||||
const TICK_KEEP_MIN_COUNT = 4;
|
||||
/** 세로 창을 따라 움직이는 좌측 고정 축에 붙이는 표식 — 한 컨테이너에 축이 둘 이상일 때
|
||||
* (B06: 종단 + 유토곡선) 남의 축까지 옮기지 않으려는 것이다. */
|
||||
export const Y_AXIS_WINDOW_CLASS = "b05-profile__yaxis--ywindow";
|
||||
/** 눈금 글자는 선보다 이만큼 아래에 앉는다(그리는 쪽과 같은 값). */
|
||||
const TICK_TEXT_DROP_PX = 4;
|
||||
|
||||
interface Baked {
|
||||
svg: SVGSVGElement;
|
||||
center: number;
|
||||
pxPerM: number;
|
||||
zero: number;
|
||||
top: number;
|
||||
height: number;
|
||||
/** 세로 과장 — 새 창의 px 환산에 그대로 곱한다(B06 조절값, B05 는 1). */
|
||||
exaggeration: number;
|
||||
}
|
||||
|
||||
export interface ElevationWindowResult {
|
||||
/** 그릴 때 대비 세로 배율(1이면 그릴 때와 같은 창). */
|
||||
scale: number;
|
||||
/** 플롯 안에 남아 있는 눈금 수. */
|
||||
visibleTicks: number;
|
||||
/** 이번 호출에서 눈금을 다시 만들었는가. */
|
||||
rebuiltTicks: boolean;
|
||||
}
|
||||
|
||||
function readBaked(root: ParentNode): Baked | null {
|
||||
const svg = root.querySelector<SVGSVGElement>(`svg[${Y_WINDOW_ATTRS.auto}="1"]`);
|
||||
if (!svg) return null;
|
||||
const read = (name: string): number => Number(svg.getAttribute(name));
|
||||
const baked: Baked = {
|
||||
svg,
|
||||
center: read(Y_WINDOW_ATTRS.center),
|
||||
pxPerM: read(Y_WINDOW_ATTRS.pxPerM),
|
||||
zero: read(Y_WINDOW_ATTRS.zero),
|
||||
top: read(Y_WINDOW_ATTRS.top),
|
||||
height: read(Y_WINDOW_ATTRS.height),
|
||||
exaggeration: read(Y_WINDOW_ATTRS.exaggeration) || 1,
|
||||
};
|
||||
const numbers = [baked.center, baked.pxPerM, baked.zero, baked.top, baked.height];
|
||||
if (!numbers.every(Number.isFinite) || baked.pxPerM <= 0 || baked.height <= 0) return null;
|
||||
return baked;
|
||||
}
|
||||
|
||||
/** 눈금선·눈금 글자·좌측 고정 축을 새 창의 눈금값으로 다시 만든다(그래프는 건드리지 않는다).
|
||||
*
|
||||
* 자리는 **그릴 때의 좌표계**로 잡는다 — 그래야 아래 변환이 눈금에도 그대로 먹는다. */
|
||||
function rebuildTicks(baked: Baked, root: ParentNode, center: number, span: number): boolean {
|
||||
const grid = baked.svg.querySelector<SVGGElement>(`.${Y_GRID_CLASS}`);
|
||||
const labels = baked.svg.querySelector<SVGGElement>(`.${Y_TICK_CLASS}`);
|
||||
if (!grid || !labels) return false;
|
||||
const sampleLine = grid.querySelector("line");
|
||||
const sampleText = labels.querySelector("text");
|
||||
if (!sampleLine || !sampleText) return false;
|
||||
const x1 = sampleLine.getAttribute("x1") ?? "0";
|
||||
const x2 = sampleLine.getAttribute("x2") ?? "0";
|
||||
const lineClass = sampleLine.getAttribute("class") ?? "";
|
||||
const textX = sampleText.getAttribute("x") ?? "0";
|
||||
const textClass = sampleText.getAttribute("class") ?? "";
|
||||
const axis = root.querySelector<HTMLElement>(`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-inner`);
|
||||
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const lines: SVGLineElement[] = [];
|
||||
const texts: SVGTextElement[] = [];
|
||||
const spans: HTMLElement[] = [];
|
||||
// 눈금값은 **과장을 뺀** 표고 폭 기준이다(그리는 쪽 `rawSpan` 과 같은 값).
|
||||
for (const { value, label } of yWindowTickValues(
|
||||
center,
|
||||
span / baked.exaggeration,
|
||||
baked.height,
|
||||
)) {
|
||||
const y = baked.zero - (value - baked.center) * baked.pxPerM;
|
||||
const line = document.createElementNS(ns, "line");
|
||||
line.setAttribute("x1", x1);
|
||||
line.setAttribute("x2", x2);
|
||||
line.setAttribute("y1", String(y));
|
||||
line.setAttribute("y2", String(y));
|
||||
line.setAttribute("class", lineClass);
|
||||
lines.push(line);
|
||||
const text = document.createElementNS(ns, "text");
|
||||
text.setAttribute("x", textX);
|
||||
text.setAttribute("y", String(y + TICK_TEXT_DROP_PX));
|
||||
text.setAttribute("text-anchor", "end");
|
||||
text.setAttribute("class", textClass);
|
||||
text.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
|
||||
text.textContent = label;
|
||||
texts.push(text);
|
||||
if (axis) {
|
||||
const span = document.createElement("span");
|
||||
span.className = "b05-profile__yaxis-tick";
|
||||
span.style.top = `${y}px`;
|
||||
span.setAttribute(Y_WINDOW_BAKED_ATTR, String(y));
|
||||
span.textContent = label;
|
||||
spans.push(span);
|
||||
}
|
||||
}
|
||||
grid.replaceChildren(...lines);
|
||||
labels.replaceChildren(...texts);
|
||||
if (axis) axis.replaceChildren(...spans);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 지금 그려져 있는 눈금 글자 중 새 창 안에 남는 개수. */
|
||||
function countTicksInside(baked: Baked, scale: number, offset: number): number {
|
||||
let count = 0;
|
||||
baked.svg.querySelectorAll<SVGTextElement>(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => {
|
||||
const moved = scale * Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)) + offset;
|
||||
if (moved >= baked.top && moved <= baked.top + baked.height) count += 1;
|
||||
});
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* 종단 그래프의 세로 창을 옮긴다. 대상 그래프가 없거나(고정 배율 B06) 창이 없으면 null.
|
||||
* 좌측 고정 축(`b05-profile__yaxis-tick`)도 같은 식으로 따라 옮긴다.
|
||||
*/
|
||||
export function applyElevationWindow(
|
||||
root: ParentNode,
|
||||
range: { min: number; max: number } | undefined,
|
||||
): ElevationWindowResult | null {
|
||||
const baked = readBaked(root);
|
||||
if (!baked || !range) return null;
|
||||
|
||||
// 새 창 — 그리는 쪽과 **같은 규칙**으로 만든다(같은 상수를 같이 쓴다).
|
||||
const span = Math.max(range.max - range.min, 1) * Y_WINDOW_SPAN_PAD;
|
||||
const center = (range.min + range.max) / 2;
|
||||
const pxPerM = (baked.exaggeration * baked.height) / span;
|
||||
const scale = pxPerM / baked.pxPerM;
|
||||
|
||||
const offset = baked.zero * (1 - scale) + (center - baked.center) * pxPerM;
|
||||
|
||||
// 눈금을 새로 만들 때 — 둘 중 하나만 걸려도 만든다(요소 20개 안팎이라 싸다).
|
||||
// ① 배율이 크게 벌어짐 — 눈금 간격이 원래 의도(1·2·5 계열)와 어긋난다.
|
||||
// ② **새 창에 있어야 할 눈금 수와 지금 화면에 남은 수가 다름** — 창이 위아래로 이동만
|
||||
// 하면 배율은 그대로인데 눈금이 한쪽으로 쓸려 나가 그 자리가 빈다(2026-09-04 사용자
|
||||
// 보고: 「종단 그래프 왔다갔다하면 Y축 값이 비는 경우」).
|
||||
const wanted = yWindowTickValues(center, span / baked.exaggeration, baked.height).length;
|
||||
const staying = countTicksInside(baked, scale, offset);
|
||||
let rebuiltTicks = false;
|
||||
if (scale < TICK_KEEP_MIN || scale > TICK_KEEP_MAX || staying !== wanted) {
|
||||
rebuiltTicks = rebuildTicks(baked, root, center, span);
|
||||
}
|
||||
|
||||
const transform = `translate(0 ${offset}) scale(1 ${scale})`;
|
||||
baked.svg.querySelectorAll<SVGGElement>(`.${Y_WINDOW_CLASS}`).forEach((layer) => {
|
||||
layer.setAttribute("transform", transform);
|
||||
});
|
||||
|
||||
const place = (bakedY: number): number => scale * bakedY + offset;
|
||||
const inside = (y: number): boolean => y >= baked.top && y <= baked.top + baked.height;
|
||||
let visibleTicks = 0;
|
||||
baked.svg.querySelectorAll<SVGTextElement>(`text[${Y_WINDOW_BAKED_ATTR}]`).forEach((label) => {
|
||||
const moved = place(Number(label.getAttribute(Y_WINDOW_BAKED_ATTR)));
|
||||
// 글자를 내리는 몫은 **변환 뒤에** 더한다 — 배율과 함께 커지면 선에서 떨어져 보인다.
|
||||
label.setAttribute("y", String(moved + TICK_TEXT_DROP_PX));
|
||||
// 플롯 밖으로 나간 눈금 글자는 감춘다 — 축 라벨·측점 띠 위로 흘러나오면 안 된다.
|
||||
label.style.display = inside(moved) ? "" : "none";
|
||||
if (inside(moved)) visibleTicks += 1;
|
||||
});
|
||||
root
|
||||
.querySelectorAll<HTMLElement>(
|
||||
`.${Y_AXIS_WINDOW_CLASS} .b05-profile__yaxis-tick[${Y_WINDOW_BAKED_ATTR}]`,
|
||||
)
|
||||
.forEach((tick) => {
|
||||
const moved = place(Number(tick.getAttribute(Y_WINDOW_BAKED_ATTR)));
|
||||
tick.style.top = `${moved}px`;
|
||||
tick.style.display = inside(moved) ? "" : "none";
|
||||
});
|
||||
return { scale, visibleTicks, rebuiltTicks };
|
||||
}
|
||||
|
||||
/** 눈금이 더는 쓸 만하지 않은가 — 패널이 그래프 전체를 다시 만들지 판단하는 기준. */
|
||||
export function needsFullRedraw(result: ElevationWindowResult | null): boolean {
|
||||
return !result || (result.visibleTicks < TICK_KEEP_MIN_COUNT && !result.rebuiltTicks);
|
||||
}
|
||||
@@ -90,7 +90,11 @@ class PipePoint:
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
# 구 형식 저장분이 확장 필드 없이 그대로 다시 저장되도록 기본값은 생략한다.
|
||||
payload: dict[str, Any] = {
|
||||
"chainage_m": round(float(self.chainage_m), 2),
|
||||
# 누가거리는 좌표(x·y)와 **같은 밀리미터 기준**으로 남긴다. cm 로 반올림하던
|
||||
# 옛 규칙은 종단 정본 변화점(3자리)과 최대 5mm 어긋났고, 그 값으로 계획고를
|
||||
# 편집하면 정본 옆 mm 자리에 가짜 변화점이 하나 더 서서 종단곡선이 mm 로
|
||||
# 쭈그러들었다 — 그 구간 기울기가 155,791% 로 튀었다(2026-09-04 사용자 보고).
|
||||
"chainage_m": round(float(self.chainage_m), 3),
|
||||
"source": self.source,
|
||||
}
|
||||
if self.facility != PIPE_FACILITY_PIPE:
|
||||
|
||||
@@ -45,6 +45,11 @@ _FILE_TREES = (
|
||||
# 배수유역을 폴더 통째로 넓히기 전(2026-09-04)에 찍힌 스냅샷이 갖고 있는 자리.
|
||||
_LEGACY_DRAINAGE_TREE = "B04_PreProcess__drainage__edits"
|
||||
|
||||
# 코리도(3D 예상형상) 초기값 — 체인이 만든 저장본 한 벌(2026-09-04 사용자 확정).
|
||||
# 작업본 파일명에는 route id가 박히는데 [초기화]는 **새 route id**를 만드므로, 스냅샷에는
|
||||
# 번호 없는 이름으로 두었다가 복원 때 새 번호로 되돌린다.
|
||||
_CORRIDOR_NAME = "initial_corridor.json"
|
||||
|
||||
# `routes.id`를 참조하는 표는 스키마상 이 넷이 전부다(001_create_schema.sql:539~556).
|
||||
_CHILD_TABLES = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
|
||||
|
||||
@@ -198,6 +203,13 @@ async def save_initial_snapshot(
|
||||
for tree in _FILE_TREES:
|
||||
_copy_tree(root / tree, target / tree.replace("/", "__"))
|
||||
|
||||
# 코리도는 체인이 마지막에 만든다 — 있으면 함께 뜬다(없으면 브라우저 폴백 그대로).
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
|
||||
corridor = corridor_path(root, route_id)
|
||||
if corridor.is_file():
|
||||
shutil.copy2(corridor, target / _CORRIDOR_NAME)
|
||||
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute("SELECT * FROM routes WHERE id = %s", (route_id,))
|
||||
route = await cursor.fetchone()
|
||||
@@ -245,7 +257,7 @@ def wipe_edited_masters(project_root: Path) -> list[str]:
|
||||
return removed
|
||||
|
||||
|
||||
def restore_snapshot_files(project_root: Path) -> None:
|
||||
def restore_snapshot_files(project_root: Path, route_id: int | None = None) -> None:
|
||||
"""스냅샷 파일을 작업본 자리에 되돌린다. 스냅샷에 없던 트리는 비워 둔다.
|
||||
|
||||
배수유역 범위를 넓히기 전(2026-09-04)에 찍힌 스냅샷은 `edits/`만 갖고 있다 —
|
||||
@@ -261,6 +273,16 @@ def restore_snapshot_files(project_root: Path) -> None:
|
||||
continue
|
||||
_copy_tree(stored, root / tree)
|
||||
|
||||
# 코리도는 복원으로 만든 **새 route id** 이름으로 되돌린다 — 이름이 어긋나면 브라우저가
|
||||
# 저장본을 못 찾아 초기화 뒤 첫 진입마다 통째로 다시 만든다.
|
||||
from B05_Profile.B05_Profile_Router_Corridor import corridor_path
|
||||
|
||||
corridor = source / _CORRIDOR_NAME
|
||||
if route_id is not None and corridor.is_file():
|
||||
target = corridor_path(root, int(route_id))
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(corridor, target)
|
||||
|
||||
|
||||
async def restore_initial_snapshot(
|
||||
connection: aiomysql.Connection, project_root: Path, project_id: str
|
||||
|
||||
@@ -442,7 +442,14 @@ function appendTransfer(
|
||||
* 띠 면을 누르면 그 balloon이 강조된다. 다른 띠나 빈 곳을 누르면 풀린다
|
||||
* (2026-08-02 사용자 지시). 강조는 이 SVG 안에서만 사는 상태라 다시 그리면 초기화된다.
|
||||
*/
|
||||
export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: BalanceLayerBox): void {
|
||||
export function appendBalanceLayer(
|
||||
svg: SVGSVGElement,
|
||||
plan: HaulPlan,
|
||||
box: BalanceLayerBox,
|
||||
/** 실제로 붙일 자리 — 그래프 칸 밖을 자르는 겹이 있으면 그쪽(2026-09-04). 재는 일은
|
||||
* `svg`(뷰박스가 필요)가 그대로 맡는다. */
|
||||
host: SVGSVGElement | SVGGElement = svg,
|
||||
): void {
|
||||
const group = svgElement("g", { class: "b06-balance" });
|
||||
appendBalanceLine(group, plan, box);
|
||||
|
||||
@@ -482,7 +489,7 @@ export function appendBalanceLayer(svg: SVGSVGElement, plan: HaulPlan, box: Bala
|
||||
balloons.forEach((balloon, index) => bind(balloon, index));
|
||||
// 띠 밖(그래프 빈 곳)을 누르면 강조를 푼다.
|
||||
svg.addEventListener("click", clearAll);
|
||||
svg.append(group);
|
||||
host.append(group);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -49,8 +49,52 @@ export interface MassHaulAxis {
|
||||
* 전 구간 기준 ±200㎥ 고정이다.
|
||||
*/
|
||||
viewRange?: { fromM: number; toM: number };
|
||||
/** 세로창 버티기·부드러운 이동 상태. 넘기면 창이 한 칸에 확 튀지 않는다(2026-09-04). */
|
||||
window?: MassHaulWindowState;
|
||||
}
|
||||
|
||||
/**
|
||||
* 세로창이 스크롤 한 칸에 몇 배씩 튀는 것을 막는 상태(2026-09-04 사용자 확정: 「버티기 +
|
||||
* 부드럽게」). 누가토량 곡선은 표고와 달리 가팔라, 급한 구간이 창에 들어오면 폭이 한 번에
|
||||
* 4~5배로 바뀌었다(실측 228 → 1,019㎥).
|
||||
*
|
||||
* 규칙 둘.
|
||||
* ① **버티기** — 곡선이 지금 창 안에 들어오고 창을 절반 넘게 채우면 **그대로 둔다**.
|
||||
* ② **부드럽게** — 그래도 바꿔야 하면 한 번에 안 가고 다시 그릴 때마다 남은 만큼 좁힌다.
|
||||
*/
|
||||
export interface MassHaulWindowState {
|
||||
/** 지금 쓰고 있는 창. */
|
||||
held: { min: number; max: number } | null;
|
||||
/** 목표에 도착했는가 — 아니면 부르는 쪽이 다음 프레임에 다시 그린다. */
|
||||
settled: boolean;
|
||||
/** 예약해 둔 다음 프레임(중복 예약 방지). */
|
||||
frame: number;
|
||||
}
|
||||
|
||||
export function createMassHaulWindowState(): MassHaulWindowState {
|
||||
return { held: null, settled: true, frame: 0 };
|
||||
}
|
||||
|
||||
/** 아직 목표에 못 갔으면 다음 프레임에 한 번 더 그리게 예약한다. */
|
||||
export function scheduleMassHaulSettle(state: MassHaulWindowState, redraw: () => void): void {
|
||||
if (state.frame) cancelAnimationFrame(state.frame);
|
||||
state.frame = 0;
|
||||
if (state.settled) return;
|
||||
state.frame = requestAnimationFrame(() => {
|
||||
state.frame = 0;
|
||||
redraw();
|
||||
});
|
||||
}
|
||||
|
||||
/** 새 창에 두는 여유(위아래 각각). 종단 그래프와 같은 20%. */
|
||||
const WINDOW_PAD_RATIO = 0.2;
|
||||
/** 곡선이 창을 이만큼 채우고 있으면 창을 그대로 둔다(버티기). */
|
||||
const WINDOW_KEEP_FILL = 0.55;
|
||||
/** 다시 그릴 때마다 목표까지 남은 거리에서 좁히는 비율. 0.2면 약 0.2초에 걸쳐 미끄러진다 —
|
||||
* 0.4는 0.08초 만에 끝나 여전히 「툭」 바뀌어 보였고, 0.15는 그리는 횟수가 늘어 프레임을
|
||||
* 더 먹었다(2026-09-04 실측). */
|
||||
const WINDOW_EASE = 0.2;
|
||||
|
||||
/** 축 눈금이 읽히는 최소 높이. 이보다 낮아지면 그래프가 뭉개진다. */
|
||||
export const MASS_HAUL_MIN_HEIGHT = 90;
|
||||
/** 패널을 건드리지 않았을 때의 유토곡선 높이. */
|
||||
@@ -137,6 +181,7 @@ const VOLUME_RANGE_BASE_M3 = 200;
|
||||
function volumeRange(
|
||||
series: MassHaulSeries[],
|
||||
viewRange?: { fromM: number; toM: number },
|
||||
state?: MassHaulWindowState,
|
||||
): { min: number; max: number } {
|
||||
let rawMin = 0;
|
||||
let rawMax = 0;
|
||||
@@ -168,8 +213,35 @@ function volumeRange(
|
||||
}
|
||||
if (found) {
|
||||
// 창 안이 거의 평평하면(구간 토량 변화가 없으면) 최소 폭을 줘 선이 축에 붙지 않게 한다.
|
||||
const padding = Math.max((rawMax - rawMin) * 0.05, 1);
|
||||
return { min: rawMin - padding, max: rawMax + padding };
|
||||
const padding = Math.max((rawMax - rawMin) * WINDOW_PAD_RATIO, 1);
|
||||
const target = { min: rawMin - padding, max: rawMax + padding };
|
||||
if (!state) return target;
|
||||
const held = state.held;
|
||||
if (held) {
|
||||
// 버티기 — 곡선이 지금 창 안에 들어오고 절반 넘게 채우면 아예 건드리지 않는다.
|
||||
const heldSpan = held.max - held.min;
|
||||
const inside = rawMin >= held.min && rawMax <= held.max;
|
||||
const fills = rawMax - rawMin >= heldSpan * WINDOW_KEEP_FILL;
|
||||
if (inside && fills) {
|
||||
state.settled = true;
|
||||
return held;
|
||||
}
|
||||
// 부드럽게 — 남은 만큼씩 좁혀 간다(다시 그릴 때마다 한 걸음).
|
||||
const next = {
|
||||
min: held.min + (target.min - held.min) * WINDOW_EASE,
|
||||
max: held.max + (target.max - held.max) * WINDOW_EASE,
|
||||
};
|
||||
const tolerance = Math.max((target.max - target.min) * 0.01, 0.5);
|
||||
const done =
|
||||
Math.abs(next.min - target.min) <= tolerance &&
|
||||
Math.abs(next.max - target.max) <= tolerance;
|
||||
state.held = done ? target : next;
|
||||
state.settled = done;
|
||||
return state.held;
|
||||
}
|
||||
state.held = target;
|
||||
state.settled = true;
|
||||
return target;
|
||||
}
|
||||
}
|
||||
for (const entry of series) {
|
||||
@@ -241,7 +313,8 @@ function textWidth(value: string): number {
|
||||
* 재서 여유가 큰 쪽을 고르며, 어느 쪽에도 안 들어가면 여유가 더 큰 쪽 끝에 붙인다.
|
||||
*/
|
||||
function appendVolumeCallout(
|
||||
svg: SVGSVGElement,
|
||||
/** 붙일 자리 — 그래프 칸 밖을 자르는 겹이다(2026-09-04). */
|
||||
svg: SVGSVGElement | SVGGElement,
|
||||
point: MassHaulPoint,
|
||||
stationInterval: number,
|
||||
box: CalloutPlacement,
|
||||
@@ -364,10 +437,35 @@ export function createMassHaulChart(
|
||||
// 기준 버튼이 라디오가 되면서(택1 표시) Y 범위도 **표시 중인 곡선**으로 잡는다 —
|
||||
// 숨은 기준까지 합쳐 잡으면 선택한 그래프가 눌려 보인다. 아무것도 안 켰으면 전체로 폴백.
|
||||
const rangeSource = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
const { min, max } = volumeRange(rangeSource.length ? rangeSource : series, axis.viewRange);
|
||||
const { min, max } = volumeRange(
|
||||
rangeSource.length ? rangeSource : series,
|
||||
axis.viewRange,
|
||||
axis.window,
|
||||
);
|
||||
const span = Math.max(max - min, 1e-6);
|
||||
const y = (volume: number) => padTop + ((max - volume) / span) * plotHeight;
|
||||
|
||||
// 그래프 칸 밖으로 나간 것은 그리지 않는다(2026-09-04 사용자 보고) — 보이는 구간에
|
||||
// 세로를 맞추면 0선과 그 채움색이 눈금 상한을 넘어 **범례·토글 버튼 위까지** 칠해졌다.
|
||||
// 종단 그래프는 진작 자르고 있었는데 유토곡선만 빠져 있었다.
|
||||
const plotBottom = heightPx - MASS_PAD_BOTTOM;
|
||||
const clipId = `b06-mass-clip-${Math.random().toString(36).slice(2, 9)}`;
|
||||
const clip = svgElement("clipPath", { id: clipId });
|
||||
clip.append(
|
||||
svgElement("rect", {
|
||||
x: axisX,
|
||||
y: padTop,
|
||||
width: Math.max(0, widthPx - axis.padRight - axisX),
|
||||
height: Math.max(0, plotHeight),
|
||||
}),
|
||||
);
|
||||
const defs = svgElement("defs", {});
|
||||
defs.append(clip);
|
||||
// 두 겹으로 나누는 이유: 측점 세로선이 면과 곡선 **사이**에 서야 한다(그리는 순서 유지).
|
||||
const plotBack = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
const plotFront = svgElement("g", { "clip-path": `url(#${clipId})` });
|
||||
svg.append(defs);
|
||||
|
||||
// 패널을 줄이면 그래프 몫이 60px대까지 내려간다 — 눈금 5개를 그대로 두면 라벨이 서로 겹친다.
|
||||
const tickRatios = plotHeight < 110 ? [0, 0.5, 1] : [0, 0.25, 0.5, 0.75, 1];
|
||||
const axisTicks: Array<{ y: number; label: string }> = [];
|
||||
@@ -395,8 +493,10 @@ export function createMassHaulChart(
|
||||
}
|
||||
|
||||
const zeroY = y(0);
|
||||
// 0선은 격자보다 진하게 따로 그리므로 고정 축 눈금에도 함께 실어 준다.
|
||||
axisTicks.push({ y: zeroY, label: "0㎥" });
|
||||
// 0선이 창 안에 있을 때만 눈금에 싣는다 — 밖으로 나갔는데도 실으면 고정 축 맨 위에
|
||||
// 남의 값과 겹쳐 찍힌다(2026-09-04 사용자 보고).
|
||||
const zeroInside = zeroY >= padTop && zeroY <= plotBottom;
|
||||
if (zeroInside) axisTicks.push({ y: zeroY, label: "0㎥" });
|
||||
onAxis?.({ padLeft: axisX, ticks: axisTicks });
|
||||
const visible = series.filter((entry) => visibleKeys.has(entry.key));
|
||||
|
||||
@@ -420,7 +520,7 @@ export function createMassHaulChart(
|
||||
const banded = visible[0];
|
||||
if (banded) {
|
||||
const last = banded.result.points[banded.result.points.length - 1];
|
||||
svg.append(
|
||||
plotBack.append(
|
||||
svgElement("path", {
|
||||
// 곡선과 **같은 경로**로 위쪽 테두리를 그린 뒤 0선을 따라 닫는다.
|
||||
d:
|
||||
@@ -431,6 +531,8 @@ export function createMassHaulChart(
|
||||
);
|
||||
}
|
||||
|
||||
svg.append(plotBack);
|
||||
|
||||
// 측점선 — 종단도와 같은 자리에 서고, 눌러서 측점을 고를 수 있다(3자 선택 동기화).
|
||||
// 라벨은 바로 위 종단면도가 이미 달고 있어 여기서는 생략한다(중복 표기 방지).
|
||||
for (const station of longitudinal.stations) {
|
||||
@@ -465,27 +567,33 @@ export function createMassHaulChart(
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그리고 눈금값도 따로 적는다.
|
||||
// Y 범위 계산(`volumeRange`)이 0을 항상 품으므로 이 선은 어떤 노선에서도 화면에 남는다.
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
y1: zeroY,
|
||||
x2: widthPx - axis.padRight,
|
||||
y2: zeroY,
|
||||
class: "b06-masshaul__zero",
|
||||
}),
|
||||
svgText("0", {
|
||||
x: axisX - 9,
|
||||
y: zeroY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick b06-masshaul__zero-tick",
|
||||
}),
|
||||
);
|
||||
// 0선 — 절토 우세와 성토 우세를 가르는 기준선이라 격자보다 진하게 그린다.
|
||||
// 보이는 구간에 세로를 맞추면 0이 창 밖으로 나갈 수 있다(2026-09-04 사용자 확정:
|
||||
// 「나가도 됨」) — 그때는 아예 그리지 않는다. 글자는 축 **왼쪽**이라 자르는 겹에 넣으면
|
||||
// 사라지므로 따로 붙인다.
|
||||
if (zeroInside) {
|
||||
plotFront.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
y1: zeroY,
|
||||
x2: widthPx - axis.padRight,
|
||||
y2: zeroY,
|
||||
class: "b06-masshaul__zero",
|
||||
}),
|
||||
);
|
||||
svg.append(
|
||||
svgText("0", {
|
||||
x: axisX - 9,
|
||||
y: zeroY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick b06-masshaul__zero-tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 뒤에 그린 곡선이 위로 오므로 범례 순서의 역순으로 그려 첫 곡선(정식)을 가장 위에 둔다.
|
||||
for (const entry of [...visible].reverse()) {
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("path", {
|
||||
d: curvePath(entry.result.points, x, y),
|
||||
class: seriesClass(entry, "b06-masshaul__curve"),
|
||||
@@ -504,7 +612,7 @@ export function createMassHaulChart(
|
||||
// 종점은 오른쪽 끝이라 라벨을 왼쪽으로 눕히고, 위아래는 플롯 안으로 클램프한다.
|
||||
const labelX = Math.max(epX - 8, axis.padLeft + labelWidth);
|
||||
const labelY = Math.min(Math.max(epY - 8, padTop + 12), heightPx - MASS_PAD_BOTTOM - 4);
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("circle", { cx: epX, cy: epY, r: 3.5, class: "b06-masshaul__ep-dot" }),
|
||||
svgElement("rect", {
|
||||
x: labelX - labelWidth,
|
||||
@@ -527,23 +635,29 @@ export function createMassHaulChart(
|
||||
// 토량 분배 레이어 — 곡선 **위**, 선택 말풍선 **아래**. 순서를 바꾸면 평형선이 곡선을
|
||||
// 가리거나(위로 올리면) 선택 말풍선이 balloon에 묻힌다(아래로 내리면).
|
||||
if (haulPlan) {
|
||||
appendBalanceLayer(svg, haulPlan, {
|
||||
x,
|
||||
y,
|
||||
left: axis.padLeft,
|
||||
right: widthPx - axis.padRight,
|
||||
top: padTop,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
stationInterval,
|
||||
});
|
||||
appendBalanceLayer(
|
||||
svg,
|
||||
haulPlan,
|
||||
{
|
||||
x,
|
||||
y,
|
||||
left: axis.padLeft,
|
||||
right: widthPx - axis.padRight,
|
||||
top: padTop,
|
||||
bottom: heightPx - MASS_PAD_BOTTOM,
|
||||
curveYAt,
|
||||
stationInterval,
|
||||
},
|
||||
// 그리는 자리는 자르는 겹 — 창 밖으로 나간 평형선·말풍선이 눈금 위로 새지 않는다.
|
||||
plotFront,
|
||||
);
|
||||
}
|
||||
|
||||
// 선택 측점 강조 — 곡선 위 점 + 구간 물량 말풍선.
|
||||
const focus = banded ? massHaulPointAt(banded, longitudinal, selectedStationId) : null;
|
||||
if (focus) {
|
||||
const focusX = x(focus.chainage_m);
|
||||
svg.append(
|
||||
plotFront.append(
|
||||
svgElement("circle", {
|
||||
cx: focusX,
|
||||
cy: y(focus.cumulative_volume_m3),
|
||||
@@ -551,7 +665,7 @@ export function createMassHaulChart(
|
||||
class: "b06-masshaul__focus",
|
||||
}),
|
||||
);
|
||||
appendVolumeCallout(svg, focus, stationInterval, {
|
||||
appendVolumeCallout(plotFront, focus, stationInterval, {
|
||||
anchorX: focusX,
|
||||
anchorY: y(focus.cumulative_volume_m3),
|
||||
left: axis.padLeft,
|
||||
@@ -562,6 +676,8 @@ export function createMassHaulChart(
|
||||
});
|
||||
}
|
||||
|
||||
svg.append(plotFront);
|
||||
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: axisX,
|
||||
|
||||
@@ -260,7 +260,41 @@ async def is_analysis_running(cursor: aiomysql.DictCursor, project_id: str) -> b
|
||||
if not row:
|
||||
return False
|
||||
state = row["state"] if isinstance(row, dict) else row[0]
|
||||
return str(state) == "IN_PROGRESS"
|
||||
if str(state) != "IN_PROGRESS":
|
||||
return False
|
||||
# 자동 확정이 보류되면 1단계는 사용자가 B04에서 모델을 고를 때까지 IN_PROGRESS 로
|
||||
# 남는다 — 계산은 이미 끝난 상태다. 그 사이에도 새 자료는 받아야 한다
|
||||
# (2026-09-04 사용자 지시 — 같은 프로젝트로 전처리를 되풀이 시험).
|
||||
return not await _surface_run_settled(cursor, project_id)
|
||||
|
||||
|
||||
# 계산이 끝나 사용자의 다음 조작을 기다리는 진행 단계 — 도는 중이 아니다.
|
||||
_SETTLED_SURFACE_STAGES = frozenset({"awaiting_confirmation", "completed", "failed"})
|
||||
|
||||
|
||||
async def _surface_run_settled(cursor: aiomysql.DictCursor, project_id: str) -> bool:
|
||||
"""전처리 진행 파일이 「끝났음」으로 적혀 있는가. 파일이 없으면 판단하지 않는다."""
|
||||
from pathlib import Path
|
||||
|
||||
from B04_PreProcess.B04_PreProcess_Router_Progress import read_surface_progress
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
|
||||
await cursor.execute(
|
||||
"SELECT storage_path FROM projects WHERE id = %s AND deleted_at IS NULL",
|
||||
(project_id,),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return False
|
||||
storage_path = row["storage_path"] if isinstance(row, dict) else row[0]
|
||||
if not storage_path:
|
||||
return False
|
||||
try:
|
||||
progress = read_surface_progress(Path(resolve_stored_project_path(str(storage_path))))
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
stage = str((progress or {}).get("current_stage") or "")
|
||||
return stage in _SETTLED_SURFACE_STAGES
|
||||
|
||||
|
||||
async def reset_stages_after_input_change(cursor: aiomysql.DictCursor, project_id: str) -> None:
|
||||
|
||||
Reference in New Issue
Block a user