This commit is contained in:
2026-07-23 20:09:58 +09:00
parent 05b24aa847
commit e4a744443d
4 changed files with 166 additions and 76 deletions
+32 -6
View File
@@ -9,6 +9,10 @@
* - 측점 ▲ / ▼ : 그 측점을 변화점으로 승격시켜 계획고를 ±step 만큼 꺾는다.
* - 구간 ⇧ / ⇩ : 직선 구간 전체를 평행이동한다(구배 유지, 양 끝 변화점 동시 이동).
* - 원복 ↺ : 그 측점의 편집 델타만 지워 자동 선형으로 되돌린다.
*
* 측점 버튼과 구간 버튼은 **그래프 위·아래 같은 줄**에 놓는다. 안쪽 줄에 두면 X축 제목과
* 측점 라벨에 가려 보이지 않기 때문이다. 자리가 겹칠 땐 측점 버튼을 수직선 위에 고정하고
* 구간 버튼만 옆으로 비킨다.
* ========================================================================== */
import type {
@@ -19,6 +23,9 @@ import type {
import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment";
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 버튼 폭(18px)에 여유를 더한 값 — 이보다 가까우면 겹친 것으로 본다. */
const BUTTON_CLEARANCE_PX = 20;
const BUTTON_HALF_PX = 9;
/** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */
const HOLD_DELAY_MS = 500;
const HOLD_INTERVAL_MS = 100;
@@ -193,6 +200,25 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const repeater = createHoldRepeater();
const edited = new Set(Object.keys(alignment.edits.station_offsets));
const stationXs = alignment.stations.map((station) => x(station.chainage_m));
/**
* 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다.
* 측점 버튼은 측점 수직선 위에 있어야 의미가 통하므로 **측점 쪽을 고정**하고
* 구간 버튼만 오른쪽으로 밀어낸다(구간이 짝수 개 측점을 걸치면 중점이 측점과 겹친다).
*/
function avoidStations(center: number): number {
let nearest = center;
let best = Infinity;
for (const stationX of stationXs) {
const distance = Math.abs(stationX - center);
if (distance < best) {
best = distance;
nearest = stationX;
}
}
return best < BUTTON_CLEARANCE_PX ? nearest + BUTTON_CLEARANCE_PX : center;
}
alignment.stations.forEach((station) => {
const left = x(station.chainage_m);
const isEdited = edited.has(chainageKey(station.chainage_m));
@@ -201,7 +227,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const up = overlayButton(repeater, "is-station is-up", "▲", `${label}${step}m 올림`, () =>
onStation(station.chainage_m, step),
);
up.style.left = `${left - 9}px`;
up.style.left = `${left - BUTTON_HALF_PX}px`;
const down = overlayButton(
repeater,
"is-station is-down",
@@ -209,7 +235,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
`${label}${step}m 내림`,
() => onStation(station.chainage_m, -step),
);
down.style.left = `${left - 9}px`;
down.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(up, down);
if (!isEdited) return;
@@ -221,7 +247,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
() => onResetStation(station.chainage_m),
);
reset.style.left = `${left - 9}px`;
reset.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(reset);
});
@@ -229,14 +255,14 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const left = x(segment.from_m);
const right = x(segment.to_m);
if (right - left < 36) return;
const center = (left + right) / 2;
const center = avoidStations((left + right) / 2);
const label =
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label}${step}m 올림`, () =>
onSegment(segment, step),
);
up.style.left = `${center - 9}px`;
up.style.left = `${center - BUTTON_HALF_PX}px`;
const down = overlayButton(
repeater,
"is-segment is-down",
@@ -244,7 +270,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
`${label}${step}m 내림`,
() => onSegment(segment, -step),
);
down.style.left = `${center - 9}px`;
down.style.left = `${center - BUTTON_HALF_PX}px`;
layer.append(up, down);
});
@@ -146,15 +146,19 @@ function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => nu
}
/**
* 테이블 값이 목표 글자 크기로 잘리지 않으려면 캔버스가 최소 이만큼 넓어야 한다.
* 측점 한 칸의 고정 폭. 목표 글자 크기(12px)로 7자리 값이 잘리지 않는 크기다.
* 화면 폭에 맞춰 늘였다 줄였다 하지 않고 이 값을 유지하고, 남는 가로는 스크롤로 훑는다.
*/
const STATION_COLUMN_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
/**
* 측점 칸 폭을 고정했을 때 필요한 캔버스 폭.
*
* 종단면도 렌더러의 최소 폭(`longitudinalMinimumWidth`)은 그래프 아래 **측점 라벨**만
* 안 겹치면 되는 기준이라 열이 48px밖에 안 돼, 7자리 값(`3000.00`)을 담기엔 좁다.
* 가로 스크롤로 훑는 화면이므로 폭을 늘려 글자 크기를 확보하는 편이 낫다.
*/
function tableMinimumWidth(stationCount: number): number {
const column = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX;
return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * column;
return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * STATION_COLUMN_PX;
}
export function createRouteProfilePanel(
@@ -312,6 +316,8 @@ export function createRouteProfilePanel(
width,
height: tableHeight,
cellWidth: stationCellWidth(longitudinal, x),
// 이름표 열을 그래프 좌측 여백과 같은 폭으로 맞춰야 그래프 시작점이 가려지지 않는다.
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
onCurveRadiusChange: (curve, radius) =>
+92 -55
View File
@@ -27,6 +27,8 @@ export interface ProfileTableOptions {
height: number;
/** 이웃 측점과 겹치지 않는 셀 폭(px). */
cellWidth: number;
/** 행 이름표 열의 폭(px). 그래프의 좌측 여백과 같아야 X축이 맞물린다. */
labelWidth: number;
rowCount: number;
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
x: (chainageM: number) => number;
@@ -35,6 +37,7 @@ export interface ProfileTableOptions {
interface StationRowSpec {
label: string;
unit: string;
cell: (index: number) => string;
modifier?: string;
}
@@ -92,17 +95,23 @@ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
row.append(node);
}
function createRow(className: string, label: string): HTMLElement {
/**
* 행 하나. 이름표는 sticky로 좌측에 고정되며 **그래프의 좌측 여백과 같은 폭**을 쓴다.
* 단위는 이름표를 좁게 유지하려고 툴팁으로 뺐다(넓히면 그래프 시작점을 가린다).
*/
function createRow(className: string, label: string, unit: string): HTMLElement {
const row = element("div", `b05-profile-table__row ${className}`);
row.append(element("span", "b05-profile-table__label", label));
const caption = element("span", "b05-profile-table__label", label);
caption.title = `${label} (${unit})`;
row.append(caption);
return row;
}
/** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */
const SEGMENT_ROWS: SegmentRowSpec[] = [
{ 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) },
{ 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[] {
@@ -110,51 +119,60 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio
return [
{
label: "절토고",
unit: "m",
modifier: "cut",
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
},
{
label: "성토고",
unit: "m",
modifier: "fill",
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
},
{
label: "계획고",
unit: "m",
modifier: "plan",
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
},
{ label: "지반고", cell: (index) => stations[index].ground_elevation_m.toFixed(2) },
{ label: "누가거리", cell: (index) => stations[index].chainage_m.toFixed(2) },
{ label: "거리", cell: (index) => (index ? stations[index].distance_m.toFixed(2) : "") },
{ label: "측점", cell: (index) => stationLabel(stations[index].chainage_m, interval) },
{ 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) => (index ? stations[index].distance_m.toFixed(2) : ""),
},
{
label: "측점",
unit: "측점번호+잔여거리",
cell: (index) => stationLabel(stations[index].chainage_m, interval),
},
];
}
/**
* 구배 3행. 각 블록은 **곡선 구간(BVC~EVC)을 뺀 실제 직선 부분** 덮는다.
* 변화점 자리는 곡선이 차지하므로 그 구간에 구배를 적어 두면 존재하지 않는 직선의
* 값을 읽는 셈이 된다. 값(L/H/S) 자체는 도면 관례대로 변화점 사이 기준이다.
* 구배 3행. 각 블록은 **변화점에서 변화점까지** 덮는다.
*
* 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고,
* 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의
* 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고,
* 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다.
*/
function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
fontPx: number,
): HTMLElement[] {
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})`);
const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit);
alignment.segments.forEach((segment) => {
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;
const left = x(segment.from_m);
const span = x(segment.to_m) - left;
const text = spec.cell(segment);
// 측점을 편집해 구간이 잘게 쪼개지면 글자가 안 들어가 테두리만 남은 빈 칸이 된다.
// 그럴 땐 블록 자체를 만들지 않는다(값은 계획고·거리 행과 그래프에서 읽을 수 있다).
if (!segmentTextFits(text, span, fontPx)) return;
const node = element("span", "b05-profile-table__segment", text);
const node = element("span", "b05-profile-table__segment", "");
// 구간이 짧아 글자가 안 들어가면 숫자만 비운다. 블록을 지우면 그 자리가 다시
// 틈으로 남아 빈 셀처럼 보이므로, 칸은 유지하고 값은 툴팁으로 읽게 한다.
if (segmentTextFits(text, span, fontPx)) node.textContent = text;
node.style.left = `${left}px`;
node.style.width = `${span}px`;
node.title =
@@ -182,61 +200,80 @@ function curveTitle(curve: AlignmentCurve): string {
);
}
/** 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 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 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", curve.l_m.toFixed(2));
lengthCell.title = curveTitle(curve);
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
"곡선 L",
"m 종단곡선 길이 (R × |대수차|)",
);
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R", "m 종단곡선 반경 (입력)");
const curveByChainage = new Map(
alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]),
);
alignment.stations.forEach((station) => {
const lengthCell = element("span", "b05-profile-table__curve");
const radiusCell = element("span", "b05-profile-table__curve");
const input = document.createElement("input");
input.type = "number";
input.step = "1";
input.min = "1";
input.className = "b05-profile-table__radius";
input.value = curve.r_m.toFixed(1);
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);
});
radiusCell.append(input);
const curve = curveByChainage.get(station.chainage_m.toFixed(3));
if (curve) {
lengthCell.textContent = curve.l_m.toFixed(2);
lengthCell.title = curveTitle(curve);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
});
placeCell(lengthRow, x(curve.chainage_m), lengthCell);
placeCell(radiusRow, x(curve.chainage_m), radiusCell);
const input = document.createElement("input");
input.type = "number";
input.step = "1";
input.min = "1";
input.className = "b05-profile-table__radius";
input.value = curve.r_m.toFixed(1);
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);
});
radiusCell.append(input);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
});
}
placeCell(lengthRow, x(station.chainage_m), lengthCell);
placeCell(radiusRow, x(station.chainage_m), radiusCell);
});
return [lengthRow, radiusRow];
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, rowCount, x } = options;
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);
// 이름표는 "누가거리"(한글 4자)가 들어가야 하므로 값 글자보다 크지 않게 제한한다.
const labelFontSize = Math.min(fontSize, Math.floor((labelWidth - 10) / 4));
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.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));
buildStationRows(alignment, stationInterval).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) => {
const value = spec.cell(stationIndex);
if (!value) return;
placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value));
const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex));
placeCell(row, x(station.chainage_m), cell);
});
table.append(row);
});
+32 -11
View File
@@ -119,6 +119,13 @@
height: 100%;
}
/* 축 제목과 측점 라벨은 아래 도면 테이블(측점 행)이 그대로 담고 있어 중복이다.
지우면 그래프 상·하단이 비어 편집 버튼이 가려지지 않고, 좌측 여백도 이름표 열로 쓸 수 있다. */
.b05-route-profile .b06-chart__axis-label,
.b05-route-profile .b06-chart__station-label {
display: none;
}
.b05-route__viewport canvas {
display: block;
width: 100%;
@@ -398,9 +405,10 @@
border-bottom: 0;
}
/* 가로 스크롤 시 값들이 이름표 열을 뚫고 보이지 않도록 가장 위 층에 불투명하게 둔다. */
.b05-profile-table__label {
position: sticky;
z-index: 3;
z-index: 5;
left: 0;
display: inline-flex;
align-items: center;
@@ -411,7 +419,9 @@
border-right: 2px solid var(--color-text-muted, var(--color-plum-velvet));
background: var(--color-surface-raised);
color: var(--color-text);
font-size: var(--b05-table-label-font, inherit);
font-weight: var(--font-weight-medium);
white-space: nowrap;
}
.b05-profile-table__cell,
@@ -426,13 +436,21 @@
overflow: hidden;
}
/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다. */
/* 측점 값 셀은 측점 수직선을 중심으로 좌우 대칭 배치된다.
셀 경계(= 이웃 측점과의 중간)에 세로 구분선을 둬 구배 행과 같은 격자를 만든다. */
.b05-profile-table__cell,
.b05-profile-table__curve {
box-sizing: border-box;
width: var(--b05-table-cell-width, 56px);
border-left: 1px solid var(--color-border);
transform: translateX(-50%);
}
.b05-profile-table__cell:last-child,
.b05-profile-table__curve:last-child {
border-right: 1px solid var(--color-border);
}
.b05-profile-table__row.is-cut .b05-profile-table__cell {
color: rgb(220 38 38);
}
@@ -446,10 +464,15 @@
font-weight: var(--font-weight-medium);
}
/* 구간 블록: 변화점 사이 직선 전체를 덮어 어느 구간의 값인지 한눈에 보이게 한다. */
/* 구간 블록: 변화점 사이를 빈틈없이 채운다.
구분선은 변화점(= 생성된 R의 중심, 측점 수직선) 위에 놓이므로 왼쪽 테두리 하나면
충분하다. 양쪽에 다 주면 맞닿는 자리가 2px로 두꺼워진다. */
.b05-profile-table__segment {
box-sizing: border-box;
border-left: 1px solid var(--color-border);
}
.b05-profile-table__segment:last-child {
border-right: 1px solid var(--color-border);
}
@@ -460,7 +483,6 @@
.b05-profile-table__curve {
z-index: 2;
background: var(--color-surface-raised);
}
.b05-profile-table__curve.is-optional {
@@ -532,17 +554,16 @@
bottom: 2px;
}
.b05-profile-edit__btn.is-segment.is-up {
top: 19px;
}
.b05-profile-edit__btn.is-segment.is-down {
bottom: 19px;
/* 구간 시프트 버튼은 측점 버튼과 같은 줄(is-up/is-down)에 놓이고, 겹치는 자리에서만
렌더러가 좌우로 비켜 배치한다. 구분을 위해 색만 달리한다. */
.b05-route-profile:hover .b05-profile-edit__btn.is-segment {
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 40%, transparent);
color: var(--color-royal-amethyst, rgb(109 40 217));
}
.b05-profile-edit__btn.is-reset,
.b05-route-profile:hover .b05-profile-edit__btn.is-reset {
top: 36px;
top: 20px;
border-color: color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 45%, transparent);
background: var(--color-surface-raised);
color: var(--color-royal-amethyst, rgb(109 40 217));