This commit is contained in:
2026-07-24 17:21:25 +09:00
parent 2d39b5127a
commit 5c8c0462f9
5 changed files with 117 additions and 29 deletions
@@ -184,9 +184,19 @@ export function createIrregularStationsSection(
const structure = structureField.value.trim();
const chainage_m = chainageOf(station, safeRemainder);
if (editingId) {
const target = stations.find((entry) => entry.id === editingId);
if (target)
Object.assign(target, { station, remainder: safeRemainder, chainage_m, structure });
// 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어
// "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다.
// 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다.
const index = stations.findIndex((entry) => entry.id === editingId);
if (index >= 0) {
stations[index] = {
id: editingId,
station,
remainder: safeRemainder,
chainage_m,
structure,
};
}
} else {
stations.push({
id: String(nextId++),
+9 -4
View File
@@ -169,10 +169,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
const activeProjectId: string = projectId;
const viewer = createRouteViewer();
const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
});
const profilePanel = createRouteProfilePanel(
activeProjectId,
(stationId) => {
viewer.markers.selectStation(stationId);
syncIrregularSelection(stationId);
},
// [초기선 복원] 시 추가한 비정규 측점도 함께 지운다.
() => panel.irregularStations.clear(),
);
/**
* 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다.
@@ -252,6 +252,8 @@ function computeProfileLayout(
export function createRouteProfilePanel(
projectId: string,
onSelectStation: (stationId: string) => void,
/** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */
onResetAll?: () => void,
) {
const root = document.createElement("section");
root.className = "b05-route-profile";
@@ -323,13 +325,16 @@ export function createRouteProfilePanel(
.join("\n");
balanceBar.append(warning);
}
if (store.edited()) {
if (store.edited() || irregularStations.length) {
const reset = document.createElement("button");
reset.type = "button";
reset.className = "b05-route-profile__balance-reset";
reset.textContent = "초기선 복원";
reset.title = "모든 편집을 지우고 자동 산출된 계획선으로 되돌립니다.";
reset.addEventListener("click", () => store.resetAll());
reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다.";
reset.addEventListener("click", () => {
store.resetAll();
onResetAll?.();
});
balanceBar.append(reset);
}
if (store.dirty()) {
+55 -19
View File
@@ -197,6 +197,7 @@ function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
fontPx: number,
rowHeight: number,
): HTMLElement[] {
return SEGMENT_ROWS.map((spec) => {
const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit);
@@ -205,9 +206,16 @@ function buildSegmentRows(
const span = x(segment.to_m) - left;
const text = spec.cell(segment);
const node = element("span", "b05-profile-table__segment", "");
// 구간이 짧아 글자가 안 들어가면 숫자만 비운다. 블록을 지우면 그 자리가 다시
// 틈으로 남아 빈 셀처럼 보이므로, 칸은 유지하고 값은 툴팁으로 읽게 한다.
if (segmentTextFits(text, span, fontPx)) node.textContent = text;
// 값은 항상 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰
// 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다).
const value = element("span", "b05-profile-table__segment-value", text);
if (!segmentTextFits(text, span, fontPx)) {
value.classList.add("is-rotated");
const byThickness = Math.max(1, span - 2);
const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontPx;
value.style.fontSize = `${Math.max(6, Math.min(fontPx, byThickness, byLength))}px`;
}
node.append(value);
node.style.left = `${left}px`;
node.style.width = `${span}px`;
node.title =
@@ -335,6 +343,9 @@ function interpolateSample(
return last[key];
}
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
type CellValue = { text: string } | { left: string; right: string };
/**
* 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고·
* 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에
@@ -352,21 +363,38 @@ function buildIrregularColumn(
const ground = interpolateSample(alignment.samples, station.chainage_m, "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,
);
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
if (inside) return { text: pick(inside).toFixed(digits) };
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
return left && right ? { left, right } : { text: left || right || "" };
};
const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변).
const rows: Array<{ text: string; modifier: string; plan?: boolean }> = [
{ text: "", modifier: "" }, // 구배 L
{ text: "", modifier: "" }, // 구배 H
{ text: "", modifier: "grade" }, // 구배 S
{ text: cut !== null ? cut.toFixed(2) : "", modifier: "cut" }, // 절토고
{ text: fill !== null ? fill.toFixed(2) : "", modifier: "fill" }, // 성토고
{ text: plan !== null ? plan.toFixed(2) : "", modifier: "plan", plan: true }, // 계획고
{ text: ground !== null ? ground.toFixed(2) : "", modifier: "" }, // 지반고
{ text: station.chainage_m.toFixed(2), modifier: "" }, // 누가거리
{ text: "", modifier: "" }, // 거리
{ text: stationLabel(station.chainage_m, interval), modifier: "" }, // 측점
{ text: "", modifier: "" }, // 곡선 L
{ text: "", modifier: "" }, // 곡선 R
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
{ value: gradeCell((segment) => segment.length_m, 2), modifier: "" }, // 구배 L
{ value: gradeCell((segment) => segment.height_m, 2), modifier: "" }, // 구배 H
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
{ value: { text: cut !== null ? cut.toFixed(2) : "" }, modifier: "cut" }, // 절토고
{ 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: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
];
const column = element("div", "b05-profile-table__irregular-col");
column.style.left = `${centerX}px`;
@@ -389,8 +417,15 @@ function buildIrregularColumn(
if (Number.isFinite(target)) onAdjustStation(station.chainage_m, target - plan);
});
cell.append(input);
} else if ("left" in row.value) {
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
cell.classList.add("is-split");
cell.append(
element("span", "b05-profile-table__irregular-col-half", row.value.left),
element("span", "b05-profile-table__irregular-col-half", row.value.right),
);
} else {
cell.textContent = row.text;
cell.textContent = row.value.text;
}
column.append(cell);
});
@@ -401,7 +436,8 @@ function buildIrregularColumn(
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth);
const rowHeight = height / Math.max(rowCount, 1);
const fontSize = fitFontSize(rowHeight, cellWidth);
// 종점이 앞 측점과 겹치면 오른쪽으로 민 셀 중심(측점 값·곡선 셀 공용). 구배 블록은 구간을
// 덮는 넓은 범위라 겹치지 않으므로 x를 그대로 쓴다.
const centers = stationCellCenters(alignment.stations, x, cellWidth, width);
@@ -414,7 +450,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.style.setProperty("--b05-table-label-width", `${labelWidth}px`);
table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`);
table.append(...buildSegmentRows(alignment, x, fontSize));
table.append(...buildSegmentRows(alignment, x, fontSize, rowHeight));
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
+32
View File
@@ -607,6 +607,16 @@
color: rgb(180 83 9);
}
/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */
.b05-profile-table__segment-value {
line-height: 1;
white-space: nowrap;
}
.b05-profile-table__segment-value.is-rotated {
transform: rotate(-90deg);
}
.b05-profile-table__curve {
z-index: 2;
}
@@ -674,9 +684,31 @@
align-items: center;
justify-content: center;
min-height: 0;
overflow: hidden;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
color: var(--color-text);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */
.b05-profile-table__irregular-col-cell.is-split {
gap: 0;
}
.b05-profile-table__irregular-col-half {
display: flex;
flex: 1 1 0;
align-items: center;
justify-content: center;
min-width: 0;
height: 100%;
overflow: hidden;
font-size: 0.72em;
}
.b05-profile-table__irregular-col-half:first-child {
border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent);
}
.b05-profile-table__irregular-col-cell:last-child {