260722_2
This commit is contained in:
@@ -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": [
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user