This commit is contained in:
2026-07-23 19:09:38 +09:00
parent 61a86d806e
commit e52823294f
4 changed files with 216 additions and 128 deletions
+69 -9
View File
@@ -19,6 +19,43 @@ import type {
import { chainageKey, emptyEdits, hasEdits } from "./B05_wf2_Route_UI_Profile_Alignment";
const DRAFT_KEY_PREFIX = "b05-profile-alignment-draft";
/** 길게 누르기: 이만큼 유지하면 반복이 시작되고, 그 뒤 초당 10회(0.1m씩)로 이어진다. */
const HOLD_DELAY_MS = 500;
const HOLD_INTERVAL_MS = 100;
/**
* 길게 누르는 동안 같은 동작을 반복한다.
*
* 버튼은 편집 한 번마다 다시 그려져 DOM에서 사라지므로, 타이머와 종료 감지를
* 버튼이 아니라 이 컨트롤러가 window 이벤트로 들고 있어야 반복이 끊기거나
* 반대로 멈추지 않는 상황이 생기지 않는다.
*/
function createHoldRepeater(): { start: (action: () => void) => void; stop: () => void } {
let delayTimer = 0;
let repeatTimer = 0;
function stop(): void {
window.clearTimeout(delayTimer);
window.clearInterval(repeatTimer);
delayTimer = 0;
repeatTimer = 0;
window.removeEventListener("pointerup", stop);
window.removeEventListener("pointercancel", stop);
window.removeEventListener("blur", stop);
}
function start(action: () => void): void {
stop();
delayTimer = window.setTimeout(() => {
repeatTimer = window.setInterval(action, HOLD_INTERVAL_MS);
}, HOLD_DELAY_MS);
window.addEventListener("pointerup", stop);
window.addEventListener("pointercancel", stop);
window.addEventListener("blur", stop);
}
return { start, stop };
}
export interface ProfileEditStore {
edits(): AlignmentEdits;
@@ -117,19 +154,32 @@ export interface EditOverlayOptions {
}
function overlayButton(
repeater: { start: (action: () => void) => void },
className: string,
glyph: string,
title: string,
onClick: () => void,
onAction: () => void,
): HTMLButtonElement {
const button = document.createElement("button");
button.type = "button";
button.className = `b05-profile-edit__btn ${className}`;
button.textContent = glyph;
button.title = title;
button.title = `${title}\n(길게 누르면 0.5초 뒤부터 연속 조정)`;
// 포인터로 누르면 즉시 1회 반응하고, 이어지는 click은 중복이므로 삼킨다.
let swallowClick = false;
button.addEventListener("pointerdown", (event) => {
event.stopPropagation();
swallowClick = true;
onAction();
repeater.start(onAction);
});
button.addEventListener("click", (event) => {
event.stopPropagation();
onClick();
if (swallowClick) {
swallowClick = false;
return;
}
onAction();
});
return button;
}
@@ -141,18 +191,23 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
layer.className = "b05-profile-edit";
layer.style.width = `${width}px`;
const repeater = createHoldRepeater();
const edited = new Set(Object.keys(alignment.edits.station_offsets));
alignment.stations.forEach((station) => {
const left = x(station.chainage_m);
const isEdited = edited.has(chainageKey(station.chainage_m));
const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`;
const up = overlayButton("is-station is-up", "▲", `${label}${step}m 올림`, () =>
const up = overlayButton(repeater, "is-station is-up", "▲", `${label}${step}m 올림`, () =>
onStation(station.chainage_m, step),
);
up.style.left = `${left - 9}px`;
const down = overlayButton("is-station is-down", "▼", `${label}${step}m 내림`, () =>
onStation(station.chainage_m, -step),
const down = overlayButton(
repeater,
"is-station is-down",
"▼",
`${label}${step}m 내림`,
() => onStation(station.chainage_m, -step),
);
down.style.left = `${left - 9}px`;
layer.append(up, down);
@@ -160,6 +215,7 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
if (!isEdited) return;
const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)];
const reset = overlayButton(
repeater,
"is-reset",
"↺",
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
@@ -177,12 +233,16 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const label =
`구간 ${segment.from_m.toFixed(0)}~${segment.to_m.toFixed(0)}m ` +
`(구배 ${segment.grade_percent.toFixed(2)}%) 전체 평행이동`;
const up = overlayButton("is-segment is-up", "⇧", `${label}${step}m 올림`, () =>
const up = overlayButton(repeater, "is-segment is-up", "⇧", `${label}${step}m 올림`, () =>
onSegment(segment, step),
);
up.style.left = `${center - 9}px`;
const down = overlayButton("is-segment is-down", "⇩", `${label}${step}m 내림`, () =>
onSegment(segment, -step),
const down = overlayButton(
repeater,
"is-segment is-down",
"⇩",
`${label}${step}m 내림`,
() => onSegment(segment, -step),
);
down.style.left = `${center - 9}px`;
layer.append(up, down);
@@ -41,7 +41,14 @@ 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;
function readAlignment(data: LongitudinalSection): ProfileAlignment | null {
const candidate = data.profile_alignment as ProfileAlignment | undefined;
@@ -133,6 +140,7 @@ export function createRouteProfilePanel(
let alignment: ProfileAlignment | null = null;
let store = createProfileEditStore(null, emptyEdits(), () => rebuild());
let resizeTimer = 0;
let redrawPending = false;
let lastWidth = 0;
let lastHeight = 0;
@@ -205,15 +213,26 @@ export function createRouteProfilePanel(
store.replace(next);
}
/**
* 편집 후 다시 그린다. 길게 누르기(초당 10회)로 연속 호출되므로 한 프레임에 한 번만
* 실제 렌더링하도록 모은다.
*/
function rebuild(): void {
if (base) alignment = buildAlignment(base, store.edits());
draw();
if (redrawPending) return;
redrawPending = true;
requestAnimationFrame(() => {
redrawPending = false;
draw();
});
}
function draw(): void {
if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return;
lastWidth = body.clientWidth;
lastHeight = body.clientHeight;
// 편집할 때마다 본문을 갈아끼우므로 보고 있던 가로 위치를 잃지 않게 되돌린다.
const scrollLeft = body.scrollLeft;
renderBalance();
const longitudinal = detail.longitudinal;
@@ -223,7 +242,6 @@ export function createRouteProfilePanel(
canvas.className = "b05-profile__canvas";
canvas.style.width = `${width}px`;
// 테이블을 먼저 붙여 실제 높이를 재고, 남는 공간 전부를 그래프에 준다.
const x = chainageMapper(longitudinal, width);
const table = alignment
? createProfileTable({
@@ -235,10 +253,12 @@ export function createRouteProfilePanel(
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
})
: null;
body.replaceChildren(canvas);
if (table) canvas.append(table);
const available = body.clientHeight - HORIZONTAL_SCROLLBAR_HEIGHT - (table?.offsetHeight ?? 0);
const chartHeight = Math.max(MIN_CHART_HEIGHT, available);
// 그래프는 고정 높이를 지키고 남는 세로 공간은 전부 테이블이 쓴다(행 높이가 늘어난다).
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";
@@ -275,7 +295,10 @@ export function createRouteProfilePanel(
}),
);
}
canvas.prepend(chartWrap);
canvas.append(chartWrap);
if (table) canvas.append(table);
body.replaceChildren(canvas);
body.scrollLeft = scrollLeft;
}
const resizeObserver = new ResizeObserver(() => {
+100 -91
View File
@@ -1,10 +1,13 @@
/* =============================================================================
* B05_wf2_Route_UI_Profile_Table.ts
* 종단면도 하단 도면 테이블 (구배·절토고·성토고·계획고·지반고·누가거리·거리·측점·곡선).
* 종단면도 하단 도면 테이블 (구배 3행 · 측점값 7행 · 곡선 2행 = 12행).
*
* 실무 종단면도 좌측 하단 표를 그대로 옮긴 9행 구성이다. 셀은 종단면도 그래프와
* **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 정확히 맞물린다. 마지막 곡선 행의
* R만 입력 가능하고 나머지는 전부 파생값이다.
* 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라
* 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다.
* - 구배: `L=` / `H=` / `S=` 3행 (S행에 변화점 중앙종거를 함께 얹는다)
* - 곡선: `L=` / `R=` 2행 (R만 입력 가능, 고치면 L을 역산한다)
*
* 셀은 종단면도 그래프와 **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 맞물린다.
* ========================================================================== */
import type {
@@ -23,15 +26,20 @@ export interface ProfileTableOptions {
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
}
interface RowSpec {
key: string;
interface StationRowSpec {
label: string;
/** 측점마다 한 칸씩 채우는 행. */
cell?: (index: number) => string;
cell: (index: number) => string;
modifier?: string;
}
interface SegmentRowSpec {
label: string;
cell: (segment: AlignmentSegment) => string;
}
const CELL_WIDTH = 56;
/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */
const MIN_SEGMENT_WIDTH = 32;
function element(tag: string, className: string, text?: string): HTMLElement {
const node = document.createElement(tag);
@@ -47,118 +55,124 @@ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void {
row.append(node);
}
function buildStationRows(alignment: ProfileAlignment, interval: number): RowSpec[] {
function createRow(className: string, label: string): HTMLElement {
const row = element("div", `b05-profile-table__row ${className}`);
row.append(element("span", "b05-profile-table__label", label));
return row;
}
/** 도면의 구배 블록 3행: 구간 연장 / 고저차 / 기울기. */
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)}%` },
];
function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] {
const stations = alignment.stations;
return [
{
key: "cut",
label: "절토고",
modifier: "cut",
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
},
{
key: "fill",
label: "성토고",
modifier: "fill",
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
},
{
key: "plan",
label: "계획고",
modifier: "plan",
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
},
{
key: "ground",
label: "지반고",
cell: (index) => stations[index].ground_elevation_m.toFixed(2),
},
{
key: "cumulative",
label: "누가거리",
cell: (index) => stations[index].chainage_m.toFixed(2),
},
{
key: "distance",
label: "거리",
cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""),
},
{
key: "station",
label: "측점",
cell: (index) => stationLabel(stations[index].chainage_m, interval),
},
{ 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) },
];
}
/**
* 구배 행: 구간 중앙에 `L / H / S`, 변화점에 중앙종거를 얹는다.
* 도면 구배 행에 찍히는 소수값(0.56, -0.75 …)이 이 중앙종거다 (|A|·L/8).
* 구배 3행. 각 행은 변화점 사이 구간 전체를 덮는 블록으로 그린다.
* S행에는 변화점 중앙종거를 함께 얹는다 — 도면 구배 행 소수값(0.56, -0.75 …)이 이 값이다(|A|·L/8).
*/
function buildGradeRow(alignment: ProfileAlignment, x: (chainage: number) => number): HTMLElement {
const row = element("div", "b05-profile-table__row b05-profile-table__row--grade");
row.append(element("span", "b05-profile-table__label", "구배"));
alignment.segments.forEach((segment: AlignmentSegment) => {
const center = (x(segment.from_m) + x(segment.to_m)) / 2;
const span = Math.abs(x(segment.to_m) - x(segment.from_m));
if (span < 40) return;
const node = element("span", "b05-profile-table__segment");
node.append(
element("em", "b05-profile-table__segment-line", `L=${segment.length_m.toFixed(2)}m`),
element(
"em",
`b05-profile-table__segment-line is-${segment.height_m >= 0 ? "up" : "down"}`,
`H=${segment.height_m.toFixed(2)}m S=${segment.grade_percent.toFixed(2)}%`,
),
);
node.style.left = `${center - span / 2}px`;
node.style.width = `${span}px`;
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)}%를 초과합니다.`;
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);
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
const span = Math.abs(x(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`;
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)}%를 초과합니다.`;
}
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);
});
}
row.append(node);
return row;
});
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;
}
/** 곡선 행: 변화점마다 `L=` 표기와 R 입력 칸을 둔다 (R을 고치면 L을 역산). */
function buildCurveRow(options: ProfileTableOptions): HTMLElement {
function curveTitle(curve: AlignmentCurve): string {
return (
`K=${curve.k.toFixed(2)} A=${curve.delta_pct.toFixed(2)}%\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(입력). */
function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
const { alignment, x, onCurveRadiusChange } = options;
const row = element("div", "b05-profile-table__row b05-profile-table__row--curve");
row.append(element("span", "b05-profile-table__label", "곡선"));
const lengthRow = createRow("b05-profile-table__row--curve", "곡선 L");
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R");
alignment.curves.forEach((curve) => {
const cell = element("span", "b05-profile-table__curve");
const length = element("em", "b05-profile-table__curve-length", `L=${curve.l_m.toFixed(2)}`);
const lengthCell = element("span", "b05-profile-table__curve", `L=${curve.l_m.toFixed(2)}`);
lengthCell.title = curveTitle(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 = R × |A| 로 역산합니다.\n` +
`K=${curve.k.toFixed(2)} BVC=${curve.bvc_m.toFixed(1)}m EVC=${curve.evc_m.toFixed(1)}m` +
(curve.omit_reason ? `\n${curve.omit_reason}` : "");
input.title = `종단곡선 반경 R (m) — 수정하면 L = R × |A| 로 역산합니다.\n${curveTitle(curve)}`;
input.addEventListener("change", () => {
const parsed = Number.parseFloat(input.value);
onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null);
});
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
cell.append(length, input);
placeCell(row, x(curve.chainage_m), cell);
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(curve.chainage_m), lengthCell);
placeCell(radiusRow, x(curve.chainage_m), radiusCell);
});
return row;
return [lengthRow, radiusRow];
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
@@ -166,21 +180,16 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const table = element("div", "b05-profile-table");
table.style.width = `${width}px`;
table.append(buildGradeRow(alignment, x));
table.append(...buildSegmentRows(alignment, x));
buildStationRows(alignment, stationInterval).forEach((spec) => {
const row = element(
"div",
`b05-profile-table__row${spec.modifier ? ` is-${spec.modifier}` : ""}`,
);
row.append(element("span", "b05-profile-table__label", spec.label));
const row = createRow(spec.modifier ? `is-${spec.modifier}` : "", spec.label);
alignment.stations.forEach((station, index) => {
const value = spec.cell ? spec.cell(index) : "";
const value = spec.cell(index);
if (!value) return;
const cell = element("span", "b05-profile-table__cell", value);
placeCell(row, x(station.chainage_m), cell);
placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value));
});
table.append(row);
});
table.append(buildCurveRow(options));
table.append(...buildCurveRows(options));
return table;
}
+17 -21
View File
@@ -34,12 +34,13 @@
background: var(--color-surface);
}
/* 하단 종단 패널: 그래프 + 도면 테이블을 담기 위해 화면 높이의 40%를 차지한다. */
/* 하단 종단 패널: 그래프 + 12행 도면 테이블을 담기 위해 화면 높이의 60%를 차지한다.
접기 핸들로 언제든 내릴 수 있어 3D 뷰를 전체 영역으로 볼 수 있다. */
.b05-route-profile {
position: relative;
display: flex;
flex: 0 0 40vh;
flex: 0 0 40dvh;
flex: 0 0 60vh;
flex: 0 0 60dvh;
flex-direction: column;
min-height: 0;
overflow: visible;
@@ -336,7 +337,10 @@
행 이름표만 sticky로 좌측에 고정되어 가로 스크롤에도 계속 보인다. */
.b05-profile-table {
position: relative;
flex: 0 0 auto;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
border-top: 1px solid var(--color-border);
color: var(--color-text-body);
font-size: 10px;
@@ -344,19 +348,20 @@
user-select: none;
}
/* 12개 행이 남는 세로 공간을 균등하게 나눠 갖는다 (패널을 키우면 행이 두꺼워진다). */
.b05-profile-table__row {
position: relative;
height: 16px;
flex: 1 1 0;
min-height: 14px;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 60%, transparent);
white-space: nowrap;
}
.b05-profile-table__row--grade {
height: 28px;
background: color-mix(in srgb, var(--color-surface) 40%, transparent);
}
.b05-profile-table__row--curve {
height: 24px;
.b05-profile-table__row--curve:last-child {
border-bottom: 0;
}
@@ -402,18 +407,13 @@
font-weight: var(--font-weight-medium);
}
/* 구간 블록: 변화점 사이 직선 전체를 덮어 어느 구간의 값인지 한눈에 보이게 한다. */
.b05-profile-table__segment {
flex-direction: column;
justify-content: center;
gap: 1px;
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);
}
.b05-profile-table__segment-line {
font-style: normal;
}
.b05-profile-table__segment.is-violation {
background: color-mix(in srgb, rgb(220 38 38) 12%, transparent);
color: rgb(180 83 9);
@@ -426,8 +426,8 @@
}
.b05-profile-table__curve {
flex-direction: column;
gap: 1px;
z-index: 2;
background: var(--color-surface-raised);
}
.b05-profile-table__curve.is-optional {
@@ -438,10 +438,6 @@
text-decoration: line-through;
}
.b05-profile-table__curve-length {
font-style: normal;
}
.b05-profile-table__radius {
box-sizing: border-box;
width: 100%;