feat(B05): 횡단을 오른쪽 붙박이 두 판으로, 전후 비교와 손질 열셋

- 메인 창을 왼쪽으로, 오른쪽 세로 칸에 「횡단」·「이전 횡단」 두 판 (계획서 0-9 ⑱)
- 노드를 놓으면 보던 측점을 다시 셈해 새것은 위, 옛것은 아래로 (⑲)
- 측점 표기를 구조물 목록과 같은 규칙으로, 긴 안내문 삭제 (⑳㉑)
- 좌하단에 예상노선·계획노선 길이 둘 (㉒)
- [거리 재기] 단추와 구분선, 잰 값은 작은 창에 내고 닫으면 지움 (㉓㉔)
- 잰 구간을 누가거리로 잘라 꼬임 해소 (㉕)
- 곡선 패널 닫기 단추와 빈 곳 누르기 해제, 하단 정보는 내각만 (㉖㉗)
- 고른 등고선에 높이 라벨, 회전 아이콘을 반원 화살표로 (㉘㉙)
- 돌려도 글자는 바로 세움 — `UPRIGHT_LABELS` 한 값으로 되돌릴 수 있음 (㉚)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
This commit is contained in:
2026-09-12 16:04:04 +09:00
co-authored by Claude Opus 5
parent 3e93360285
commit 1953ea9dc8
12 changed files with 639 additions and 337 deletions
@@ -202,6 +202,9 @@ export interface StationTickOptions {
toScreen: (x: number, y: number) => [number, number]; toScreen: (x: number, y: number) => [number, number];
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
avoidChainages?: ReadonlyArray<number>; avoidChainages?: ReadonlyArray<number>;
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
uprightRad?: number;
} }
export function drawStationTicks( export function drawStationTicks(
@@ -274,11 +277,18 @@ export function drawStationTicks(
); );
if (collides) continue; if (collides) continue;
drawn.push({ x: lx, y: ly, half }); drawn.push({ x: lx, y: ly, half });
context.save();
if (options.uprightRad) {
context.translate(lx, ly);
context.rotate(options.uprightRad);
context.translate(-lx, -ly);
}
// 배경을 깔아 등고선 위에서도 읽히게 한다. // 배경을 깔아 등고선 위에서도 읽히게 한다.
context.fillStyle = "rgba(255, 255, 255, 0.78)"; context.fillStyle = "rgba(255, 255, 255, 0.78)";
context.fillRect(lx - half, ly - 8, width, 16); context.fillRect(lx - half, ly - 8, width, 16);
context.fillStyle = "#222222"; context.fillStyle = "#222222";
context.fillText(label, lx, ly); context.fillText(label, lx, ly);
context.restore();
} }
context.restore(); context.restore();
} }
@@ -534,6 +534,8 @@ export function drawPreparedLabels(
view: ViewState, view: ViewState,
color: string, color: string,
everyM = 25, everyM = 25,
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
uprightRad = 0,
): void { ): void {
const affine = affineOf(view); const affine = affineOf(view);
const margin = CULL_MARGIN; const margin = CULL_MARGIN;
@@ -559,11 +561,19 @@ export function drawPreparedLabels(
continue; continue;
} }
drawn.push({ x, y, half }); drawn.push({ x, y, half });
context.save();
if (uprightRad) {
// 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다.
context.translate(x, y);
context.rotate(uprightRad);
context.translate(-x, -y);
}
context.lineWidth = 3; context.lineWidth = 3;
context.strokeStyle = haloColor(); context.strokeStyle = haloColor();
context.strokeText(feature.labelText, x, y); context.strokeText(feature.labelText, x, y);
context.fillStyle = color; context.fillStyle = color;
context.fillText(feature.labelText, x, y); context.fillText(feature.labelText, x, y);
context.restore();
} }
} }
+58 -54
View File
@@ -46,6 +46,7 @@ import {
} from "./B05_Profile_UI_RouteEdit_Input"; } from "./B05_Profile_UI_RouteEdit_Input";
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome";
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import { import {
@@ -93,54 +94,8 @@ export async function openRouteEditModal(
options: RouteEditOptions = {}, options: RouteEditOptions = {},
): Promise<void> { ): Promise<void> {
const stationIntervalM = options.stationIntervalM ?? 20; const stationIntervalM = options.stationIntervalM ?? 20;
const overlay = document.createElement("div"); const chrome = createRouteEditChrome();
overlay.className = "b05-routeedit"; const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong>계획노선 편집</strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-routeedit__hint">
<span>노드 끌기 = 옮기기</span><span>노드 클릭 = R 라벨</span>
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
<span>측점 눈금 클릭 = 횡단 미리보기</span><span>Shift+클릭 = 거리·기울기</span>
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
</div>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기">↺</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기">↻</button>
</div>
<div class="b05-routeedit__info">
<span class="b05-routeedit__status">노선을 읽는 중…</span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> 예상노선(원본)
<i class="is-planned"></i> 계획노선
</span>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
let expected: Vertex[] = []; let expected: Vertex[] = [];
@@ -177,7 +132,7 @@ export async function openRouteEditModal(
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
const crossPreview = createCrossPreview({ const crossPreview = createCrossPreview({
projectId, projectId,
bounds: () => canvas.getBoundingClientRect(), side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
request: () => ({ request: () => ({
vertices: planned.map(([x, y], index) => ({ vertices: planned.map(([x, y], index) => ({
x, x,
@@ -198,10 +153,36 @@ export async function openRouteEditModal(
toScreen: (vertex) => toScreen(vertex), toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed, isClosed: () => closed,
onChange: () => { onChange: () => {
status.textContent = `${routeHead()}${measure.hint()}`; syncMeasureBox();
draw(); draw();
}, },
}); });
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
function syncMeasureBox(): void {
const on = measure.active();
measureBox.hidden = !on;
measureText.textContent = measure.hint();
if (on && picked >= 0) {
picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다.
syncCurveBar();
}
}
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
let measureMode = false;
measureButton.addEventListener("click", () => {
measureMode = !measureMode;
measureButton.classList.toggle("is-active", measureMode);
if (!measureMode) measure.clear();
});
overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
measureMode = false;
measureButton.classList.remove("is-active");
syncMeasureBox();
draw();
});
let view: ViewState = { let view: ViewState = {
width: 0, width: 0,
height: 0, height: 0,
@@ -226,7 +207,6 @@ export async function openRouteEditModal(
window.removeEventListener("resize", resize); window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
crossPreview.destroy();
overlay.remove(); overlay.remove();
}; };
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
@@ -292,7 +272,8 @@ export async function openRouteEditModal(
pickedContour, pickedContour,
contourStepM: contourStepM(), contourStepM: contourStepM(),
rotationRad: rotation.radians(), rotationRad: rotation.radians(),
measure: measure.points(), uprightRad: rotation.uprightRad(),
measure: measure.marks(),
expected, expected,
plannedLine, plannedLine,
planned, planned,
@@ -355,7 +336,8 @@ export async function openRouteEditModal(
/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로
* 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */
const routeHead = (): string => const routeHead = (): string =>
`길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` +
`계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` +
`노드 ${planned.length}`; `노드 ${planned.length}`;
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
@@ -395,6 +377,11 @@ export async function openRouteEditModal(
}), }),
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)), toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
applyEdit: (message) => applyEdit(message), applyEdit: (message) => applyEdit(message),
onUnselect: () => {
picked = -1;
syncCurveBar();
draw();
},
}); });
const curveLabelBox = curveBar.label; const curveLabelBox = curveBar.label;
const syncCurveBar = curveBar.sync; const syncCurveBar = curveBar.sync;
@@ -441,7 +428,7 @@ export async function openRouteEditModal(
if (event.button !== 0) return; if (event.button !== 0) return;
const rect = canvas.getBoundingClientRect(); const rect = canvas.getBoundingClientRect();
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
if (event.shiftKey) { if (event.shiftKey || measureMode) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py); void measure.pick(px, py);
return; return;
@@ -454,12 +441,26 @@ export async function openRouteEditModal(
dragMoved = false; dragMoved = false;
if (dragNode >= 0) { if (dragNode >= 0) {
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
syncCurveBar(); syncCurveBar();
draw(); draw();
} else if (dragHandle) { } else if (dragHandle) {
picked = dragHandle.node; picked = dragHandle.node;
measure.clear();
syncCurveBar(); syncCurveBar();
draw(); draw();
} else if (
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
((): boolean => {
if (picked >= 0) {
picked = -1;
syncCurveBar();
}
return false;
})()
) {
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
} else if ( } else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => { (() => {
@@ -547,6 +548,9 @@ export async function openRouteEditModal(
if (dragMoved) { if (dragMoved) {
history?.commit(snapshotNow()); history?.commit(snapshotNow());
historyControls.sync(); historyControls.sync();
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void crossPreview.refresh();
} }
dragNode = -1; dragNode = -1;
dragHandle = null; dragHandle = null;
@@ -0,0 +1,98 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
* 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다.
*
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
*
* **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다.
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
* ========================================================================== */
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
const HALF_TURN_ICON = {
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
cw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8a5 5 0 0 1 10 0" /><path d="M13 8 14.8 5.6" /><path d="M13 8 10.6 6.6" /></svg>`,
};
export interface RouteEditChrome {
overlay: HTMLElement;
canvas: HTMLCanvasElement;
status: HTMLElement;
busy: HTMLElement;
measureBox: HTMLElement;
measureText: HTMLElement;
measureButton: HTMLButtonElement;
}
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
export function createRouteEditChrome(): RouteEditChrome {
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" role="dialog" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<strong>계획노선 편집</strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="history-reset"
title="이 창을 연 상태로 되돌립니다 (재계산 없음)" disabled>초기화</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="reset">예상노선으로</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel">취소</button>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply">확인</button>
</span>
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-routeedit__hint">
<span>노드 끌기 = 옮기기</span><span>노드 클릭 = R 라벨</span>
<span>선 두 번 클릭 = 노드 추가</span><span>노드 오른쪽 클릭 = 삭제</span>
<span>측점 눈금 클릭 = 횡단 미리보기</span><span>Shift+클릭 = 거리·기울기</span>
<span>가운데(휠) 버튼 끌기 = 지도 이동</span><span>휠 = 확대</span>
</div>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>
<div class="b05-routeedit__measure" hidden>
<span class="b05-routeedit__measure-text"></span>
<button type="button" class="b05-routeedit__measure-close" aria-label="닫기"
title="닫기">✕</button>
</div>
<div class="b05-routeedit__info">
<span class="b05-routeedit__status">노선을 읽는 중…</span>
<span class="b05-routeedit__legend">
<i class="is-expected"></i> 예상노선(원본)
<i class="is-planned"></i> 계획노선
</span>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__side"></div>`;
document.body.append(overlay);
const canvas = overlay.querySelector<HTMLCanvasElement>(".b05-routeedit__canvas")!;
const status = overlay.querySelector<HTMLElement>(".b05-routeedit__status")!;
const measureBox = overlay.querySelector<HTMLElement>(".b05-routeedit__measure")!;
const measureText = overlay.querySelector<HTMLElement>(".b05-routeedit__measure-text")!;
const measureButton = overlay.querySelector<HTMLButtonElement>('[data-act="measure"]')!;
const busy = overlay.querySelector<HTMLElement>(".b05-routeedit__busy")!;
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
}
+89 -192
View File
@@ -1,30 +1,30 @@
/* ============================================================================= /* =============================================================================
* B05_Profile_UI_RouteEdit_Cross.ts * B05_Profile_UI_RouteEdit_Cross.ts
* 계획노선 편집 중 **측점 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ). * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
*
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
* *
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
* *
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의 * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
* *
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기). * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */ * ========================================================================== */
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw";
/** 그림 가장자리 여백(px). */ import { formatStation } from "./B05_Profile_Util_Station";
const PAD = 28;
export interface CrossPreviewParams { export interface CrossPreviewParams {
projectId: string; projectId: string;
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */ /** 두 판이 들어앉을 오른쪽 세로 칸. */
bounds: () => DOMRect; side: HTMLElement;
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */ /** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
request: () => { request: () => {
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
min_radius_m: number; min_radius_m: number;
@@ -33,202 +33,99 @@ export interface CrossPreviewParams {
} }
export interface CrossPreviewWindow { export interface CrossPreviewWindow {
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */ /** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */
open: (chainageM: number) => Promise<void>; open: (chainageM: number) => Promise<void>;
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
destroy: () => void; refresh: () => Promise<void>;
} }
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { interface CrossPane {
const root = document.createElement("div"); root: HTMLElement;
/** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */
show: (preview: CrossPreviewResponse | null, intervalM: number) => void;
/** 기다리는 중임을 알린다. */
wait: (text: string) => void;
}
function createPane(title: string, empty: string): CrossPane {
const root = document.createElement("section");
root.className = "b05-routeedit__cross"; root.className = "b05-routeedit__cross";
root.hidden = true;
root.innerHTML = ` root.innerHTML = `
<div class="b05-routeedit__cross-head"> <div class="b05-routeedit__cross-head">
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong> <strong class="b05-routeedit__cross-title">${title}</strong>
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기"></button> <span class="b05-routeedit__cross-station"></span>
</div> </div>
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas> <canvas class="b05-routeedit__cross-canvas" width="420" height="240"></canvas>
<div class="b05-routeedit__cross-foot"></div>`; <div class="b05-routeedit__cross-foot">${empty}</div>`;
document.body.append(root); const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!; const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!; const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
const context = canvas.getContext("2d")!; const context = canvas.getContext("2d")!;
return {
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => { root,
root.hidden = true; show(preview, intervalM) {
}); context.clearRect(0, 0, canvas.width, canvas.height);
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다. if (!preview) {
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { station.textContent = "";
root.addEventListener(type, (event) => event.stopPropagation()); foot.textContent = empty;
} return;
}
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ── // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
let dragFrom: { x: number; y: number; left: number; top: number } | null = null; // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
head.addEventListener("pointerdown", (event) => { station.textContent = formatStation(preview.chainage_m, intervalM);
if ((event.target as HTMLElement).closest("button")) return; drawCross(context, canvas, preview);
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop }; foot.textContent = summarizeCross(preview);
head.setPointerCapture(event.pointerId); },
event.preventDefault(); wait(text) {
}); context.clearRect(0, 0, canvas.width, canvas.height);
head.addEventListener("pointermove", (event) => { foot.textContent = text;
if (!dragFrom) return; },
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
});
const stopDrag = (event: PointerEvent): void => {
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
dragFrom = null;
}; };
head.addEventListener("pointerup", stopDrag); }
head.addEventListener("pointercancel", stopDrag);
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */ export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
let asked = -1; const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다.");
const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다.");
params.side.append(current.root, previous.root);
/** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */
let watching: number | null = null;
/** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */
let shown: CrossPreviewResponse | null = null;
/** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */
let ticket = 0;
async function load(chainageM: number, keepPrevious: boolean): Promise<void> {
const mine = ++ticket;
const request = params.request();
if (keepPrevious && shown) previous.show(shown, request.station_interval_m);
current.wait("읽는 중…");
try {
const preview = await fetchCrossPreview(params.projectId, {
...request,
chainage_m: chainageM,
});
if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다.
shown = preview;
current.show(preview, request.station_interval_m);
} catch (error) {
if (mine !== ticket) return;
shown = null;
current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다.");
}
}
return { return {
async open(chainageM) { async open(chainageM) {
asked = chainageM; // 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다.
root.hidden = false; const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6;
if (!root.style.left) { if (!sameStation) previous.show(null, params.request().station_interval_m);
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다. watching = chainageM;
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다. await load(chainageM, sameStation);
const box = params.bounds();
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
}
title.textContent = "횡단 미리보기 — 읽는 중…";
foot.textContent = "";
context.clearRect(0, 0, canvas.width, canvas.height);
let preview: CrossPreviewResponse;
try {
preview = await fetchCrossPreview(params.projectId, {
...params.request(),
chainage_m: chainageM,
});
} catch (error) {
if (asked !== chainageM) return;
title.textContent = "횡단 미리보기";
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
return;
}
if (asked !== chainageM || root.hidden) return;
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
drawCross(context, canvas, preview);
foot.textContent = summarize(preview);
}, },
destroy() { async refresh() {
root.remove(); if (watching === null) return;
await load(watching, true);
}, },
}; };
} }
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
function summarize(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
const label = side === "left" ? "좌" : "우";
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return (
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}` +
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
);
}
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
function drawCross(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse,
): void {
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
const design = (preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as [number, number],
);
const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height);
if (all.length < 2) return;
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
const toScreen = (point: [number, number]): [number, number] => [
canvas.width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale,
];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
if (points.length < 2) return;
context.beginPath();
points.forEach((point, index) => {
const [x, y] = toScreen(point);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "rgba(148,163,184,0.7)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(centerX, PAD / 2);
context.lineTo(centerX, canvas.height - PAD / 2);
context.stroke();
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 6);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 4,
);
}
@@ -0,0 +1,120 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Cross_Draw.ts
* 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약.
*
* `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정).
* 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다.
* ========================================================================== */
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan";
/** 그림 가장자리 여백(px). */
const PAD = 24;
/** 성토사면 길이·절성토 면적 한 줄. */
export function summarizeCross(preview: CrossPreviewResponse): string {
const design = preview.design;
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
const lengths = fillSlopeLengths({
samples: preview.samples,
design,
} as unknown as CrossSection);
const sides = (["left", "right"] as const)
.filter((side) => lengths[side] !== null)
.map((side) => {
const value = lengths[side]!;
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
});
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}`;
}
/**
* 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+offset)가 화면 왼쪽이다
* (`generate_sections` cad_exchange 규약과 같은 방향).
*
* **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
* 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
*/
export function drawCross(
context: CanvasRenderingContext2D,
canvas: HTMLCanvasElement,
preview: CrossPreviewResponse,
): void {
const ground = preview.samples
.filter((sample) => sample.valid && sample.elevation_m !== null)
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
const design = (preview.design?.design_line ?? []).map(
(point) => [point.offset_m, point.elevation_m] as [number, number],
);
const all = [...ground, ...design];
context.clearRect(0, 0, canvas.width, canvas.height);
if (all.length < 2) return;
const offsets = all.map((point) => point[0]);
const heights = all.map((point) => point[1]);
const minOffset = Math.min(...offsets);
const maxOffset = Math.max(...offsets);
const minZ = Math.min(...heights);
const maxZ = Math.max(...heights);
const spanX = maxOffset - minOffset || 1;
const spanZ = maxZ - minZ || 1;
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
const centerOffset = (minOffset + maxOffset) / 2;
const centerZ = (minZ + maxZ) / 2;
const toScreen = (point: [number, number]): [number, number] => [
canvas.width / 2 + (centerOffset - point[0]) * scale,
canvas.height / 2 + (centerZ - point[1]) * scale,
];
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
if (points.length < 2) return;
context.beginPath();
points.forEach((point, index) => {
const [x, y] = toScreen(point);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
context.strokeStyle = color;
context.lineWidth = width;
context.stroke();
};
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
const [centerX] = toScreen([0, centerZ]);
context.save();
context.setLineDash([4, 4]);
context.strokeStyle = "rgba(148,163,184,0.7)";
context.lineWidth = 1;
context.beginPath();
context.moveTo(centerX, PAD / 2);
context.lineTo(centerX, canvas.height - PAD / 2);
context.stroke();
context.restore();
stroke(ground, "#94a3b8", 1.6); // 원지반
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
context.font = "11px system-ui, sans-serif";
context.textBaseline = "top";
context.fillStyle = "#94a3b8";
context.textAlign = "left";
context.fillText("원지반", PAD, 4);
context.fillStyle = "#f97316";
context.textAlign = "right";
context.fillText("기본 계획 횡단", canvas.width - PAD, 4);
context.fillStyle = "#94a3b8";
context.textAlign = "center";
context.textBaseline = "bottom";
context.fillText(
`${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
canvas.width / 2,
canvas.height - 2,
);
}
@@ -41,6 +41,8 @@ export interface CurveBarParams {
toScreen: (vertex: Vertex) => [number, number]; toScreen: (vertex: Vertex) => [number, number];
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
applyEdit: (message: string) => void; applyEdit: (message: string) => void;
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
onUnselect: () => void;
} }
export interface CurveBar { export interface CurveBar {
@@ -51,6 +53,7 @@ export interface CurveBar {
export function createCurveBar(params: CurveBarParams): CurveBar { export function createCurveBar(params: CurveBarParams): CurveBar {
const label = createCurveLabel({ const label = createCurveLabel({
onClose: () => params.onUnselect(),
onRadius: (value) => { onRadius: (value) => {
const { picked, curveRadius } = params.state(); const { picked, curveRadius } = params.state();
if (picked < 0) return; if (picked < 0) return;
+13 -11
View File
@@ -77,6 +77,8 @@ export interface CurveLabelState {
} }
export interface CurveLabelHandlers { export interface CurveLabelHandlers {
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
onClose: () => void;
onRadius: (value: number | null) => void; onRadius: (value: number | null) => void;
onArcLength: (value: number | null) => void; onArcLength: (value: number | null) => void;
onCurveOn: (on: boolean) => void; onCurveOn: (on: boolean) => void;
@@ -114,6 +116,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<div class="b05-routeedit__label-head"> <div class="b05-routeedit__label-head">
<span class="b05-routeedit__curve-label"></span> <span class="b05-routeedit__curve-label"></span>
<button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button> <button type="button" class="b05-routeedit__label-toggle" data-act="curve-toggle"></button>
<button type="button" class="b05-routeedit__label-close" data-act="curve-close"
aria-label="닫기" title="닫기">✕</button>
</div> </div>
<label class="b05-routeedit__curve-field">반지름 <label class="b05-routeedit__curve-field">반지름
<input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" /> <input type="number" class="b05-routeedit__curve-radius" min="1" step="0.5" />
@@ -127,8 +131,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc" <button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button> title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
</label> </label>
<span class="b05-routeedit__curve-info"></span> <span class="b05-routeedit__curve-info"></span>`;
<span class="b05-routeedit__curve-note">칸을 비우면 자동</span>`;
document.body.append(root); document.body.append(root);
const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!; const head = root.querySelector<HTMLElement>(".b05-routeedit__label-head")!;
@@ -171,6 +174,9 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius))); radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius)));
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc))); arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn));
root
.querySelector('[data-act="curve-close"]')!
.addEventListener("click", () => handlers.onClose());
lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius"));
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
@@ -274,16 +280,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
arc.value = arc.value =
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
const inner = state.innerAngleDeg; const inner = state.innerAngleDeg;
const held = // 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; // 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
const floors = [
limitRadius > 0 ? `R ≥ ${limitRadius}m` : "",
limitArc > 0 ? `L ≥ ${limitArc}m` : "",
]
.filter(Boolean)
.join(" · ");
info.textContent = state.curveOn info.textContent = state.curveOn
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}` ? inner
? `내각 ${Math.round(inner)}°`
: ""
: "곡선 없음 — 직선이 그대로 꺾입니다"; : "곡선 없음 — 직선이 그대로 꺾입니다";
place(state); place(state);
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
@@ -37,13 +37,23 @@ export interface MeasureToolParams {
onChange: () => void; onChange: () => void;
} }
export interface MeasureMark {
point: Vertex;
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
chainageM: number;
}
export interface MeasureTool { export interface MeasureTool {
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */ /** 찍힌 자리(0~2개) — 그리기가 쓴다. */
points: () => Vertex[]; marks: () => MeasureMark[];
/** 상태줄에 낼 한 줄. */ /** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
hint: () => string; hint: () => string;
/** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */ /** 재고 있나 — 작은 창을 띄울지 정하는 값. */
active: () => boolean;
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
pick: (px: number, py: number) => Promise<void>; pick: (px: number, py: number) => Promise<void>;
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
clear: () => void;
} }
export function createMeasureTool(params: MeasureToolParams): MeasureTool { export function createMeasureTool(params: MeasureToolParams): MeasureTool {
@@ -51,7 +61,7 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
let picked: MeasurePoint[] = []; let picked: MeasurePoint[] = [];
const hint = (): string => { const hint = (): string => {
if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다."; if (picked.length === 0) return "";
const first = picked[0]; const first = picked[0];
if (picked.length === 1) { if (picked.length === 1) {
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
@@ -73,8 +83,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
}; };
return { return {
points: () => picked.map((entry) => entry.point), marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
hint, hint,
active: () => picked.length > 0,
clear() {
if (picked.length === 0) return;
picked = [];
params.onChange();
},
async pick(px, py) { async pick(px, py) {
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
if (!hit) { if (!hit) {
+89 -24
View File
@@ -15,6 +15,7 @@ import {
drawPreparedLabels, drawPreparedLabels,
drawPreparedLayer, drawPreparedLayer,
layerScreenBounds, layerScreenBounds,
normalizedToScreen,
type PreparedLayer, type PreparedLayer,
type ViewState, type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
@@ -80,10 +81,32 @@ export interface RouteEditScene {
picked: number; picked: number;
/** 규칙 측점 간격(m). */ /** 규칙 측점 간격(m). */
stationIntervalM: number; stationIntervalM: number;
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */ /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
measure: ReadonlyArray<Vertex>; measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */ /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
rotationRad: number; rotationRad: number;
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
uprightRad: number;
}
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
function upright(
context: CanvasRenderingContext2D,
radians: number,
x: number,
y: number,
paint: () => void,
): void {
if (!radians) {
paint();
return;
}
context.save();
context.translate(x, y);
context.rotate(radians);
context.translate(-x, -y);
paint();
context.restore();
} }
export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void {
@@ -137,6 +160,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed"; context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6; context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view); drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
drawPickedContourLabel(context, scene, view);
} }
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③). // 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③).
context.font = "10px system-ui, sans-serif"; context.font = "10px system-ui, sans-serif";
@@ -148,6 +172,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
view, view,
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc", style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
everyM, everyM,
scene.uprightRad,
); );
} }
context.restore(); context.restore();
@@ -259,37 +284,71 @@ function drawMeasureMarks(
context.font = "bold 11px system-ui, sans-serif"; context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
scene.measure.forEach((vertex, index) => { scene.measure.forEach((mark, index) => {
const [x, y] = scene.toScreen(vertex); const [x, y] = scene.toScreen(mark.point);
context.fillStyle = "rgba(255,255,255,0.95)"; context.fillStyle = "rgba(255,255,255,0.95)";
context.beginPath(); context.beginPath();
context.arc(x, y, 7, 0, Math.PI * 2); context.arc(x, y, 7, 0, Math.PI * 2);
context.fill(); context.fill();
context.stroke(); context.stroke();
context.fillStyle = "#14532d"; context.fillStyle = "#14532d";
context.fillText(index === 0 ? "a" : "b", x, y); upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y));
}); });
context.restore(); context.restore();
} }
/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */ /**
function spanBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] { * 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕).
const nearest = (target: Vertex): number => { *
let best = 0; * ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이
let bestDistance = Infinity; * 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로
line.forEach((vertex, index) => { * 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다.
const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]); */
if (distance < bestDistance) { function spanBetween(
bestDistance = distance; line: ReadonlyArray<Vertex>,
best = index; from: { point: Vertex; chainageM: number },
} to: { point: Vertex; chainageM: number },
}); ): Vertex[] {
return best; const low = Math.min(from.chainageM, to.chainageM);
}; const high = Math.max(from.chainageM, to.chainageM);
const start = nearest(from); const head = from.chainageM <= to.chainageM ? from.point : to.point;
const end = nearest(to); const tail = from.chainageM <= to.chainageM ? to.point : from.point;
const [low, high] = start <= end ? [start, end] : [end, start]; const inside: Vertex[] = [];
return [from, ...line.slice(low, high + 1), to]; let travelled = 0;
for (let index = 1; index < line.length; index += 1) {
const step = Math.hypot(
line[index][0] - line[index - 1][0],
line[index][1] - line[index - 1][1],
);
// 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다.
if (travelled > low && travelled < high) inside.push(line[index - 1]);
travelled += step;
}
return [head, ...inside, tail];
}
/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */
function drawPickedContourLabel(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
view: ViewState,
): void {
const feature = scene.contours?.layer.features[scene.pickedContour];
if (!feature || feature.labelValue === null) return;
const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY);
const text = `${feature.labelValue}m`;
upright(context, scene.uprightRad, x, y, () => {
context.save();
context.font = "bold 13px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
const width = context.measureText(text).width + 10;
context.fillStyle = "#7c3aed";
context.fillRect(x - width / 2, y - 9, width, 18);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
context.restore();
});
} }
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ /** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */
@@ -307,11 +366,12 @@ function drawStationMarks(
intervalM: scene.stationIntervalM, intervalM: scene.stationIntervalM,
pxPerMeter: scene.pxPerMeter, pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]), toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
}, },
); );
const total = polylineLengthM(line); const total = polylineLengthM(line);
const last = line.length - 1; const last = line.length - 1;
endLabel(context, scene, line[0], line[1], "시점 0+0.0"); endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`);
endLabel( endLabel(
context, context,
scene, scene,
@@ -339,6 +399,11 @@ function endLabel(
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX; const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX; const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
context.save(); context.save();
if (scene.uprightRad) {
context.translate(x, y);
context.rotate(scene.uprightRad);
context.translate(-x, -y);
}
context.font = "bold 12px system-ui, sans-serif"; context.font = "bold 12px system-ui, sans-serif";
context.textAlign = "center"; context.textAlign = "center";
context.textBaseline = "middle"; context.textBaseline = "middle";
@@ -12,6 +12,15 @@
* 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */ * 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */
const ROTATE_STEP_DEG = 15; const ROTATE_STEP_DEG = 15;
/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시).
*
* 그림만 돌고 숫자는 늘 바로 서게 한다 — 180° 로 돌리면 글자가 뒤집혀 안 읽히기 때문이다.
* 비용은 라벨 하나에 변환 한 번뿐이라 그림을 다시 그리는 값에 묻힌다.
*
* ⚠ **되돌리려면 이 값을 `false` 로만 바꾸면 된다** — 그러면 글자도 그림과 함께 돈다
* (CAD 도면과 같은 방식). 사용자가 화면을 보고 판단할 수 있게 한 자리에 모아 두었다. */
export const UPRIGHT_LABELS = true;
export interface MapRotationParams { export interface MapRotationParams {
/** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */ /** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
overlay: HTMLElement; overlay: HTMLElement;
@@ -30,6 +39,8 @@ export interface MapRotation {
rerotate: (px: number, py: number) => [number, number]; rerotate: (px: number, py: number) => [number, number];
/** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */ /** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
unrotateDelta: (dx: number, dy: number) => [number, number]; unrotateDelta: (dx: number, dy: number) => [number, number];
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
uprightRad: () => number;
} }
export function createMapRotation(params: MapRotationParams): MapRotation { export function createMapRotation(params: MapRotationParams): MapRotation {
@@ -61,6 +72,7 @@ export function createMapRotation(params: MapRotationParams): MapRotation {
radians: () => radians, radians: () => radians,
unrotate: (px, py) => spin(px, py, -radians), unrotate: (px, py) => spin(px, py, -radians),
rerotate: (px, py) => spin(px, py, radians), rerotate: (px, py) => spin(px, py, radians),
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
unrotateDelta: (dx, dy) => { unrotateDelta: (dx, dy) => {
if (!radians) return [dx, dy]; if (!radians) return [dx, dy];
const cos = Math.cos(-radians); const cos = Math.cos(-radians);
+116 -51
View File
@@ -6,16 +6,21 @@
inset: 0; inset: 0;
z-index: var(--z-modal, 1000); z-index: var(--z-modal, 1000);
display: flex; display: flex;
gap: var(--spacing-12);
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: var(--spacing-12);
background: rgb(0 0 0 / 55%); background: rgb(0 0 0 / 55%);
} }
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
.b05-routeedit__box { .b05-routeedit__box {
position: relative; position: relative;
display: flex; display: flex;
flex: 1 1 auto;
flex-direction: column; flex-direction: column;
width: min(1200px, 94vw); max-width: 1200px;
height: min(820px, 92vh); height: min(820px, 92vh);
overflow: hidden; overflow: hidden;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
@@ -142,6 +147,107 @@
border-top: 2px solid var(--map-route, #f97316); border-top: 2px solid var(--map-route, #f97316);
} }
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */
.b05-routeedit__side {
display: flex;
flex: none;
flex-direction: column;
gap: var(--spacing-12);
width: 452px;
height: min(820px, 92vh);
}
@media (width < 1500px) {
/* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */
.b05-routeedit__side {
display: none;
}
}
.b05-routeedit__cross {
display: flex;
flex: 1 1 0;
min-height: 0;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-12);
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-16, 12px);
background: var(--color-surface-raised);
box-shadow: 0 12px 40px rgb(0 0 0 / 45%);
}
.b05-routeedit__cross-head {
display: flex;
flex: none;
align-items: baseline;
gap: var(--spacing-8);
}
.b05-routeedit__cross-station {
color: var(--color-text-secondary);
font-size: var(--text-caption);
}
.b05-routeedit__cross-canvas {
flex: 1 1 auto;
min-height: 0;
width: 100%;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
}
.b05-routeedit__cross-foot {
flex: none;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
.b05-routeedit__divider {
width: 1px;
height: 20px;
margin: 0 var(--spacing-4, 4px);
background: var(--color-border);
}
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: var(--spacing-12);
bottom: var(--spacing-12);
z-index: 1;
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
max-width: 52%;
padding: var(--spacing-8) var(--spacing-12);
border: 1px solid color-mix(in srgb, #22c55e 60%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
color: var(--color-text-body);
font-size: var(--text-caption);
}
/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */
.b05-routeedit__measure-text {
min-width: 0;
line-height: 1.5;
}
.b05-routeedit__measure-close {
flex: none;
border: none;
background: none;
color: var(--color-text-secondary);
cursor: pointer;
}
/* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */ /* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */
.b05-routeedit__busy { .b05-routeedit__busy {
position: absolute; position: absolute;
@@ -189,6 +295,15 @@
touch-action: none; touch-action: none;
} }
.b05-routeedit__label-close {
border: none;
background: none;
color: var(--color-text-secondary);
font-size: 13px;
line-height: 1;
cursor: pointer;
}
/* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ /* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */
.b05-routeedit__lock { .b05-routeedit__lock {
padding: 1px 6px; padding: 1px 6px;
@@ -266,53 +381,3 @@
color: var(--color-text-secondary); color: var(--color-text-secondary);
line-height: 1.35; line-height: 1.35;
} }
/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ────────────────────────────────
곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden`
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */
.b05-routeedit__cross {
position: fixed;
z-index: calc(var(--z-modal, 1000) + 2);
display: flex;
flex-direction: column;
gap: var(--spacing-8, 8px);
width: 452px;
padding: var(--spacing-8);
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface-raised);
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
}
.b05-routeedit__cross-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--spacing-8, 8px);
cursor: move;
touch-action: none;
}
.b05-routeedit__cross-close {
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: transparent;
color: var(--color-text-secondary);
cursor: pointer;
}
.b05-routeedit__cross-canvas {
display: block;
width: 100%;
height: auto;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
}
.b05-routeedit__cross-foot {
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.5;
}