참고 도면 `13 유토곡선(울진 울진 대흥 산65 외2(3공구)).pdf` 분석 결과를 반영해 B06 종단면도 바로 아래에 유토곡선을 추가하고, 둘을 접이식 상단 슬라이드 패널 하나로 묶었다. 계산 (프론트 전담, 확정 시에만 영구 저장) - `_UI_MassHaul.ts` 신설: 평균단면법으로 구간 토량을 만들고 토량환산계수로 기준을 통일해 누가토량을 누적하는 순수 함수. DOM 의존 0이라 B08/B09가 재사용 가능. - 기준상태는 다짐상태 — 운반거리 산정 시 모든 수량은 다짐상태로 환산하고 내역서 수량은 자연상태로 한다(국도건설공사 설계실무 요령). 내역서용 자연상태 수량은 cut_natural_m3로 함께 보존한다. - 지반유형이 다른 구간은 각 측점이 자기 절반(단면적 × Δd / 2)을 자기 지반유형으로 가져간다. 두 절반의 합이 평균단면법과 정확히 일치한다. - 측점 조작마다 백엔드를 왕복하지 않고 메모리에서 계산하며, 확정 시점에만 longitudinal_sections.data.mass_haul에 저장한다. X축 정렬 - longitudinalMaxChainage()를 공용으로 신설해 종단 렌더러와 유토곡선이 같은 X 매핑을 쓰게 했다. 두 SVG는 chart-wrap 하나를 공유해 가로 스크롤도 함께 움직인다. 패널 높이 - 아래 경계 리사이저로 사용자가 조절한다. 기본값 이상으로 키우면 종단도는 220px을 지키고 늘어난 몫은 유토곡선이 먹으며, 기본값보다 줄이면 둘이 같은 비율로 작아진다. - 종단도가 줄면 Y스케일도 그 높이로 다시 잡아 표고가 잘리지 않게 했다. 토량환산계수 - config_system.py 5-4-3절 신설(유일한 정의처). 표준품셈 암종별 범위를 B06 지반유형 3종에 대응시킨 제안값이며 현장 조정이 전제라 env 없이 상수로 두었다. 검증: tsc 0건, ruff 통과, prettier 적용, 전 파일 700줄 이하. 계산 엔진 테스트 10종 전건 통과(평균단면법·다짐환산·지반유형 배분·비정규 측점 간격·정렬·중복 측점·계수 부재 폴백·페이로드 반올림). 서버 기동 및 번들 포함 확인. Phase 2(평형선·운반토량 Q·평균운반거리 L·장비 배분·M.N)는 장비 거리 경계와 M.N 정의가 미확정이라 제외했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
168 lines
6.5 KiB
TypeScript
168 lines
6.5 KiB
TypeScript
/* =============================================================================
|
|
* B06_wf3_ProfileCross_UI_Section_Common.ts
|
|
* 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티.
|
|
*
|
|
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러
|
|
* (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가
|
|
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
|
|
* ========================================================================== */
|
|
|
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
|
import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design";
|
|
import type {
|
|
DesignProfile,
|
|
LongitudinalSection,
|
|
SectionDetailResponse,
|
|
SectionSample,
|
|
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
|
|
|
export type { CrossDesignChange };
|
|
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
|
|
|
|
export const SVG_NS = "http://www.w3.org/2000/svg";
|
|
export const LONG_WIDTH = 1200;
|
|
export const LONG_HEIGHT = 220;
|
|
export const CROSS_WIDTH = 560;
|
|
export const CROSS_HEIGHT = 250;
|
|
// 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%).
|
|
export const CROSS_GRID_MIN_WIDTH = 560;
|
|
export const CROSS_GRID_GAP = 16;
|
|
export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
|
|
export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 };
|
|
|
|
export interface YScaleOptions {
|
|
pixelsPerMeter: number;
|
|
globalMinElevation: number;
|
|
globalMaxElevation: number;
|
|
}
|
|
|
|
export function L(key: keyof typeof ui_locales): string {
|
|
return ui_locales[key][currentLanguageIndex];
|
|
}
|
|
|
|
export function svgElement<K extends keyof SVGElementTagNameMap>(
|
|
tag: K,
|
|
attributes: Record<string, string | number> = {},
|
|
): SVGElementTagNameMap[K] {
|
|
const element = document.createElementNS(SVG_NS, tag);
|
|
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
|
return element;
|
|
}
|
|
|
|
export function svgText(
|
|
value: string,
|
|
attributes: Record<string, string | number>,
|
|
): SVGTextElement {
|
|
const text = svgElement("text", attributes);
|
|
text.textContent = value;
|
|
return text;
|
|
}
|
|
|
|
export function validElevation(
|
|
sample: SectionSample,
|
|
): sample is SectionSample & { elevation_m: number } {
|
|
return (
|
|
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
|
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
|
*/
|
|
export function designElevationAt(
|
|
designProfiles: DesignProfile[] | undefined,
|
|
chainageM: number,
|
|
): number | undefined {
|
|
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
|
Number.isFinite(sample.elevation_m),
|
|
);
|
|
if (!samples?.length) return undefined;
|
|
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
|
const last = samples[samples.length - 1];
|
|
if (chainageM >= last.chainage_m) return last.elevation_m;
|
|
for (let index = 1; index < samples.length; index += 1) {
|
|
const previous = samples[index - 1];
|
|
const current = samples[index];
|
|
if (chainageM > current.chainage_m) continue;
|
|
const span = current.chainage_m - previous.chainage_m;
|
|
if (span <= 0) return current.elevation_m;
|
|
const ratio = (chainageM - previous.chainage_m) / span;
|
|
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
|
}
|
|
return last.elevation_m;
|
|
}
|
|
|
|
/**
|
|
* 종단 X축이 덮는 누가거리의 최댓값.
|
|
*
|
|
* 종단면도와 유토곡선이 **같은 X 매핑**을 써야 측점 위치가 어긋나지 않으므로,
|
|
* 두 렌더러가 이 함수 하나만 보게 한다(각자 계산하면 조용히 틀어진다).
|
|
*/
|
|
export function longitudinalMaxChainage(data: LongitudinalSection): number {
|
|
const samples = data.samples.filter(validElevation);
|
|
return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1);
|
|
}
|
|
|
|
/**
|
|
* 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로
|
|
* 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이).
|
|
*/
|
|
export function calculateYScale(
|
|
detail: SectionDetailResponse,
|
|
longHeightPx: number = LONG_HEIGHT,
|
|
): YScaleOptions | undefined {
|
|
const elevations = [
|
|
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
|
...detail.cross_sections.flatMap((section) =>
|
|
section.samples.map((sample) => sample.elevation_m),
|
|
),
|
|
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
|
|
profile.samples.map((sample) => sample.elevation_m),
|
|
),
|
|
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
if (!elevations.length) return undefined;
|
|
const globalMinElevation = Math.min(...elevations);
|
|
const globalMaxElevation = Math.max(...elevations);
|
|
const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1);
|
|
return {
|
|
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
|
globalMinElevation,
|
|
globalMaxElevation,
|
|
};
|
|
}
|
|
|
|
export function emptyView(message: string): HTMLElement {
|
|
const empty = document.createElement("div");
|
|
empty.className = "b06-section__empty";
|
|
empty.textContent = message;
|
|
return empty;
|
|
}
|
|
|
|
export function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
|
|
const counts = new Map<number, number>();
|
|
for (let index = 1; index < stations.length; index += 1) {
|
|
const difference = stations[index].chainage_m - stations[index - 1].chainage_m;
|
|
if (difference <= 0) continue;
|
|
const rounded = Math.round(difference * 10) / 10;
|
|
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
|
|
}
|
|
return (
|
|
[...counts.entries()].sort(
|
|
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
|
|
)[0]?.[0] ?? 1
|
|
);
|
|
}
|
|
|
|
export function stationLabel(chainage: number, interval: number): string {
|
|
const safeInterval = interval > 0 ? interval : 1;
|
|
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
|
|
let remainder = chainage - stationNumber * safeInterval;
|
|
if (Math.abs(remainder) < 0.05) remainder = 0;
|
|
if (remainder >= safeInterval - 0.05) {
|
|
stationNumber += 1;
|
|
remainder = 0;
|
|
}
|
|
return `${stationNumber}+${remainder.toFixed(1)}`;
|
|
}
|