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:
@@ -202,6 +202,9 @@ export interface StationTickOptions {
|
||||
toScreen: (x: number, y: number) => [number, number];
|
||||
/** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */
|
||||
avoidChainages?: ReadonlyArray<number>;
|
||||
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
|
||||
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
|
||||
uprightRad?: number;
|
||||
}
|
||||
|
||||
export function drawStationTicks(
|
||||
@@ -274,11 +277,18 @@ export function drawStationTicks(
|
||||
);
|
||||
if (collides) continue;
|
||||
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.fillRect(lx - half, ly - 8, width, 16);
|
||||
context.fillStyle = "#222222";
|
||||
context.fillText(label, lx, ly);
|
||||
context.restore();
|
||||
}
|
||||
context.restore();
|
||||
}
|
||||
|
||||
@@ -534,6 +534,8 @@ export function drawPreparedLabels(
|
||||
view: ViewState,
|
||||
color: string,
|
||||
everyM = 25,
|
||||
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */
|
||||
uprightRad = 0,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const margin = CULL_MARGIN;
|
||||
@@ -559,11 +561,19 @@ export function drawPreparedLabels(
|
||||
continue;
|
||||
}
|
||||
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.strokeStyle = haloColor();
|
||||
context.strokeText(feature.labelText, x, y);
|
||||
context.fillStyle = color;
|
||||
context.fillText(feature.labelText, x, y);
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
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 { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
@@ -93,54 +94,8 @@ export async function openRouteEditModal(
|
||||
options: RouteEditOptions = {},
|
||||
): Promise<void> {
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
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="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 chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -177,7 +132,7 @@ export async function openRouteEditModal(
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
bounds: () => canvas.getBoundingClientRect(),
|
||||
side: overlay.querySelector<HTMLElement>(".b05-routeedit__side")!,
|
||||
request: () => ({
|
||||
vertices: planned.map(([x, y], index) => ({
|
||||
x,
|
||||
@@ -198,10 +153,36 @@ export async function openRouteEditModal(
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onChange: () => {
|
||||
status.textContent = `${routeHead()} — ${measure.hint()}`;
|
||||
syncMeasureBox();
|
||||
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 = {
|
||||
width: 0,
|
||||
height: 0,
|
||||
@@ -226,7 +207,6 @@ export async function openRouteEditModal(
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
crossPreview.destroy();
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -292,7 +272,8 @@ export async function openRouteEditModal(
|
||||
pickedContour,
|
||||
contourStepM: contourStepM(),
|
||||
rotationRad: rotation.radians(),
|
||||
measure: measure.points(),
|
||||
uprightRad: rotation.uprightRad(),
|
||||
measure: measure.marks(),
|
||||
expected,
|
||||
plannedLine,
|
||||
planned,
|
||||
@@ -355,7 +336,8 @@ export async function openRouteEditModal(
|
||||
/** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로
|
||||
* 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */
|
||||
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}개`;
|
||||
|
||||
/** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */
|
||||
@@ -395,6 +377,11 @@ export async function openRouteEditModal(
|
||||
}),
|
||||
toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
onUnselect: () => {
|
||||
picked = -1;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
},
|
||||
});
|
||||
const curveLabelBox = curveBar.label;
|
||||
const syncCurveBar = curveBar.sync;
|
||||
@@ -441,7 +428,7 @@ export async function openRouteEditModal(
|
||||
if (event.button !== 0) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (event.shiftKey) {
|
||||
if (event.shiftKey || measureMode) {
|
||||
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
|
||||
void measure.pick(px, py);
|
||||
return;
|
||||
@@ -454,12 +441,26 @@ export async function openRouteEditModal(
|
||||
dragMoved = false;
|
||||
if (dragNode >= 0) {
|
||||
picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다.
|
||||
measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔).
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (dragHandle) {
|
||||
picked = dragHandle.node;
|
||||
measure.clear();
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (
|
||||
// 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나
|
||||
// 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다.
|
||||
((): boolean => {
|
||||
if (picked >= 0) {
|
||||
picked = -1;
|
||||
syncCurveBar();
|
||||
}
|
||||
return false;
|
||||
})()
|
||||
) {
|
||||
/* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */
|
||||
} else if (
|
||||
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
|
||||
(() => {
|
||||
@@ -547,6 +548,9 @@ export async function openRouteEditModal(
|
||||
if (dragMoved) {
|
||||
history?.commit(snapshotNow());
|
||||
historyControls.sync();
|
||||
// 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲).
|
||||
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
|
||||
void crossPreview.refresh();
|
||||
}
|
||||
dragNode = -1;
|
||||
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 };
|
||||
}
|
||||
@@ -1,30 +1,30 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross.ts
|
||||
* 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧).
|
||||
* 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲).
|
||||
*
|
||||
* · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다.
|
||||
* · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간**
|
||||
* 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다.
|
||||
*
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
||||
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
||||
*
|
||||
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의
|
||||
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다.
|
||||
*
|
||||
* 셈은 **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";
|
||||
|
||||
/** 그림 가장자리 여백(px). */
|
||||
const PAD = 28;
|
||||
import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw";
|
||||
import { formatStation } from "./B05_Profile_Util_Station";
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
projectId: string;
|
||||
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
|
||||
bounds: () => DOMRect;
|
||||
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
|
||||
/** 두 판이 들어앉을 오른쪽 세로 칸. */
|
||||
side: HTMLElement;
|
||||
/** 지금 편집값 — 셈을 부르는 순간에 읽는다. */
|
||||
request: () => {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
@@ -33,202 +33,99 @@ export interface CrossPreviewParams {
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
|
||||
/** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
||||
destroy: () => void;
|
||||
/** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */
|
||||
refresh: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
const root = document.createElement("div");
|
||||
interface CrossPane {
|
||||
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.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__cross-head">
|
||||
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong>
|
||||
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기">✕</button>
|
||||
<strong class="b05-routeedit__cross-title">${title}</strong>
|
||||
<span class="b05-routeedit__cross-station"></span>
|
||||
</div>
|
||||
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
|
||||
<div class="b05-routeedit__cross-foot"></div>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
|
||||
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
|
||||
<canvas class="b05-routeedit__cross-canvas" width="420" height="240"></canvas>
|
||||
<div class="b05-routeedit__cross-foot">${empty}</div>`;
|
||||
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
|
||||
root.hidden = true;
|
||||
});
|
||||
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
|
||||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||||
root.addEventListener(type, (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
|
||||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||
head.addEventListener("pointerdown", (event) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
|
||||
head.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
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;
|
||||
return {
|
||||
root,
|
||||
show(preview, intervalM) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (!preview) {
|
||||
station.textContent = "";
|
||||
foot.textContent = empty;
|
||||
return;
|
||||
}
|
||||
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
|
||||
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
|
||||
station.textContent = formatStation(preview.chainage_m, intervalM);
|
||||
drawCross(context, canvas, preview);
|
||||
foot.textContent = summarizeCross(preview);
|
||||
},
|
||||
wait(text) {
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
foot.textContent = text;
|
||||
},
|
||||
};
|
||||
head.addEventListener("pointerup", stopDrag);
|
||||
head.addEventListener("pointercancel", stopDrag);
|
||||
}
|
||||
|
||||
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
|
||||
let asked = -1;
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
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 {
|
||||
async open(chainageM) {
|
||||
asked = chainageM;
|
||||
root.hidden = false;
|
||||
if (!root.style.left) {
|
||||
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
|
||||
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
|
||||
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);
|
||||
// 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다.
|
||||
const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6;
|
||||
if (!sameStation) previous.show(null, params.request().station_interval_m);
|
||||
watching = chainageM;
|
||||
await load(chainageM, sameStation);
|
||||
},
|
||||
destroy() {
|
||||
root.remove();
|
||||
async refresh() {
|
||||
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];
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
||||
applyEdit: (message: string) => void;
|
||||
/** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */
|
||||
onUnselect: () => void;
|
||||
}
|
||||
|
||||
export interface CurveBar {
|
||||
@@ -51,6 +53,7 @@ export interface CurveBar {
|
||||
|
||||
export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const label = createCurveLabel({
|
||||
onClose: () => params.onUnselect(),
|
||||
onRadius: (value) => {
|
||||
const { picked, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
|
||||
@@ -77,6 +77,8 @@ export interface CurveLabelState {
|
||||
}
|
||||
|
||||
export interface CurveLabelHandlers {
|
||||
/** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */
|
||||
onClose: () => void;
|
||||
onRadius: (value: number | null) => void;
|
||||
onArcLength: (value: number | null) => void;
|
||||
onCurveOn: (on: boolean) => void;
|
||||
@@ -114,6 +116,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
<div class="b05-routeedit__label-head">
|
||||
<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-close" data-act="curve-close"
|
||||
aria-label="닫기" title="닫기">✕</button>
|
||||
</div>
|
||||
<label class="b05-routeedit__curve-field">반지름
|
||||
<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"
|
||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<span class="b05-routeedit__curve-info"></span>
|
||||
<span class="b05-routeedit__curve-note">칸을 비우면 자동</span>`;
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
document.body.append(root);
|
||||
|
||||
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)));
|
||||
arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc)));
|
||||
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"));
|
||||
lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc"));
|
||||
|
||||
@@ -274,16 +280,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
arc.value =
|
||||
state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10);
|
||||
const inner = state.innerAngleDeg;
|
||||
const held =
|
||||
lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음";
|
||||
const floors = [
|
||||
limitRadius > 0 ? `R ≥ ${limitRadius}m` : "",
|
||||
limitArc > 0 ? `L ≥ ${limitArc}m` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
// 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로,
|
||||
// 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다.
|
||||
info.textContent = state.curveOn
|
||||
? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}`
|
||||
? inner
|
||||
? `내각 ${Math.round(inner)}°`
|
||||
: ""
|
||||
: "곡선 없음 — 직선이 그대로 꺾입니다";
|
||||
place(state);
|
||||
// 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다.
|
||||
|
||||
@@ -37,13 +37,23 @@ export interface MeasureToolParams {
|
||||
onChange: () => void;
|
||||
}
|
||||
|
||||
export interface MeasureMark {
|
||||
point: Vertex;
|
||||
/** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */
|
||||
chainageM: number;
|
||||
}
|
||||
|
||||
export interface MeasureTool {
|
||||
/** 찍힌 자리(0~2개) — 그리기가 쓴다. */
|
||||
points: () => Vertex[];
|
||||
/** 상태줄에 낼 한 줄. */
|
||||
marks: () => MeasureMark[];
|
||||
/** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */
|
||||
hint: () => string;
|
||||
/** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */
|
||||
/** 재고 있나 — 작은 창을 띄울지 정하는 값. */
|
||||
active: () => boolean;
|
||||
/** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */
|
||||
pick: (px: number, py: number) => Promise<void>;
|
||||
/** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
@@ -51,7 +61,7 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
let picked: MeasurePoint[] = [];
|
||||
|
||||
const hint = (): string => {
|
||||
if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다.";
|
||||
if (picked.length === 0) return "";
|
||||
const first = picked[0];
|
||||
if (picked.length === 1) {
|
||||
return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
|
||||
@@ -73,8 +83,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
};
|
||||
|
||||
return {
|
||||
points: () => picked.map((entry) => entry.point),
|
||||
marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })),
|
||||
hint,
|
||||
active: () => picked.length > 0,
|
||||
clear() {
|
||||
if (picked.length === 0) return;
|
||||
picked = [];
|
||||
params.onChange();
|
||||
},
|
||||
async pick(px, py) {
|
||||
const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX);
|
||||
if (!hit) {
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
drawPreparedLabels,
|
||||
drawPreparedLayer,
|
||||
layerScreenBounds,
|
||||
normalizedToScreen,
|
||||
type PreparedLayer,
|
||||
type ViewState,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
@@ -80,10 +81,32 @@ export interface RouteEditScene {
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
stationIntervalM: number;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<Vertex>;
|
||||
/** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */
|
||||
measure: ReadonlyArray<{ point: Vertex; chainageM: number }>;
|
||||
/** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */
|
||||
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 {
|
||||
@@ -137,6 +160,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
|
||||
context.lineWidth = 2.6;
|
||||
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
|
||||
drawPickedContourLabel(context, scene, view);
|
||||
}
|
||||
// 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③).
|
||||
context.font = "10px system-ui, sans-serif";
|
||||
@@ -148,6 +172,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
view,
|
||||
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
|
||||
everyM,
|
||||
scene.uprightRad,
|
||||
);
|
||||
}
|
||||
context.restore();
|
||||
@@ -259,37 +284,71 @@ function drawMeasureMarks(
|
||||
context.font = "bold 11px system-ui, sans-serif";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
scene.measure.forEach((vertex, index) => {
|
||||
const [x, y] = scene.toScreen(vertex);
|
||||
scene.measure.forEach((mark, index) => {
|
||||
const [x, y] = scene.toScreen(mark.point);
|
||||
context.fillStyle = "rgba(255,255,255,0.95)";
|
||||
context.beginPath();
|
||||
context.arc(x, y, 7, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
context.stroke();
|
||||
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();
|
||||
}
|
||||
|
||||
/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */
|
||||
function spanBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
|
||||
const nearest = (target: Vertex): number => {
|
||||
let best = 0;
|
||||
let bestDistance = Infinity;
|
||||
line.forEach((vertex, index) => {
|
||||
const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const start = nearest(from);
|
||||
const end = nearest(to);
|
||||
const [low, high] = start <= end ? [start, end] : [end, start];
|
||||
return [from, ...line.slice(low, high + 1), to];
|
||||
/**
|
||||
* 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕).
|
||||
*
|
||||
* ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이
|
||||
* 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로
|
||||
* 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다.
|
||||
*/
|
||||
function spanBetween(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
from: { point: Vertex; chainageM: number },
|
||||
to: { point: Vertex; chainageM: number },
|
||||
): Vertex[] {
|
||||
const low = Math.min(from.chainageM, to.chainageM);
|
||||
const high = Math.max(from.chainageM, to.chainageM);
|
||||
const head = from.chainageM <= to.chainageM ? from.point : to.point;
|
||||
const tail = from.chainageM <= to.chainageM ? to.point : from.point;
|
||||
const inside: Vertex[] = [];
|
||||
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 ②). */
|
||||
@@ -307,11 +366,12 @@ function drawStationMarks(
|
||||
intervalM: scene.stationIntervalM,
|
||||
pxPerMeter: scene.pxPerMeter,
|
||||
toScreen: (x, y) => scene.toScreen([x, y]),
|
||||
uprightRad: scene.uprightRad,
|
||||
},
|
||||
);
|
||||
const total = polylineLengthM(line);
|
||||
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(
|
||||
context,
|
||||
scene,
|
||||
@@ -339,6 +399,11 @@ function endLabel(
|
||||
const x = x0 + ((x0 - x1) / length) * OUTWARD_PX;
|
||||
const y = y0 + ((y0 - y1) / length) * OUTWARD_PX;
|
||||
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.textAlign = "center";
|
||||
context.textBaseline = "middle";
|
||||
|
||||
@@ -12,6 +12,15 @@
|
||||
* 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */
|
||||
const ROTATE_STEP_DEG = 15;
|
||||
|
||||
/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시).
|
||||
*
|
||||
* 그림만 돌고 숫자는 늘 바로 서게 한다 — 180° 로 돌리면 글자가 뒤집혀 안 읽히기 때문이다.
|
||||
* 비용은 라벨 하나에 변환 한 번뿐이라 그림을 다시 그리는 값에 묻힌다.
|
||||
*
|
||||
* ⚠ **되돌리려면 이 값을 `false` 로만 바꾸면 된다** — 그러면 글자도 그림과 함께 돈다
|
||||
* (CAD 도면과 같은 방식). 사용자가 화면을 보고 판단할 수 있게 한 자리에 모아 두었다. */
|
||||
export const UPRIGHT_LABELS = true;
|
||||
|
||||
export interface MapRotationParams {
|
||||
/** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */
|
||||
overlay: HTMLElement;
|
||||
@@ -30,6 +39,8 @@ export interface MapRotation {
|
||||
rerotate: (px: number, py: number) => [number, number];
|
||||
/** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */
|
||||
unrotateDelta: (dx: number, dy: number) => [number, number];
|
||||
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
|
||||
uprightRad: () => number;
|
||||
}
|
||||
|
||||
export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
@@ -61,6 +72,7 @@ export function createMapRotation(params: MapRotationParams): MapRotation {
|
||||
radians: () => radians,
|
||||
unrotate: (px, py) => spin(px, py, -radians),
|
||||
rerotate: (px, py) => spin(px, py, radians),
|
||||
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
|
||||
unrotateDelta: (dx, dy) => {
|
||||
if (!radians) return [dx, dy];
|
||||
const cos = Math.cos(-radians);
|
||||
|
||||
@@ -6,16 +6,21 @@
|
||||
inset: 0;
|
||||
z-index: var(--z-modal, 1000);
|
||||
display: flex;
|
||||
gap: var(--spacing-12);
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-12);
|
||||
background: rgb(0 0 0 / 55%);
|
||||
}
|
||||
|
||||
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
|
||||
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
|
||||
.b05-routeedit__box {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
width: min(1200px, 94vw);
|
||||
max-width: 1200px;
|
||||
height: min(820px, 92vh);
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--color-border);
|
||||
@@ -142,6 +147,107 @@
|
||||
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장). */
|
||||
.b05-routeedit__busy {
|
||||
position: absolute;
|
||||
@@ -189,6 +295,15 @@
|
||||
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 {
|
||||
padding: 1px 6px;
|
||||
@@ -266,53 +381,3 @@
|
||||
color: var(--color-text-secondary);
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user