This commit is contained in:
2026-07-24 16:50:11 +09:00
parent a0a553fd94
commit 580026a28f
9 changed files with 219 additions and 29 deletions
@@ -27,6 +27,8 @@ export interface IrregularStationsSection {
getStations: () => IrregularStation[];
/** chainage로 목록 항목을 골라 폼에 로드한다(그래프·3D에서 선택 시). null이면 선택 해제. */
selectByChainage: (chainageM: number | null) => void;
/** 외부(백엔드 복귀)에서 목록을 통째로 채운다(측점번호·잔여거리는 chainage로 역산). */
setStations: (seed: Array<{ chainage_m: number; structure: string }>) => void;
clear: () => void;
}
@@ -231,6 +233,24 @@ export function createIrregularStationsSection(
const target = stations.find((entry) => Math.abs(entry.chainage_m - chainageM) < 1e-6);
loadForm(target ?? null);
},
setStations(seed) {
const interval = intervalMax();
stations.length = 0;
seed.forEach((entry) => {
const stationNo = Math.floor((entry.chainage_m + 1e-6) / interval);
const remainder = Number((entry.chainage_m - stationNo * interval).toFixed(3));
stations.push({
id: String(nextId++),
station: stationNo,
remainder,
chainage_m: entry.chainage_m,
structure: entry.structure,
});
});
loadForm(null);
renderList();
callbacks.onChange([...stations]);
},
clear() {
stations.length = 0;
loadForm(null);
+11
View File
@@ -316,6 +316,17 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
}
try {
renderSections(detail, routeId);
// 복귀/최초 진입 시(클라이언트 목록이 비어 있을 때만) 확정된 비정규 측점을 사이드바에 복원한다.
// 재탐색 시엔 새 경로 detail에 비정규 측점이 없어(빈 목록으로 덮이지 않게) 이 가드로 건너뛴다.
if (!irregularStations.length) {
const restored = detail.longitudinal.stations
.filter((station) => station.kind === "irregular")
.map((station) => ({
chainage_m: station.chainage_m,
structure: station.structure ?? "",
}));
if (restored.length) panel.irregularStations.setStations(restored);
}
} catch (error) {
// 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로
// 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다.
+43 -10
View File
@@ -155,6 +155,8 @@ export interface EditOverlayOptions {
width: number;
x: (chainageM: number) => number;
step: number;
/** 규칙 격자 밖 비정규 측점(구조물). 규칙 측점과 똑같이 ▲/▼(+원복) 버튼을 단다. */
irregularStations?: Array<{ chainage_m: number }>;
onStation: (chainageM: number, delta: number) => void;
onSegment: (segment: AlignmentSegment, delta: number) => void;
onResetStation: (chainageM: number) => void;
@@ -191,16 +193,38 @@ function overlayButton(
return button;
}
/** 계획선 샘플에서 chainage 위치의 계획고를 선형보간한다(버튼 라벨용). */
function planElevationAtSample(alignment: ProfileAlignment, chainageM: number): number {
const samples = alignment.samples;
if (!samples.length) return 0;
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 current = samples[index];
if (current.chainage_m < chainageM) continue;
const previous = samples[index - 1];
const span = current.chainage_m - previous.chainage_m;
const ratio = span > 1e-9 ? (chainageM - previous.chainage_m) / span : 0;
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
}
return last.elevation_m;
}
/** 그래프 영역 위에 겹치는 편집 버튼 층을 만든다 (선 자체는 가리지 않는다). */
export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
const { alignment, width, x, step, onStation, onSegment, onResetStation } = options;
const irregular = options.irregularStations ?? [];
const layer = document.createElement("div");
layer.className = "b05-profile-edit";
layer.style.width = `${width}px`;
const repeater = createHoldRepeater();
const edited = new Set(Object.keys(alignment.edits.station_offsets));
const stationXs = alignment.stations.map((station) => x(station.chainage_m));
const stationXs = [
...alignment.stations.map((station) => x(station.chainage_m)),
...irregular.map((station) => x(station.chainage_m)),
];
/**
* 구간 버튼을 측점 버튼과 같은 행에 두되, 겹치는 자리면 옆으로 비킨다.
@@ -219,13 +243,15 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
}
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));
const label = `${station.chainage_m.toFixed(1)}m 계획고 ${station.plan_elevation_m.toFixed(2)}m`;
// 측점 하나에 ▲/▼(+편집됐으면 원복 ↺) 버튼을 단다. 규칙·비정규 측점 공용 — 비정규 측점도
// `onStation`이 임의 chainage를 변화점으로 승격시키므로 규칙 측점과 완전히 같은 파이프라인이다.
function addStationButtons(chainageM: number, planElevationM: number): void {
const left = x(chainageM);
const isEdited = edited.has(chainageKey(chainageM));
const label = `${chainageM.toFixed(1)}m 계획고 ${planElevationM.toFixed(2)}m`;
const up = overlayButton(repeater, "is-station is-up", "▲", `${label}${step}m 올림`, () =>
onStation(station.chainage_m, step),
onStation(chainageM, step),
);
up.style.left = `${left - BUTTON_HALF_PX}px`;
const down = overlayButton(
@@ -233,23 +259,30 @@ export function createEditOverlay(options: EditOverlayOptions): HTMLElement {
"is-station is-down",
"▼",
`${label}${step}m 내림`,
() => onStation(station.chainage_m, -step),
() => onStation(chainageM, -step),
);
down.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(up, down);
if (!isEdited) return;
const offset = alignment.edits.station_offsets[chainageKey(station.chainage_m)];
const offset = alignment.edits.station_offsets[chainageKey(chainageM)];
const reset = overlayButton(
repeater,
"is-reset",
"↺",
`${label} — 자동 선형으로 원복 (현재 ${offset >= 0 ? "+" : ""}${offset.toFixed(2)}m)`,
() => onResetStation(station.chainage_m),
() => onResetStation(chainageM),
);
reset.style.left = `${left - BUTTON_HALF_PX}px`;
layer.append(reset);
});
}
alignment.stations.forEach((station) =>
addStationButtons(station.chainage_m, station.plan_elevation_m),
);
irregular.forEach((station) =>
addStationButtons(station.chainage_m, planElevationAtSample(alignment, station.chainage_m)),
);
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
@@ -176,6 +176,33 @@ function irregularGraphStations(list: IrregularStation[], maxChainage: number):
}));
}
/**
* 가로 스크롤에도 좌측에 고정되는 Y축(표고 눈금) 오버레이.
*
* SVG와 **같은 눈금**(렌더러가 콜백으로 넘김)을 쓰고, 불투명 배경으로 스크롤되는 그래프가
* 새어 보이지 않게 가린다. 레이아웃에 영향을 주지 않도록 0크기 sticky 앵커 위에 축을 절대배치한다.
*/
function buildStickyYAxis(
axis: { padLeft: number; ticks: Array<{ y: number; label: string }> },
chartHeight: number,
): HTMLElement {
const anchor = document.createElement("div");
anchor.className = "b05-profile__yaxis";
const inner = document.createElement("div");
inner.className = "b05-profile__yaxis-inner";
inner.style.width = `${axis.padLeft}px`;
inner.style.height = `${chartHeight}px`;
axis.ticks.forEach(({ y, label }) => {
const tick = document.createElement("span");
tick.className = "b05-profile__yaxis-tick";
tick.style.top = `${y}px`;
tick.textContent = label;
inner.append(tick);
});
anchor.append(inner);
return anchor;
}
/** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */
function chainageMapper(
data: LongitudinalSection,
@@ -401,6 +428,9 @@ export function createRouteProfilePanel(
selectedStationId,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
// 값 열 계획고 직접 입력 → 규칙 측점과 동일한 station_offset 파이프라인.
onAdjustStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
})
: null;
@@ -421,6 +451,7 @@ export function createRouteProfilePanel(
),
}
: graphData;
let yAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
chartWrap.append(
createLongitudinalProfile(
graphLongitudinal,
@@ -434,8 +465,13 @@ export function createRouteProfilePanel(
width,
designProfiles,
originOffset,
(axis) => {
yAxis = axis;
},
),
);
// 가로 스크롤에도 고정되는 sticky Y축 오버레이(SVG와 같은 눈금·불투명 배경으로 값 누출 차단).
if (yAxis) chartWrap.append(buildStickyYAxis(yAxis, chartHeight));
if (alignment) {
chartWrap.append(
createEditOverlay({
@@ -443,6 +479,11 @@ export function createRouteProfilePanel(
width,
x,
step: alignment.policy.edit_step_m,
// 비정규 측점도 규칙 측점처럼 ▲/▼ 버튼으로 계획고 조정(임의 chainage 변화점 승격).
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
onStation: (chainage, delta) =>
base && applyEdits(adjustStation(base, store.edits(), chainage, delta)),
onSegment: (segment, delta) =>
@@ -504,12 +545,18 @@ export function createRouteProfilePanel(
detail = nextDetail;
stationInterval = nextStationInterval;
const stored = readAlignment(nextDetail.longitudinal);
// 재탐색으로 경로(routeId)가 바뀔 때, 사용자가 조작한 편집이 있으면 **새 경로에 이월**한다.
// 편집은 chainage 키라 새 base에 그대로 재적용된다(범위 밖·미매칭 변화점은 best-effort로 드롭).
const routeChanged = nextRouteId !== routeId;
const carried = routeChanged && store.edited() ? store.edits() : null;
// 서버 저장분을 기준으로 삼되, 남아 있는 세션 초안이 있으면 그쪽을 우선한다.
if (nextRouteId !== routeId || !store.dirty()) {
if (routeChanged || !store.dirty()) {
routeId = nextRouteId ?? routeId;
store = createProfileEditStore(routeId, stored?.edits ?? emptyEdits(), () => rebuild());
}
base = stored ? toAlignmentBase(stored) : null;
// 이월분은 base 설정 후 미저장 초안으로 커밋한다(확정 시 전송·재탐색 후 새로고침에도 유지).
if (carried) store.replace(carried);
alignment = base ? buildAlignment(base, store.edits()) : null;
draw();
requestAnimationFrame(draw);
+36 -16
View File
@@ -42,6 +42,8 @@ export interface ProfileTableOptions {
/** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
selectedStationId?: string | null;
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
/** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */
onAdjustStation?: (chainageM: number, deltaM: number) => void;
}
interface StationRowSpec {
@@ -344,35 +346,52 @@ function buildIrregularColumn(
centerX: number,
cellWidth: number,
interval: 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 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;
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
const rows: Array<[string, string]> = [
["", ""], // 구배 L
["", ""], // 구배 H
["", "grade"], // 구배 S
[cut !== null ? cut.toFixed(2) : "", "cut"], // 절토고
[fill !== null ? fill.toFixed(2) : "", "fill"], // 토고
[plan !== null ? plan.toFixed(2) : "", "plan"], // 계획
[ground !== null ? ground.toFixed(2) : "", ""], // 지반
[station.chainage_m.toFixed(2), ""], // 누가거리
["", ""], // 거리
[stationLabel(station.chainage_m, interval), ""], // 측점
["", ""], // 곡선 L
["", ""], // 곡선 R
// `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 column = element("div", "b05-profile-table__irregular-col");
column.style.left = `${centerX}px`;
column.style.width = `${cellWidth}px`;
rows.forEach(([text, modifier]) => {
rows.forEach((row) => {
const cell = element(
"div",
`b05-profile-table__irregular-col-cell${modifier ? ` is-${modifier}` : ""}`,
text,
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
);
if (row.plan && onAdjustStation && plan !== null) {
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 규칙 측점과 동일한 station_offset 반영.
const input = document.createElement("input");
input.type = "number";
input.step = "any";
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
input.value = plan.toFixed(2);
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);
});
cell.append(input);
} else {
cell.textContent = row.text;
}
column.append(cell);
});
column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`;
@@ -422,6 +441,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
x(selectedIrregular.chainage_m),
cellWidth,
stationInterval,
options.onAdjustStation,
),
);
}
+49 -1
View File
@@ -81,6 +81,35 @@
min-height: 0;
}
/* 가로 스크롤에도 고정되는 Y축(표고 눈금). 0크기 sticky 앵커에 축을 절대배치해 레이아웃 불변. */
.b05-profile__yaxis {
position: sticky;
left: 0;
z-index: 3;
width: 0;
height: 0;
}
.b05-profile__yaxis-inner {
position: absolute;
left: 0;
top: 0;
box-sizing: border-box;
/* SVG 배경(b06-chart__bg)과 같은 색으로 스크롤되는 그래프를 가려 값 누출을 막는다. */
background: var(--color-surface-raised);
border-right: 1px solid var(--color-text-secondary);
pointer-events: none;
}
.b05-profile__yaxis-tick {
position: absolute;
right: 6px;
color: var(--color-text-secondary);
font-size: 10px;
white-space: nowrap;
transform: translateY(-50%);
}
.b05-route-profile.is-collapsed .b05-route-profile__body,
.b05-route-profile.is-collapsed .b05-route-profile__balance {
display: none;
@@ -604,7 +633,9 @@
세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */
.b05-profile-table__irregular-col {
position: absolute;
z-index: 6;
/* 값 셀(0)·곡선(2) 위, sticky 행 이름표(5) **아래**로 둔다 — 스크롤로 값 열이 이름표까지 와도
행 제목이 가려지지 않는다. */
z-index: 4;
top: 0;
bottom: 0;
display: flex;
@@ -617,6 +648,7 @@
var(--color-surface)
);
transform: translateX(-50%);
/* 열 자체는 클릭을 통과시키고, 입력 셀만 pointer-events를 되살린다(작업6). */
pointer-events: none;
}
@@ -648,6 +680,22 @@
font-weight: var(--font-weight-medium);
}
/* 값 열의 계획고 직접 입력 셀 — 열은 pointer-events:none이라 입력만 되살린다. */
.b05-profile-table__irregular-input {
box-sizing: border-box;
width: 92%;
height: 88%;
padding: 0;
border: 1px solid var(--color-royal-amethyst, rgb(109 40 217));
border-radius: 2px;
background: var(--color-surface);
color: var(--color-royal-amethyst, rgb(109 40 217));
font: inherit;
font-weight: var(--font-weight-medium);
text-align: center;
pointer-events: auto;
}
/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */
.b05-profile-edit {
position: absolute;
@@ -58,6 +58,8 @@ export interface SectionStation {
label: string;
/** irregular = 사용자가 구조물용으로 추가한 비정규 측점(프론트 주입, 백엔드 미영속). */
kind: "bp" | "ep" | "regular" | "irregular";
/** 비정규 측점의 구조물 설명(백엔드가 확정 시 부여). 복귀 시 사이드바 목록 복원에 쓴다. */
structure?: string;
center_z: number | null;
azimuth_deg: number | null;
center_x: number;
@@ -91,6 +91,11 @@ export function createLongitudinalProfile(
* B05 도면 테이블과 정렬할 때 0측점을 이름표 열 바깥으로 밀어내는 데 쓴다(기본 0).
*/
originOffsetPx = 0,
/**
* Y축(표고 눈금) 정보를 넘겨받는 콜백(선택). B05가 가로 스크롤에도 고정되는 sticky Y축
* 오버레이를 그릴 때 SVG와 **같은 Y-스케일**을 공유하려고 쓴다. B06은 넘기지 않는다.
*/
onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
): HTMLElement {
const samples = data.samples.filter(validElevation);
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
@@ -131,10 +136,12 @@ export function createLongitudinalProfile(
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
const yAxisTicks: Array<{ y: number; label: string }> = [];
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
const gridY = LONG_PAD.top + ratio * plotHeight;
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
yAxisTicks.push({ y: gridY, label: `${rawValue.toFixed(1)}m` });
svg.append(
svgElement("line", {
x1: LONG_PAD.left,
@@ -151,6 +158,8 @@ export function createLongitudinalProfile(
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
+1 -1
View File
@@ -112,7 +112,7 @@ def serve_frontend_dev() -> None:
logger.info("[Frontend] npm run dev 시작 (백그라운드)...")
try:
subprocess.Popen(
"npm run dev",
"npm run dev -- --host",
shell=True,
cwd=str(root_dir),
env=_frontend_process_env(root_dir),