This commit is contained in:
2026-07-23 19:31:52 +09:00
parent e52823294f
commit f633dc0a49
9 changed files with 245 additions and 149 deletions
+2 -2
View File
@@ -187,10 +187,10 @@ export async function updateContourInterval(
);
}
/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 길이). */
/** 종단 계획선 편집 델타 (자동 선형 대비 측점 계획고 델타 + 종단곡선 반경). */
export interface ProfileAlignmentEdits {
station_offsets: Record<string, number>;
curve_lengths: Record<string, number>;
curve_radii: Record<string, number>;
}
export interface ProfileAlignmentSaveResponse {
@@ -8,7 +8,7 @@
기하 규칙 (사용자 확정 사항):
- 변화점은 **기준 측점 위에만** 놓인다 → 곡선 중심이 측점 수직선상에 있다.
- 종단곡선은 변화점 대칭 배치이며 좌우에 직선 구간이 반드시 남는다.
- 곡선 기본 길이 L = 측점간격 × `curve_length_ratio`, 반경 R = L / |A| 로 파생.
- 곡선 기본 **반경** R = 측점간격 × `curve_radius_ratio`, 길이 L = R × |A| 로 파생.
수치 최적화(직선 분할 DP·표고 결정)는 [[B05_wf2_Route_Engine_Grade_Solver]]가,
기준 해석과 오케스트레이션은 [[B05_wf2_Route_Engine_Grade]]가 담당한다. 이 모듈은
@@ -37,8 +37,7 @@ class AlignmentPolicy:
"""계획선 선형·편집 정책. config 기본값 위에 노선별 해석값을 얹는다."""
station_interval_m: float
curve_length_ratio: float
curve_length_min_m: float
curve_radius_ratio: float
curve_tangent_max_ratio: float
curve_skip_legal_exception: bool
balance_tolerance_percent: float
@@ -62,8 +61,7 @@ class AlignmentPolicy:
config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls(
station_interval_m=float(station_interval_m),
curve_length_ratio=float(config["curve_length_ratio"]),
curve_length_min_m=float(config["curve_length_min_m"]),
curve_radius_ratio=float(config["curve_radius_ratio"]),
curve_tangent_max_ratio=float(config["curve_tangent_max_ratio"]),
curve_skip_legal_exception=bool(config["curve_skip_legal_exception"]),
balance_tolerance_percent=float(config["balance_tolerance_percent"]),
@@ -82,11 +80,8 @@ class AlignmentPolicy:
config = FOREST_ROAD_PROFILE_ALIGNMENT
return cls(
station_interval_m=float(payload.get("station_interval_m") or 20.0),
curve_length_ratio=float(
payload.get("curve_length_ratio", config["curve_length_ratio"])
),
curve_length_min_m=float(
payload.get("curve_length_min_m", config["curve_length_min_m"])
curve_radius_ratio=float(
payload.get("curve_radius_ratio", config["curve_radius_ratio"])
),
curve_tangent_max_ratio=float(
payload.get("curve_tangent_max_ratio", config["curve_tangent_max_ratio"])
@@ -109,19 +104,22 @@ class AlignmentPolicy:
)
@property
def default_curve_length_m(self) -> float:
"""측점간격 기준 기본 종단곡선 길이."""
return max(self.curve_length_min_m, self.station_interval_m * self.curve_length_ratio)
def default_curve_radius_m(self) -> float:
"""측점간격 기준 기본 종단곡선 반경.
R을 1차 값으로 고정해야 계획고를 편집해도 곡선 반경이 흔들리지 않는다.
곡선 길이는 L = R × |대수차| 로 따라 움직인다.
"""
return max(1e-6, self.station_interval_m * self.curve_radius_ratio)
def as_dict(self) -> dict[str, Any]:
"""프론트엔드가 동일 기하를 재현하는 데 필요한 상수 묶음."""
return {
"station_interval_m": self.station_interval_m,
"curve_length_ratio": self.curve_length_ratio,
"curve_length_min_m": self.curve_length_min_m,
"curve_radius_ratio": self.curve_radius_ratio,
"curve_tangent_max_ratio": self.curve_tangent_max_ratio,
"curve_skip_legal_exception": self.curve_skip_legal_exception,
"default_curve_length_m": self.default_curve_length_m,
"default_curve_radius_m": self.default_curve_radius_m,
"balance_tolerance_percent": self.balance_tolerance_percent,
"edit_step_m": self.edit_step_m,
"grade_violation_policy": self.grade_violation_policy,
@@ -174,14 +172,17 @@ def build_curves(
pvi_s: np.ndarray,
pvi_z: np.ndarray,
policy: AlignmentPolicy,
curve_lengths: dict[str, float] | None = None,
curve_radii: dict[str, float] | None = None,
) -> tuple[list[dict[str, Any]], list[str]]:
"""각 변화점에 대칭 종단곡선을 삽입하고 곡선 제원을 만든다.
**반경 R이 1차 값**이고 곡선길이는 L = R × |대수차| 로 따라온다. 계획고를 편집하면
대수차가 바뀌므로 L은 변하지만 R은 지정한 값 그대로 유지된다.
곡선 반쪽 길이는 짧은 쪽 인접 직선의 `curve_tangent_max_ratio` 이내로 제한해
좌우에 직선이 반드시 남게 한다(사용자가 R을 키워도 곡선끼리 겹치지 않는다).
좌우에 직선이 반드시 남게 한다(R을 크게 넣어도 곡선끼리 겹치지 않는다).
"""
overrides = curve_lengths or {}
overrides = curve_radii or {}
spans = pvi_s[1:] - pvi_s[:-1]
grades = (pvi_z[1:] - pvi_z[:-1]) / np.where(spans > 0, spans, 1.0)
curves: list[dict[str, Any]] = []
@@ -201,11 +202,12 @@ def build_curves(
omitted = skip_allowed and policy.curve_skip_legal_exception
requested = overrides.get(key)
try:
length = float(requested) if requested is not None else policy.default_curve_length_m
radius = float(requested) if requested is not None else policy.default_curve_radius_m
except (TypeError, ValueError):
length = policy.default_curve_length_m
if not np.isfinite(length) or length <= 0:
length = policy.default_curve_length_m
radius = policy.default_curve_radius_m
if not np.isfinite(radius) or radius <= 0:
radius = policy.default_curve_radius_m
length = radius * abs(delta)
half_limit = (
min(float(spans[index - 1]), float(spans[index])) * policy.curve_tangent_max_ratio
)
@@ -214,8 +216,8 @@ def build_curves(
continue
if length / 2.0 - half > 1e-6:
warnings.append(
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 길이를 "
f"{half * 2.0:.1f}m로 줄였습니다."
f"chainage {chainage:.1f}m: 인접 직선이 짧아 종단곡선 반경을 "
f"{half * 2.0 / abs(delta):.0f}m(길이 {half * 2.0:.1f}m)로 줄였습니다."
)
length = half * 2.0
curves.append(
@@ -353,14 +355,14 @@ def build_alignment(
for key, value in (edits.get("station_offsets") or {}).items()
if value is not None
}
curve_lengths = {
curve_radii = {
str(key): float(value)
for key, value in (edits.get("curve_lengths") or {}).items()
for key, value in (edits.get("curve_radii") or {}).items()
if value is not None
}
pvi_s, pvi_z, sources = resolve_pvi(base_s, base_z, station_offsets)
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_lengths)
curves, warnings = build_curves(pvi_s, pvi_z, policy, curve_radii)
segments = _segments(pvi_s, pvi_z)
plan = evaluate(pvi_s, pvi_z, curves, chainage)
difference = plan - ground
@@ -420,7 +422,7 @@ def build_alignment(
{"chainage_m": round(float(s), 6), "elevation_m": round(float(z), 6)}
for s, z in zip(base_s, base_z)
],
"edits": {"station_offsets": station_offsets, "curve_lengths": curve_lengths},
"edits": {"station_offsets": station_offsets, "curve_radii": curve_radii},
"pvi": pvi_rows,
"segments": segments,
"curves": [
+6 -6
View File
@@ -182,18 +182,18 @@ class ProfileAlignmentSaveRequest(BaseModel):
route_id: int = Field(gt=0)
# {chainage 문자열(소수 3자리): 자동 선형 대비 계획고 델타(m)}
station_offsets: dict[str, float] = Field(default_factory=dict)
# {chainage 문자열(소수 3자리): 종단곡선 길이(m)}. 화면의 R 입력에서 역산된 값.
curve_lengths: dict[str, float] = Field(default_factory=dict)
# {chainage 문자열(소수 3자리): 종단곡선 반경(m)}. 길이 L은 R × |대수차| 로 파생된다.
curve_radii: dict[str, float] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_edits(self) -> "ProfileAlignmentSaveRequest":
for chainage, length in self.curve_lengths.items():
if length <= 0:
raise ValueError(f"종단곡선 길이는 0보다 커야 합니다 (chainage {chainage}).")
for chainage, radius in self.curve_radii.items():
if radius <= 0:
raise ValueError(f"종단곡선 반경은 0보다 커야 합니다 (chainage {chainage}).")
return self
def edits(self) -> dict[str, Any]:
return {"station_offsets": self.station_offsets, "curve_lengths": self.curve_lengths}
return {"station_offsets": self.station_offsets, "curve_radii": self.curve_radii}
class ProfileAlignmentSaveResponse(BaseModel):
@@ -9,16 +9,16 @@
* 규칙:
* - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다.
* - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다.
* - 반경 R이 1차 값이고 곡선길이 L = R × |대수차| 로 따라온다 (편집해도 R은 유지).
* - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다.
* ========================================================================== */
export interface AlignmentPolicy {
station_interval_m: number;
curve_length_ratio: number;
curve_length_min_m: number;
curve_radius_ratio: number;
curve_tangent_max_ratio: number;
curve_skip_legal_exception: boolean;
default_curve_length_m: number;
default_curve_radius_m: number;
balance_tolerance_percent: number;
edit_step_m: number;
grade_violation_policy: string;
@@ -102,7 +102,8 @@ export interface AlignmentViolation {
export interface AlignmentEdits {
station_offsets: Record<string, number>;
curve_lengths: Record<string, number>;
/** 변화점별 종단곡선 반경(m). 지정하지 않으면 정책 기본 반경을 쓴다. */
curve_radii: Record<string, number>;
}
export interface ProfileAlignment {
@@ -135,7 +136,7 @@ export function chainageKey(value: number): string {
}
export function emptyEdits(): AlignmentEdits {
return { station_offsets: {}, curve_lengths: {} };
return { station_offsets: {}, curve_radii: {} };
}
/** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */
@@ -201,7 +202,7 @@ interface WorkingCurve extends AlignmentCurve {
function buildCurves(
pvi: AlignmentNode[],
policy: AlignmentPolicy,
curveLengths: Record<string, number>,
curveRadii: Record<string, number>,
warnings: string[],
): WorkingCurve[] {
const curves: WorkingCurve[] = [];
@@ -217,15 +218,16 @@ function buildCurves(
const chainage = pvi[index].chainage_m;
const key = chainageKey(chainage);
const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12;
const requested = curveLengths[key];
const desired =
Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_length_m;
const requested = curveRadii[key];
const radius =
Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_radius_m;
const desired = radius * Math.abs(delta);
const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio;
const half = Math.min(desired / 2, halfLimit);
if (half <= 1e-9) continue;
if (desired / 2 - half > 1e-6) {
warnings.push(
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 길이를 ${(half * 2).toFixed(1)}m로 줄였습니다.`,
`${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 반경을 ${((half * 2) / Math.abs(delta)).toFixed(0)}m로 줄였습니다.`,
);
}
const length = half * 2;
@@ -290,7 +292,7 @@ export function buildAlignment(base: AlignmentBase, edits: AlignmentEdits): Prof
const nodes = resolvePvi(base, edits.station_offsets);
const pviS = nodes.map((node) => node.chainage_m);
const pviZ = nodes.map((node) => node.elevation_m);
const curves = buildCurves(nodes, base.policy, edits.curve_lengths, warnings);
const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings);
const segments: AlignmentSegment[] = [];
for (let index = 0; index < nodes.length - 1; index += 1) {
@@ -472,25 +474,23 @@ export function shiftSegment(
return { ...edits, station_offsets: offsets };
}
/** 곡선 행에서 R을 고치면 L = R × |A| 로 역산해 곡선 길이 override로 저장한다. */
/**
* 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는
* L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다).
* 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다.
*/
export function setCurveRadius(
edits: AlignmentEdits,
curve: AlignmentCurve,
radiusM: number,
): AlignmentEdits {
const key = chainageKey(curve.chainage_m);
const deltaRatio = Math.abs(curve.delta_pct) / 100;
const curveLengths = { ...edits.curve_lengths };
if (!Number.isFinite(radiusM) || radiusM <= 0 || deltaRatio < 1e-9) {
delete curveLengths[key];
} else {
curveLengths[key] = Number((radiusM * deltaRatio).toFixed(6));
}
return { ...edits, curve_lengths: curveLengths };
const curveRadii = { ...edits.curve_radii };
if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key];
else curveRadii[key] = Number(radiusM.toFixed(6));
return { ...edits, curve_radii: curveRadii };
}
export function hasEdits(edits: AlignmentEdits): boolean {
return (
Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_lengths).length > 0
);
return Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_radii).length > 0;
}
@@ -77,7 +77,7 @@ function readDraft(storageKey: string): AlignmentEdits | null {
const parsed = JSON.parse(raw) as Partial<AlignmentEdits>;
return {
station_offsets: parsed.station_offsets ?? {},
curve_lengths: parsed.curve_lengths ?? {},
curve_radii: parsed.curve_radii ?? {},
};
} catch {
return null;
@@ -130,10 +130,10 @@ export function createProfileEditStore(
resetStation(chainageM) {
const key = chainageKey(chainageM);
const stationOffsets = { ...current.station_offsets };
const curveLengths = { ...current.curve_lengths };
const curveRadii = { ...current.curve_radii };
delete stationOffsets[key];
delete curveLengths[key];
commit({ station_offsets: stationOffsets, curve_lengths: curveLengths });
delete curveRadii[key];
commit({ station_offsets: stationOffsets, curve_radii: curveRadii });
},
resetAll() {
commit(emptyEdits());
+44 -17
View File
@@ -2,8 +2,9 @@
* B05_wf2_Route_UI_Profile_Panel.ts
* 하단 종단면도 패널 — 그래프 + 도면 테이블 2단, 계획선 직접 편집.
*
* 화면 높이의 40%를 쓰며, 그래프와 9행 도면 테이블이 **하나의 가로 스크롤러** 안에
* 화면 높이의 60%를 쓰며, 그래프와 12행 도면 테이블이 **하나의 가로 스크롤러** 안에
* 같은 폭으로 쌓여 X축이 자동으로 맞물린다(스크롤 동기화 코드 불필요).
* 본문 세로는 그래프 30% : 테이블 70%로 나눈다.
*
* 편집은 전부 프론트에서 즉시 계산해 다시 그리고, 영속화는 [확정] 시점에
* `saveProfileAlignment()`로 편집 델타만 보낸다.
@@ -40,15 +41,11 @@ import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table";
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
const COLLAPSED_KEY = "b05-route-profile-collapsed";
const HORIZONTAL_SCROLLBAR_HEIGHT = 16;
/**
* 그래프 높이는 패널을 키워도 이 값을 유지한다. 패널을 60vh로 넓힌 목적이 그래프 확대가
* 아니라 12행 도면 테이블의 가독성 확보이므로, 늘어난 세로 공간은 테이블이 가져간다.
*/
const CHART_HEIGHT = 180;
const MIN_CHART_HEIGHT = 120;
/** 12행 × 최소 행높이. 이보다 좁아지면 그래프를 줄여서라도 테이블을 확보한다. */
const MIN_TABLE_HEIGHT = 168;
/** 정보 라인을 뺀 본문 세로를 그래프 30% : 테이블 70%로 나눈다. */
const CHART_HEIGHT_RATIO = 0.3;
const MIN_CHART_HEIGHT = 100;
/** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */
const TABLE_ROW_COUNT = 12;
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
const candidate = data.profile_alignment as ProfileAlignment | undefined;
@@ -115,6 +112,14 @@ function chainageMapper(data: LongitudinalSection, width: number): (chainage: nu
return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
}
/** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */
function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => number): number {
const stations = data.stations;
if (stations.length < 2) return 72;
const span = x(stations[stations.length - 1].chainage_m) - x(stations[0].chainage_m);
return Math.max(30, Math.min(span / (stations.length - 1) - 2, 96));
}
export function createRouteProfilePanel(
projectId: string,
onSelectStation: (stationId: string) => void,
@@ -158,7 +163,7 @@ export function createRouteProfilePanel(
],
["변화점", `${alignment.pvi.length}`],
["종단곡선", `${alignment.curves.filter((curve) => !curve.omitted).length}`],
["기 R", `${policy.default_curve_length_m.toFixed(1)} m 곡선길이`],
["기 R", `${policy.default_curve_radius_m.toFixed(1)} m`],
];
const editedCount = Object.keys(alignment.edits.station_offsets).length;
if (editedCount) entries.push(["편집 측점", `${editedCount}`, "edited"]);
@@ -243,23 +248,28 @@ export function createRouteProfilePanel(
canvas.style.width = `${width}px`;
const x = chainageMapper(longitudinal, width);
// 그래프 30% : 테이블 70% (상단 정보 라인은 본문 밖이라 애초에 빠져 있다).
// 가로 스크롤바를 `overflow-x: scroll`로 항상 띄우므로 clientHeight에서 이미 빠져 있다.
const available = Math.max(120, body.clientHeight);
const chartHeight = alignment
? Math.max(MIN_CHART_HEIGHT, Math.round(available * CHART_HEIGHT_RATIO))
: available;
const tableHeight = available - chartHeight;
const table = alignment
? createProfileTable({
alignment,
stationInterval: stationInterval ?? alignment.policy.station_interval_m,
width,
height: tableHeight,
cellWidth: stationCellWidth(longitudinal, x),
rowCount: TABLE_ROW_COUNT,
x,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
})
: null;
// 그래프는 고정 높이를 지키고 남는 세로 공간은 전부 테이블이 쓴다(행 높이가 늘어난다).
const available = body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT;
const chartHeight = table
? Math.max(MIN_CHART_HEIGHT, Math.min(CHART_HEIGHT, available - MIN_TABLE_HEIGHT))
: Math.max(MIN_CHART_HEIGHT, available);
const chartWrap = document.createElement("div");
chartWrap.className = "b05-profile__chart";
chartWrap.style.height = `${chartHeight}px`;
@@ -295,12 +305,29 @@ export function createRouteProfilePanel(
}),
);
}
canvas.style.height = `${available}px`;
canvas.append(chartWrap);
if (table) canvas.append(table);
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
}
// 종단면도는 가로로 매우 길다. 세로 휠을 가로 스크롤로 돌려 스크롤바를 잡지 않고도
// 노선을 훑을 수 있게 한다 (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 },
);
const resizeObserver = new ResizeObserver(() => {
if (
body.clientWidth <= 0 ||
+70 -39
View File
@@ -4,8 +4,10 @@
*
* 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라
* 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다.
* - 구배: `L=` / `H=` / `S=` 3행 (S행에 변화점 중앙종거를 함께 얹는다)
* - 곡선: `L=` / `R=` 2행 (R만 입력 가능, 고치면 L을 역산한다)
* - 구배: 연장 / 고저차 / 기울기 3행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다.
* - 곡선: 곡선길이 / 반경 2행. 반경 R만 입력 가능하고 L = R × |대수차| 로 따라온다.
*
* 값에는 `L=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다.
*
* 셀은 종단면도 그래프와 **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 맞물린다.
* ========================================================================== */
@@ -21,6 +23,11 @@ export interface ProfileTableOptions {
alignment: ProfileAlignment;
stationInterval: number;
width: number;
/** 테이블 전체 높이(px). 행 수로 나눈 값이 글자 크기 기준이 된다. */
height: number;
/** 이웃 측점과 겹치지 않는 셀 폭(px). */
cellWidth: number;
rowCount: number;
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
x: (chainageM: number) => number;
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
@@ -34,12 +41,28 @@ interface StationRowSpec {
interface SegmentRowSpec {
label: string;
unit: string;
cell: (segment: AlignmentSegment) => string;
}
const CELL_WIDTH = 56;
/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */
const MIN_SEGMENT_WIDTH = 32;
const FONT_MIN_PX = 9;
const FONT_MAX_PX = 16;
/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */
const FONT_PER_ROW_HEIGHT = 0.52;
/** 셀 폭 대비 글자 크기 비율 — "409.96"(6자) + `-` 여유가 잘리지 않는 값. */
const FONT_PER_CELL_WIDTH = 0.145;
/**
* 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다.
* 패널을 키우면 행이 두꺼워지며 글자도 같이 커지고, 측점이 촘촘해 셀이 좁아지면
* 가로가 먼저 한계에 걸려 글자가 작아진다.
*/
function fitFontSize(rowHeight: number, cellWidth: number): number {
const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, cellWidth * FONT_PER_CELL_WIDTH);
return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted)));
}
function element(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
@@ -50,8 +73,7 @@ function element(tag: string, className: string, text?: string): HTMLElement {
/** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */
function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
node.style.left = `${centerX - CELL_WIDTH / 2}px`;
node.style.width = `${CELL_WIDTH}px`;
node.style.left = `${centerX}px`;
row.append(node);
}
@@ -61,11 +83,11 @@ function createRow(className: string, label: string): HTMLElement {
return row;
}
/** 도면의 구배 블록 3행: 구간 연장 / 고저차 / 기울기. */
/** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */
const SEGMENT_ROWS: SegmentRowSpec[] = [
{ label: "구배 L", cell: (segment) => `L=${segment.length_m.toFixed(2)}m` },
{ label: "구배 H", cell: (segment) => `H=${segment.height_m.toFixed(2)}m` },
{ label: "구배 S", cell: (segment) => `S=${segment.grade_percent.toFixed(2)}%` },
{ label: "구배 L", unit: "m", cell: (segment) => segment.length_m.toFixed(2) },
{ label: "구배 H", unit: "m", cell: (segment) => segment.height_m.toFixed(2) },
{ label: "구배 S", unit: "%", cell: (segment) => segment.grade_percent.toFixed(2) },
];
function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] {
@@ -94,61 +116,61 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio
}
/**
* 구배 3행. 각 행은 변화점 사이 구간 전체를 덮는 블록으로 그린다.
* S행에는 변화점 중앙종거를 함께 얹는다 — 도면 구배 행의 소수값(0.56, -0.75 …)이 이 값이다(|A|·L/8).
* 구배 3행. 각 블록은 **곡선 구간(BVC~EVC)을 뺀 실제 직선 부분**만 덮는다.
* 변화점 자리는 곡선이 차지하므로 그 구간에 구배를 적어 두면 존재하지 않는 직선의
* 값을 읽는 셈이 된다. 값(L/H/S) 자체는 도면 관례대로 변화점 사이 기준이다.
*/
function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
): HTMLElement[] {
return SEGMENT_ROWS.map((spec, rowIndex) => {
const row = createRow("b05-profile-table__row--grade", spec.label);
const curveByPvi = new Map(
alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]),
);
return SEGMENT_ROWS.map((spec) => {
const row = createRow("b05-profile-table__row--grade", `${spec.label} (${spec.unit})`);
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
const span = Math.abs(x(segment.to_m) - left);
const startCurve = curveByPvi.get(segment.index);
const endCurve = curveByPvi.get(segment.index + 1);
const left = x(startCurve ? startCurve.evc_m : segment.from_m);
const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left;
if (span < MIN_SEGMENT_WIDTH) return;
const node = element("span", "b05-profile-table__segment", spec.cell(segment));
node.style.left = `${left}px`;
node.style.width = `${span}px`;
node.title =
`${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선\n` +
`연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` +
`구배 ${segment.grade_percent.toFixed(2)}%`;
if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) {
node.classList.add("is-violation");
node.title = `종단기울기 ${segment.grade_percent.toFixed(2)}%가 기준 ${alignment.policy.max_grade_pct.toFixed(1)}% 초과합니다.`;
node.title += `\n⚠ 기준 ${alignment.policy.max_grade_pct.toFixed(1)}% 초과`;
}
row.append(node);
});
// 중앙종거는 기울기 변화의 결과이므로 마지막 S행에만 표기한다.
if (rowIndex === SEGMENT_ROWS.length - 1) {
alignment.curves.forEach((curve) => {
const node = element(
"span",
"b05-profile-table__ordinate",
(curve.delta_pct >= 0 ? "" : "-") + curve.middle_ordinate_m.toFixed(2),
);
node.title = `중앙종거 (대수차 ${curve.delta_pct.toFixed(2)}% × L ${curve.l_m.toFixed(1)}m / 8)`;
placeCell(row, x(curve.chainage_m), node);
});
}
return row;
});
}
function curveTitle(curve: AlignmentCurve): string {
return (
`K=${curve.k.toFixed(2)} A=${curve.delta_pct.toFixed(2)}%\n` +
`종단곡선 R=${curve.r_m.toFixed(1)}m · L=${curve.l_m.toFixed(2)}m (L = R × |대수차|)\n` +
`대수차 A=${curve.delta_pct.toFixed(2)}% K=${curve.k.toFixed(2)} ` +
`중앙종거 ${curve.middle_ordinate_m.toFixed(3)}m\n` +
`BVC=${curve.bvc_m.toFixed(1)}m (EL ${curve.bvc_elevation_m.toFixed(2)})\n` +
`EVC=${curve.evc_m.toFixed(1)}m (EL ${curve.evc_elevation_m.toFixed(2)})` +
(curve.omit_reason ? `\n${curve.omit_reason}` : "")
);
}
/** 곡선 2행: 곡선길이 L(파생) / 반경 R(입력). */
/** 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 L(= R × |대수차| 파생). */
function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
const { alignment, x, onCurveRadiusChange } = options;
const lengthRow = createRow("b05-profile-table__row--curve", "곡선 L");
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R");
const lengthRow = createRow("b05-profile-table__row--curve is-group-start", "곡선 L (m)");
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R (m)");
alignment.curves.forEach((curve) => {
const lengthCell = element("span", "b05-profile-table__curve", `L=${curve.l_m.toFixed(2)}`);
const lengthCell = element("span", "b05-profile-table__curve", curve.l_m.toFixed(2));
lengthCell.title = curveTitle(curve);
const radiusCell = element("span", "b05-profile-table__curve");
@@ -158,7 +180,7 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
input.min = "1";
input.className = "b05-profile-table__radius";
input.value = curve.r_m.toFixed(1);
input.title = `종단곡선 반경 R (m) — 수정하면 L = R × |A| 로 역산합니다.\n${curveTitle(curve)}`;
input.title = `종단곡선 반경 R (m) — 이 값이 기준이고 곡선길이 L이 따라 계산됩니다.\n${curveTitle(curve)}`;
input.addEventListener("change", () => {
const parsed = Number.parseFloat(input.value);
onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null);
@@ -176,15 +198,24 @@ function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, x } = options;
const { alignment, stationInterval, width, height, cellWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth);
table.style.width = `${width}px`;
table.style.height = `${height}px`;
table.style.setProperty("--b05-table-font", `${fontSize}px`);
table.style.setProperty("--b05-table-cell-width", `${cellWidth}px`);
// 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다.
table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`);
table.append(...buildSegmentRows(alignment, x));
buildStationRows(alignment, stationInterval).forEach((spec) => {
const row = createRow(spec.modifier ? `is-${spec.modifier}` : "", spec.label);
alignment.stations.forEach((station, index) => {
const value = spec.cell(index);
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
spec.label,
);
alignment.stations.forEach((station, stationIndex) => {
const value = spec.cell(stationIndex);
if (!value) return;
placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value));
});
+58 -24
View File
@@ -58,16 +58,43 @@
box-sizing: border-box;
flex: 1 1 auto;
min-height: 0;
overflow-x: auto;
overflow-x: scroll;
overflow-y: hidden;
padding-inline: 15px;
}
/* 종단면도는 노선 전체 길이만큼 가로로 길어 스크롤바가 유일한 이동 수단이다.
전역 스크롤바(8px, thumb 불투명도 12%)는 가로로 쓰기엔 너무 옅어 잡히지 않으므로
이 패널에서만 더 두껍고 대비가 분명한 막대로 덮어쓴다. */
.b05-route-profile__body {
scrollbar-color: var(--color-text-muted, var(--color-plum-velvet)) var(--color-surface);
scrollbar-width: auto;
}
.b05-route-profile__body::-webkit-scrollbar {
height: 12px;
}
.b05-route-profile__body::-webkit-scrollbar-track {
border-top: 1px solid var(--color-border);
background-color: var(--color-surface);
}
.b05-route-profile__body::-webkit-scrollbar-thumb {
border: 2px solid transparent;
border-radius: var(--radius-pills);
background-color: color-mix(in srgb, var(--color-text) 45%, transparent);
background-clip: padding-box;
}
.b05-route-profile__body::-webkit-scrollbar-thumb:hover {
background-color: color-mix(in srgb, var(--color-text) 65%, transparent);
}
/* 그래프와 테이블을 같은 폭으로 쌓아 X축이 저절로 맞물리게 한다. */
.b05-profile__canvas {
display: flex;
flex-direction: column;
height: 100%;
}
.b05-profile__chart {
@@ -338,30 +365,36 @@
.b05-profile-table {
position: relative;
display: flex;
flex: 1 1 auto;
flex: 0 0 auto;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--color-border);
border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet));
color: var(--color-text-body);
font-size: 10px;
/* 행 높이와 셀 폭에 맞춰 렌더러가 계산해 넣는다 (createProfileTable). */
font-size: var(--b05-table-font, 11px);
line-height: 1.1;
user-select: none;
}
/* 12개 행이 남는 세로 공간을 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */
/* 12개 행이 테이블 세로를 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */
.b05-profile-table__row {
position: relative;
flex: 1 1 0;
min-height: 14px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
min-height: 12px;
border-bottom: 1px solid var(--color-border);
white-space: nowrap;
}
.b05-profile-table__row--grade {
background: color-mix(in srgb, var(--color-surface) 40%, transparent);
/* 구배 / 측점값 / 곡선 묶음의 시작 행은 굵은 선으로 그룹을 구분한다. */
.b05-profile-table__row.is-group-start {
border-top: 2px solid var(--color-text-muted, var(--color-plum-velvet));
}
.b05-profile-table__row--curve:last-child {
.b05-profile-table__row--grade {
background: color-mix(in srgb, var(--color-surface) 55%, transparent);
}
.b05-profile-table__row:last-child {
border-bottom: 0;
}
@@ -372,17 +405,16 @@
display: inline-flex;
align-items: center;
box-sizing: border-box;
width: 56px;
width: var(--b05-table-label-width, 60px);
height: 100%;
padding-inline: var(--spacing-4);
border-right: 1px solid var(--color-border);
border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet));
background: var(--color-surface-raised);
color: var(--color-text-muted, var(--color-plum-velvet));
color: var(--color-text);
font-weight: var(--font-weight-medium);
}
.b05-profile-table__cell,
.b05-profile-table__ordinate,
.b05-profile-table__segment,
.b05-profile-table__curve {
position: absolute;
@@ -394,6 +426,13 @@
overflow: hidden;
}
/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. */
.b05-profile-table__cell,
.b05-profile-table__curve {
width: var(--b05-table-cell-width, 56px);
transform: translateX(-50%);
}
.b05-profile-table__row.is-cut .b05-profile-table__cell {
color: rgb(220 38 38);
}
@@ -410,8 +449,8 @@
/* 구간 블록: 변화점 사이 직선 전체를 덮어 어느 구간의 값인지 한눈에 보이게 한다. */
.b05-profile-table__segment {
box-sizing: border-box;
border-left: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
border-right: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
}
.b05-profile-table__segment.is-violation {
@@ -419,12 +458,6 @@
color: rgb(180 83 9);
}
.b05-profile-table__ordinate {
z-index: 2;
background: var(--color-surface-raised);
color: var(--color-text-muted, var(--color-plum-velvet));
}
.b05-profile-table__curve {
z-index: 2;
background: var(--color-surface-raised);
@@ -441,12 +474,13 @@
.b05-profile-table__radius {
box-sizing: border-box;
width: 100%;
height: 90%;
padding: 0 1px;
border: 1px solid var(--color-border);
border-radius: 2px;
background: var(--color-surface);
color: var(--color-text-body);
font-size: 10px;
font-size: inherit;
text-align: center;
}
+7 -5
View File
@@ -338,11 +338,13 @@ FOREST_ROAD_PROFILE_ALIGNMENT = {
# 5% 미만이면 지반 추종이 왜곡되어 불필요한 변화점이 늘고, 15%를 넘으면
# 사토·객토 운반 물량 부담이 커진다. 10%를 실무 균형점으로 둔다.
"balance_tolerance_percent": 10.0,
# 종단곡선 기본 길이 = 측점간격 × 이 비율 (변화점 대칭 배치).
# 측점간격 20m → 곡선 8m가 변화점 좌우 ±4m를 점유하고 나머지 12m는 직선이다.
# 곡선반경 R은 길이 L과 기울기 대수차 A로부터 R = L / |A| 로 파생 표기한다.
"curve_length_ratio": 0.40,
"curve_length_min_m": 2.0,
# 종단곡선 기본 **반경** R = 측점간격 × 이 비율 (변화점 대칭 배치).
# R을 1차 값으로 두어야 계획고를 편집해도 R이 흔들리지 않는다(곡선길이 L이 대신 변한다).
# L = R × |기울기 대수차 A(비율)|
# 주의: 측점간격 20m 기준 R=8m이면 A=5%일 때 L=0.4m, A=15%일 때 L=1.2m로
# 도면상 곡선이 거의 드러나지 않는다. 곡선을 뚜렷하게 보려면 이 비율을 크게 올린다
# (예: 20.0 → R=400m, A=10%일 때 L=40m).
"curve_radius_ratio": 0.40,
# 인접 직선 길이 대비 곡선 반쪽이 점유할 수 있는 최대 비율.
# 0.45면 짧은 쪽 직선의 55%가 항상 직선으로 남아 좌우 곡선이 겹치지 않는다.
"curve_tangent_max_ratio": 0.45,