This commit is contained in:
2026-07-24 18:13:11 +09:00
parent 505f28a30b
commit 24acc22a0c
9 changed files with 136 additions and 53 deletions
@@ -75,7 +75,7 @@ export function createIrregularStationsSection(
root.className = "b05-route__panel-section ui-collapsible";
const heading = document.createElement("h3");
heading.className = "ui-collapsible__title";
heading.textContent = "비정규 측점 (구조물)";
heading.textContent = "구조물 배치";
const body = document.createElement("div");
body.className = "b05-route__panel-body";
root.append(heading, body);
+3 -1
View File
@@ -223,6 +223,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
onInputChange: markStale,
onIrregularChange: (stations) => applyIrregularStations(stations),
onStationDisplayChange: (offset) => profilePanel.setStationDisplay(offset),
onIrregularSelect: (station) => {
if (selectionSyncing) return;
selectionSyncing = true;
@@ -309,6 +310,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
function renderSections(detail: SectionDetailResponse, routeId?: number): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined, routeId);
profilePanel.setStationDisplay(panel.stationDisplayOffset());
profilePanel.setIrregularStations(irregularStations);
renderStationLines(detail);
}
@@ -512,7 +514,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
mainContent.className = "b05-route__main";
mainContent.append(viewer.root, profilePanel.root);
const layout = createWorkflowLayout({
title: "노선 설계",
title: "종단 설계",
steps: workflowSteps(),
activeStep: 2,
leftPanel: panel.root,
+28 -1
View File
@@ -62,6 +62,8 @@ interface PanelCallbacks {
onIrregularChange: (stations: IrregularStation[]) => void;
/** 비정규 측점을 목록에서 선택/해제할 때 해당 측점(또는 null). */
onIrregularSelect: (station: IrregularStation | null) => void;
/** 이어 공사 시작 기준(시작 측점·누가거리 시작)이 바뀔 때. */
onStationDisplayChange: (offset: { station: number; cumulative: number }) => void;
}
type WrappedInput = HTMLInputElement & { wrapper: HTMLLabelElement };
@@ -255,7 +257,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
longSampleInterval.wrapper,
);
const gradeLine = section("계획선(시공계획고) 설계");
const gradeLine = section("종단 설계 기준");
const terrainType = document.createElement("select");
terrainType.innerHTML =
'<option value="normal">일반지형</option><option value="special">특수지형</option>';
@@ -284,6 +286,28 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
);
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced);
// 공사 시작점 — 이전 공사에 이어 시공할 때 0측점을 임의 측점/누가거리로 시작 표기한다.
// (내부 chainage는 0기준 유지, 측점 라벨·누가거리 "표시"만 이 값만큼 이동.) 기본값 0/0.
const startBasis = section("공사 시작점");
const startStation = numberField("시작 측점", "0");
startStation.step = "1";
startStation.min = "0";
const startCumulative = numberField("시작 누가거리 (m)", "0");
// 2열×2행: 1행 항목명 · 2행 값 입력 / 1열 시작 측점 · 2열 시작 누가거리.
const startRow = document.createElement("div");
startRow.className = "b05-route__field-row";
startRow.append(startStation.wrapper, startCumulative.wrapper);
startBasis.body.append(startRow);
const stationDisplayOffset = (): { station: number; cumulative: number } => ({
station: Math.max(0, Math.round(Number(startStation.value) || 0)),
cumulative: Number(startCumulative.value) || 0,
});
[startStation, startCumulative].forEach((input) =>
input.addEventListener("change", () =>
callbacks.onStationDisplayChange(stationDisplayOffset()),
),
);
// 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 Page로 올려
// 그래프·테이블·3D에 반영한다. chainage 환산 기준인 측점간격은 실시간 조회한다.
const irregular = createIrregularStationsSection({
@@ -352,6 +376,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
conditions.root,
sectionOptions.root,
gradeLine.root,
startBasis.root,
irregular.root,
result.root,
actionRow,
@@ -366,6 +391,8 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
viewControls,
/** 비정규 측점 섹션 API(목록 조회·선택·초기화). 아직 백엔드로 보내지 않는다(프론트 프리뷰). */
irregularStations: irregular as IrregularStationsSection,
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋) 현재값. */
stationDisplayOffset,
values(): RoutePanelValues {
return {
contourInterval: Number(contourInterval.value) || 1,
@@ -273,6 +273,8 @@ export function createRouteProfilePanel(
let stationInterval: number | undefined;
let routeId: number | null = null;
let irregularStations: IrregularStation[] = [];
// 이어 공사 시작 기준 — 측점번호·누가거리 표시 오프셋(내부 chainage는 0기준 유지).
let stationDisplay = { station: 0, cumulative: 0 };
let base: AlignmentBase | null = null;
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
@@ -432,6 +434,7 @@ export function createRouteProfilePanel(
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
stationDisplay,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
@@ -474,6 +477,7 @@ export function createRouteProfilePanel(
(axis) => {
yAxis = axis;
},
stationDisplay.station,
),
);
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
@@ -576,6 +580,11 @@ export function createRouteProfilePanel(
irregularStations = stations;
draw();
},
/** 이어 공사 시작 기준(측점번호·누가거리 오프셋)을 반영해 측점 라벨·누가거리 표시를 옮긴다. */
setStationDisplay(next: { station: number; cumulative: number }) {
stationDisplay = next;
draw();
},
/**
* 특정 chainage의 계획고 편집(station_offset·curve_radii)을 지운다.
* 비정규 측점을 옮기거나 지울 때 옛 위치에 남는 편집(유령 변화점)을 청소하는 데 쓴다.
+72 -37
View File
@@ -18,11 +18,7 @@ import type {
ProfileAlignment,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
import { irregularStationId, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
@@ -39,8 +35,10 @@ export interface ProfileTableOptions {
x: (chainageM: number) => number;
/** 규칙 격자 밖 비정규 측점(구조물). 선택된 측점만 값 열로 오버레이한다. */
irregularStations?: IrregularStation[];
/** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
/** 현재 선택된 측점 id. 규칙/비정규 측점 모두 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
selectedStationId?: string | null;
/** 이어 공사 시작 기준 — 측점번호·누가거리 표시를 이만큼 더한다(내부 chainage는 0기준 유지). */
stationDisplay?: { station: number; cumulative: number };
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
/** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */
onAdjustStation?: (chainageM: number, deltaM: number) => void;
@@ -149,7 +147,19 @@ const SEGMENT_ROWS: SegmentRowSpec[] = [
{ label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) },
];
function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] {
/** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */
function displayStationLabel(chainageM: number, interval: number, stationOffset: number): string {
const base = stationLabel(chainageM, interval); // "X+YY.Y"
if (!stationOffset) return base;
const [number, remainder] = base.split("+");
return `${Number(number) + stationOffset}+${remainder}`;
}
function buildStationRows(
alignment: ProfileAlignment,
interval: number,
display: { station: number; cumulative: number },
): StationRowSpec[] {
const stations = alignment.stations;
return [
{
@@ -171,7 +181,11 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
},
{ label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) },
{ label: "누가거리", unit: "m", cell: (index) => stations[index].chainage_m.toFixed(2) },
{
label: "누가거리",
unit: "m",
cell: (index) => (stations[index].chainage_m + display.cumulative).toFixed(2),
},
{
label: "거리",
unit: "m 전 측점 대비",
@@ -180,7 +194,7 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio
{
label: "측점",
unit: "측점번호+잔여거리",
cell: (index) => stationLabel(stations[index].chainage_m, interval),
cell: (index) => displayStationLabel(stations[index].chainage_m, interval, display.station),
},
];
}
@@ -292,11 +306,6 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
if (curve.omitted) cell.classList.add("is-omitted");
});
}
// 선택된 규칙 측점 열의 곡선 셀도 함께 하이라이트한다.
if (station.station_id && station.station_id === options.selectedStationId) {
lengthCell.classList.add("is-selected");
radiusCell.classList.add("is-selected");
}
placeCell(lengthRow, centers[index], lengthCell);
placeCell(radiusRow, centers[index], radiusCell);
});
@@ -352,26 +361,27 @@ function interpolateSample(
type CellValue = { text: string } | { left: string; right: string };
/**
* 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고·
* 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에
* 있으므로 여기엔 넣지 않고, 세로 점선도 두지 않는다.
* 선택된 측점(규칙·비정규 공용)의 **값 열 오버레이**. 12행 순서 그대로 값(계획고·지반고·누가거리·
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
*/
function buildIrregularColumn(
station: IrregularStation,
function buildSelectedColumn(
chainage: number,
alignment: ProfileAlignment,
centerX: number,
cellWidth: number,
interval: number,
display: { station: number; cumulative: number },
onAdjustStation?: (chainageM: number, deltaM: number) => void,
): HTMLElement {
const plan = interpolateSample(alignment.samples, station.chainage_m, "elevation_m");
const ground = interpolateSample(alignment.samples, station.chainage_m, "ground_elevation_m");
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
const ground = interpolateSample(alignment.samples, chainage, "ground_elevation_m");
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
const chainage = station.chainage_m;
const inside = alignment.segments.find(
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
);
@@ -384,6 +394,14 @@ function buildIrregularColumn(
return left && right ? { left, right } : { text: left || right || "" };
};
const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
// 거리: 바로 앞 측점(계획선 측점)까지의 간격.
const previous = alignment.stations
.filter((row) => row.chainage_m < chainage - 1e-6)
.reduce<number | null>(
(acc, row) => (acc === null ? row.chainage_m : Math.max(acc, row.chainage_m)),
null,
);
const distance = previous !== null ? chainage - previous : null;
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변).
@@ -395,9 +413,9 @@ function buildIrregularColumn(
{ value: { text: fill !== null ? fill.toFixed(2) : "" }, modifier: "fill" }, // 성토고
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
{ value: { text: station.chainage_m.toFixed(2) }, modifier: "" }, // 누가거리
{ value: { text: "" }, modifier: "" }, // 거리
{ value: { text: stationLabel(station.chainage_m, interval) }, modifier: "" }, // 측점
{ value: { text: (chainage + display.cumulative).toFixed(2) }, modifier: "" }, // 누가거리
{ value: { text: distance !== null ? distance.toFixed(2) : "" }, modifier: "" }, // 거리
{ value: { text: displayStationLabel(chainage, interval, display.station) }, modifier: "" }, // 측점
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
];
@@ -410,7 +428,7 @@ function buildIrregularColumn(
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
);
if (row.plan && onAdjustStation && plan !== null) {
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 규칙 측점과 동일한 station_offset 반영.
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 station_offset 반영(규칙·비정규 공용).
const input = document.createElement("input");
input.type = "number";
input.step = "any";
@@ -419,7 +437,7 @@ function buildIrregularColumn(
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
input.addEventListener("change", () => {
const target = Number.parseFloat(input.value);
if (Number.isFinite(target)) onAdjustStation(station.chainage_m, target - plan);
if (Number.isFinite(target)) onAdjustStation(chainage, target - plan);
});
cell.append(input);
} else if ("left" in row.value) {
@@ -434,12 +452,15 @@ function buildIrregularColumn(
}
column.append(cell);
});
column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`;
column.title = `${displayStationLabel(chainage, interval, display.station)} · ${(
chainage + display.cumulative
).toFixed(2)}m`;
return column;
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
const display = options.stationDisplay ?? { station: 0, cumulative: 0 };
const table = element("div", "b05-profile-table");
const rowHeight = height / Math.max(rowCount, 1);
const fontSize = fitFontSize(rowHeight, cellWidth);
@@ -456,36 +477,50 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`);
table.append(...buildSegmentRows(alignment, x, fontSize, rowHeight));
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
buildStationRows(alignment, stationInterval, display).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
spec.label,
spec.unit,
);
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
alignment.stations.forEach((station, stationIndex) => {
alignment.stations.forEach((_station, stationIndex) => {
const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex));
// 선택된 규칙 측점은 그 열의 값 셀을 하이라이트한다(비정규 측점의 값 열 강조와 통일).
if (station.station_id && station.station_id === options.selectedStationId) {
cell.classList.add("is-selected");
}
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
});
table.append(...buildCurveRows(options, centers));
// 선택된 비정규 측점만 값 열로 오버레이한다(세로 점선·구조물 태그 없음).
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
const selectedIrregular = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
const selectedRegularIndex = options.selectedStationId
? alignment.stations.findIndex((row) => row.station_id === options.selectedStationId)
: -1;
if (selectedIrregular) {
table.append(
buildIrregularColumn(
selectedIrregular,
buildSelectedColumn(
selectedIrregular.chainage_m,
alignment,
x(selectedIrregular.chainage_m),
cellWidth,
stationInterval,
display,
options.onAdjustStation,
),
);
} else if (selectedRegularIndex >= 0) {
table.append(
buildSelectedColumn(
alignment.stations[selectedRegularIndex].chainage_m,
alignment,
centers[selectedRegularIndex],
cellWidth,
stationInterval,
display,
options.onAdjustStation,
),
);
+7 -10
View File
@@ -233,6 +233,13 @@
gap: var(--spacing-8);
}
/* 2열 그리드 — 각 열은 항목명(위)+값 입력(아래). '공사 시작점' 등 나란한 입력에 쓴다. */
.b05-route__field-row {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-8);
}
.b05-route__contour-row .b05-route__field {
flex: 1;
}
@@ -555,16 +562,6 @@
border-right: 1px solid var(--color-border);
}
/* 선택된 규칙 측점 열의 값·곡선 셀 하이라이트(비정규 측점 값 열 강조와 같은 색). */
.b05-profile-table__cell.is-selected,
.b05-profile-table__curve.is-selected {
background: color-mix(
in srgb,
var(--color-royal-amethyst, rgb(109 40 217)) 14%,
var(--color-surface)
);
}
.b05-profile-table__row.is-cut .b05-profile-table__cell {
color: rgb(220 38 38);
}
@@ -21,6 +21,14 @@ import {
type YScaleOptions,
} from "./B06_wf3_ProfileCross_UI_Section_Common";
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string {
const base = stationLabel(chainageM, interval);
if (!stationOffset) return base;
const [number, remainder] = base.split("+");
return `${Number(number) + stationOffset}+${remainder}`;
}
export function longitudinalMinimumWidth(
data: LongitudinalSection,
configuredStationInterval?: number,
@@ -96,6 +104,8 @@ export function createLongitudinalProfile(
* SVG와 ** Y-** . B06은 .
*/
onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
/** 이어 공사 시작 측점 오프셋. 측점 라벨의 측점번호에 이만큼 더한다(B05 표시용, 기본 0). */
stationNumberOffset = 0,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
@@ -215,7 +225,7 @@ export function createLongitudinalProfile(
y2: heightPx - LONG_PAD.bottom + 8,
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
}),
svgText(stationLabel(station.chainage_m, stationInterval), {
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - 23,
"text-anchor": "middle",
+1 -1
View File
@@ -725,7 +725,7 @@ export const ui_locales = {
B05_Route_Solve_Failed: ["경로 탐색에 실패했습니다.", "Route solve failed."],
B05_Route_Confirm_Success: ["경로를 확정했습니다.", "Route confirmed."],
B05_Route_Confirm_Failed: ["경로 확정에 실패했습니다.", "Route confirm failed."],
B05_Route_Group_SectionOptions: ["측점·횡단 옵션", "Station & Cross Options"],
B05_Route_Group_SectionOptions: ["측점 및 샘플링 설정", "Station & Sampling Settings"],
B05_Route_Field_StationInterval: ["측점 간격(m)", "Station interval (m)"],
B05_Route_Field_CrossHalfWidth: ["횡단 반폭(m)", "Cross half-width (m)"],
B05_Route_Field_CrossSample: ["횡단 샘플 간격(m)", "Cross sample interval (m)"],
+4 -1
View File
@@ -346,8 +346,11 @@
.ui-collapsible__title::after {
content: "▾";
flex: 0 0 auto;
margin-block: -0.4em;
color: var(--color-text-muted);
font-size: 0.8em;
/* 레이아웃 허용 범위 내에서 최대한 크게(제목 글자의 약 1.7배). 음수 margin으로 행 높이는 유지. */
font-size: 1.7em;
line-height: 1;
}
.ui-collapsible.is-collapsed > .ui-collapsible__title::after {