feat(B05): 계획노선 편집 창 — 측점 사이 기울기 라벨 · 1m 등고선 켬끔 · 보던 화면 기억

- 측점 사이 종단기울기(지반고 %) 라벨 — 측점 간격이 화면 90px 이상일 때만 · 측점 라벨 맞은편 · 회전해도 글 바로 섬 · 끄는 동안 안 물음
- 「1m 등고선」 단추(기본 꺼짐 · LAS 없으면 잠김) — 1m 선 얇고 옅게 · 켰을 때 처음 한 번만 준비 · 5m 선은 한 단 짙게(--map-contour-major)
- 회전 · 가운데 · 배율을 화면 취향(routeedit-view · 프로젝트마다 · 계정에도 올라감)에 적고 다시 열면 그대로
- 700줄 한도 — 화면 셈·기억은 _View, 재기 배선은 _Measure, 바탕 읽기는 _Contour, 노드 끼우기·빼기는 _Edits 로 나눔
- 시험 test_b05_routeedit_view.py (창 크기 달라도 같은 자리 · 저장소 왕복 · 노드 편집값 자리)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015sCeZz8z8YnbovsKgn7HK7
This commit is contained in:
2026-09-29 09:33:57 +09:00
co-authored by Claude Opus 5.5
parent 1bba901834
commit 207c2e40eb
13 changed files with 840 additions and 115 deletions
+80 -104
View File
@@ -15,18 +15,19 @@
import {
computeMapRect,
computeRouteView,
createNormalizer,
hitPreparedLayer,
metricToScreen,
pickLevelStep,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { showToast } from "@ui/ui_template_elements";
import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import {
bindMinorContourToggle,
loadRouteEditBackground,
type RouteEditContours,
} from "./B05_Profile_UI_RouteEdit_Contour";
import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts";
import { fetchRoutePlan } from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
@@ -47,7 +48,16 @@ import {
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 { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope";
import {
captureView,
createViewMemory,
fitCanvas,
openingView,
screenToMetric,
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
applyArcLocks,
@@ -57,6 +67,7 @@ import {
curveSummary,
flattenServerPlan,
shortfallCrossed,
spliceNode,
type CurveLock,
} from "./B05_Profile_UI_RouteEdit_Edits";
import {
@@ -101,6 +112,7 @@ export async function openRouteEditModal(
const stationIntervalM = options.stationIntervalM ?? 20;
const chrome = createRouteEditChrome();
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
const { minorContourButton } = chrome;
const context = canvas.getContext("2d")!;
let expected: Vertex[] = [];
@@ -161,35 +173,23 @@ export async function openRouteEditModal(
toScreen: (vertex) => toScreen(vertex),
isClosed: () => closed,
onChange: () => {
syncMeasureBox();
measureControls.sync();
draw();
},
});
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
function syncMeasureBox(): void {
const on = measure.active();
measureBox.hidden = !on;
measureText.textContent = measure.hint();
if (on && picked >= 0) {
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
const measureControls = bindMeasureControls({
measure,
box: measureBox,
text: measureText,
button: measureButton,
closeButton: overlay.querySelector<HTMLElement>(".b05-routeedit__measure-close")!,
onShown: () => {
if (picked < 0) return;
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();
},
redraw: () => draw(),
});
let view: ViewState = {
width: 0,
@@ -209,8 +209,18 @@ export async function openRouteEditModal(
draw();
},
});
/** 측점 사이 기울기 라벨 — 높이 묻기·그리기는 `_Slope` 몫(계획서 18 ①). */
const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() });
/** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */
const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw());
/** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */
let viewReady = false;
const viewMemory = createViewMemory(projectId, () =>
meta && viewReady ? captureView(meta, view, rotation.radians()) : null,
);
const close = (): void => {
viewMemory.flush();
closed = true;
window.removeEventListener("resize", resize);
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
@@ -223,15 +233,7 @@ export async function openRouteEditModal(
function resize(): void {
if (closed) return;
const wrap = canvas.parentElement!;
const ratio = window.devicePixelRatio || 1;
const width = wrap.clientWidth;
const height = wrap.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
const { width, height } = fitCanvas(canvas, context);
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
draw();
}
@@ -240,29 +242,12 @@ export async function openRouteEditModal(
const toScreen = (vertex: Vertex): [number, number] =>
meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0];
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
function toMetric(px: number, py: number): Vertex {
if (!meta) return [0, 0];
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1, y1] = metricToScreen(
meta,
view,
meta.x_min + meta.width_meters,
meta.y_min + meta.height_meters,
);
const sx = (x1 - x0) / (meta.width_meters || 1);
const sy = (y1 - y0) / (meta.height_meters || 1);
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
/** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */
const toMetric = (px: number, py: number): Vertex =>
meta ? screenToMetric(meta, view, px, py) : [0, 0];
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. `metricToScreen` 이 선형이라
* 100m 떨어진 두 점으로 잰다(1m 로 재면 반올림 오차가 그대로 비율에 실린다). */
function pxPerMeter(): number {
if (!meta) return 1;
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */
const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1);
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */
const contourStepM = (): number =>
@@ -281,6 +266,16 @@ export async function openRouteEditModal(
contourStepM: contourStepM(),
rotationRad: rotation.radians(),
uprightRad: rotation.uprightRad(),
minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null,
overStations: (ctx) =>
slope.draw(ctx, {
line: plannedLine.length ? plannedLine : planned,
intervalM: stationIntervalM,
pxPerMeter: pxPerMeter(),
toScreen,
uprightRad: rotation.uprightRad(),
dragging: dragNode >= 0 || dragHandle !== null,
}),
measure: measure.marks(),
expected,
plannedLine,
@@ -293,6 +288,7 @@ export async function openRouteEditModal(
});
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
syncCurveBar();
viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다.
}
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
@@ -443,7 +439,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 || measureMode) {
if (event.shiftKey || measureControls.mode()) {
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
void measure.pick(px, py);
return;
@@ -582,9 +578,15 @@ export async function openRouteEditModal(
// 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초).
void crossPreview.refresh();
}
const moved = dragMoved;
dragNode = -1;
dragHandle = null;
dragMoved = false;
// 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①).
if (moved) {
slope.refresh();
draw();
}
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
@@ -594,13 +596,7 @@ export async function openRouteEditModal(
const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top);
const segment = segmentAt(px, py);
if (segment < 0) return;
planned.splice(segment + 1, 0, toMetric(px, py));
// 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다.
curveOn.splice(segment + 1, 0, true);
curveRadius.splice(segment + 1, 0, null);
curveLock.splice(segment + 1, 0, null);
curveArc.splice(segment + 1, 0, null);
apexLock.splice(segment + 1, 0, false);
spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다.
picked = segment + 1;
applyEdit("새 노드를 넣었습니다(직선 추가).");
});
@@ -614,12 +610,7 @@ export async function openRouteEditModal(
showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
return;
}
planned.splice(index, 1);
curveOn.splice(index, 1);
curveRadius.splice(index, 1);
curveLock.splice(index, 1);
curveArc.splice(index, 1);
apexLock.splice(index, 1);
spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
});
@@ -678,39 +669,24 @@ export async function openRouteEditModal(
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
const normalizer = createNormalizer(drainage.meta);
// 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null,
{
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
},
);
const background = await loadRouteEditBackground(projectId, drainage, {
surfaceModelId: options.surfaceModelId ?? null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
});
otherSheets = background.otherSheets;
contours = background.contours;
if (closed) return;
// 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다.
minorContourButton.disabled = !contours?.minor;
if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다";
resize();
const xs = planned.map((vertex) => vertex[0]);
const ys = planned.map((vertex) => vertex[1]);
const fitted = computeRouteView(
meta,
{
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
},
view.width,
view.height,
);
view = { ...view, ...fitted };
// 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다.
const opening = openingView(projectId, meta, view, planned);
view = opening.view;
rotation.set(opening.rotationRad);
viewReady = true;
status.textContent =
`${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` +
`${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` +
+17 -1
View File
@@ -29,6 +29,7 @@ export interface RouteEditChrome {
measureBox: HTMLElement;
measureText: HTMLElement;
measureButton: HTMLButtonElement;
minorContourButton: HTMLButtonElement;
}
/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */
@@ -40,6 +41,9 @@ export function createRouteEditChrome(): RouteEditChrome {
<div class="b05-routeedit__head">
<strong>계획노선 편집</strong>
<span class="b05-routeedit__actions">
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
aria-pressed="false" disabled
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
@@ -93,6 +97,18 @@ export function createRouteEditChrome(): RouteEditChrome {
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")!;
const minorContourButton = overlay.querySelector<HTMLButtonElement>(
'[data-act="contour-minor"]',
)!;
return { overlay, canvas, status, busy, measureBox, measureText, measureButton };
return {
overlay,
canvas,
status,
busy,
measureBox,
measureText,
measureButton,
minorContourButton,
};
}
@@ -14,6 +14,7 @@
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import {
createNormalizer,
type GeoJsonCollection,
type Normalizer,
type PreparedLayer,
@@ -40,6 +41,9 @@ export interface RouteEditContours {
/** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */
intervalM: number;
source: "las" | "sheet";
/** 5m 배수가 아닌 **1m 간격 선**(계획서 18 ②) — 켰을 때 처음 한 번 준비한다(무거워서).
* LAS 자료가 없으면(도엽 갈래) null — 도엽에는 1m 선이 없다. */
minor: (() => PreparedLayer) | null;
}
interface ContourResponse {
@@ -70,19 +74,26 @@ export async function loadRouteEditContours(
`${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` +
`/contour?interval=${interval}&smooth=${options.smooth}`,
);
const lines = (data.contours ?? [])
// 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
.filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6)
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
const all = data.contours ?? [];
const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6;
const toLines = (picked: typeof all) =>
picked
.map((contour) => ({
points: contour.coordinates.map(([x, y]) => [x, y] as const),
label: contour.level,
}))
.filter((line) => line.points.length >= 2);
// 5m 단위만 바로 준비한다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다.
const lines = toLines(all.filter((contour) => onUnit(contour.level)));
if (lines.length > 0) {
// 나머지(1m 간격 선)는 켰을 때 처음 한 번만 준비한다 — 안 켜는 사람은 값을 안 치른다.
const rest = all.filter((contour) => !onUnit(contour.level));
let minor: PreparedLayer | null = null;
return {
layer: prepareMetricPolylines(lines, meta),
intervalM: LAS_CONTOUR_UNIT_M,
source: "las",
minor: rest.length ? () => (minor ??= prepareMetricPolylines(toLines(rest), meta)) : null,
};
}
} catch {
@@ -90,7 +101,50 @@ export async function loadRouteEditContours(
}
}
const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS);
return { layer, intervalM: inferIntervalM(layer), source: "sheet" };
return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null };
}
/**
* 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선).
* 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다.
*/
export async function loadRouteEditBackground(
projectId: string,
drainage: {
meta: VWorldMeta;
layers: ReadonlyArray<readonly [string, GeoJsonCollection | null]>;
},
options: { surfaceModelId: number | null; intervalM: number; smooth: boolean },
): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> {
const normalizer = createNormalizer(drainage.meta);
const otherSheets = drainage.layers
.filter(([layer]) => layer !== "도엽_등고선")
.map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null))
.filter((layer): layer is PreparedLayer => layer !== null);
const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null;
const contours = await loadRouteEditContours(
projectId,
drainage.meta,
normalizer,
sheet,
options,
);
return { otherSheets, contours };
}
/** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */
export function bindMinorContourToggle(
button: HTMLButtonElement,
onChange: () => void,
): () => boolean {
let on = false;
button.addEventListener("click", () => {
on = !on;
button.classList.toggle("is-active", on);
button.setAttribute("aria-pressed", String(on));
onChange();
});
return () => on;
}
/** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */
@@ -270,3 +270,33 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F
});
return out;
}
/** 꺾임점마다 한 칸씩 든 편집값 묶음 — 노드를 넣고 뺄 때 **모두 같은 자리**를 움직인다. */
export interface NodeColumns {
planned: Vertex[];
curveOn: boolean[];
curveRadius: Array<number | null>;
curveLock: CurveLock[];
curveArc: Array<number | null>;
apexLock: boolean[];
}
/** `vertex` 를 주면 `index` 자리에 새 노드를 끼우고(곡선 켬 · 값 비움), 안 주면 그 노드를 뺀다.
* 편집값도 같은 자리에서 움직인다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. */
export function spliceNode(columns: NodeColumns, index: number, vertex?: Vertex): void {
if (vertex) {
columns.planned.splice(index, 0, vertex);
columns.curveOn.splice(index, 0, true);
columns.curveRadius.splice(index, 0, null);
columns.curveLock.splice(index, 0, null);
columns.curveArc.splice(index, 0, null);
columns.apexLock.splice(index, 0, false);
return;
}
columns.planned.splice(index, 1);
columns.curveOn.splice(index, 1);
columns.curveRadius.splice(index, 1);
columns.curveLock.splice(index, 1);
columns.curveArc.splice(index, 1);
columns.apexLock.splice(index, 1);
}
@@ -56,6 +56,48 @@ export interface MeasureTool {
clear: () => void;
}
export interface MeasureControlsParams {
measure: MeasureTool;
/** 잰 값 작은 창·글·[거리 재기] 단추·작은 창 닫기 단추. */
box: HTMLElement;
text: HTMLElement;
button: HTMLButtonElement;
closeButton: HTMLElement;
/** 잰 창이 떴다 — 곡선 패널과 같이 뜨지 않게 호출부가 고른 꺾임점을 푼다(㉔). */
onShown: () => void;
redraw: () => void;
}
/** 재기 단추·작은 창 배선. 창 맞추기(`sync`)와 재기 모드인가(`mode`)를 돌려준다. */
export function bindMeasureControls(params: MeasureControlsParams): {
sync: () => void;
mode: () => boolean;
} {
const { measure, box, text, button } = params;
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
let mode = false;
/** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */
const sync = (): void => {
const on = measure.active();
box.hidden = !on;
text.textContent = measure.hint();
if (on) params.onShown();
};
button.addEventListener("click", () => {
mode = !mode;
button.classList.toggle("is-active", mode);
if (!mode) measure.clear();
});
params.closeButton.addEventListener("click", () => {
measure.clear(); // 닫으면 잰 것이 지워진다(㉔).
mode = false;
button.classList.remove("is-active");
sync();
params.redraw();
});
return { sync, mode: () => mode };
}
export function createMeasureTool(params: MeasureToolParams): MeasureTool {
/** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */
let picked: MeasurePoint[] = [];
+15 -1
View File
@@ -87,6 +87,10 @@ export interface RouteEditScene {
rotationRad: number;
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
uprightRad: number;
/** 1m 간격 등고선 — 켰을 때만 준다. 끄면 null(계획서 18 ②). */
minorContours: PreparedLayer | null;
/** 측점 눈금 바로 위에 얹을 것(측점 사이 기울기 라벨, 계획서 18 ①). 회전 안에서 부른다. */
overStations?: (context: CanvasRenderingContext2D) => void;
}
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
@@ -152,7 +156,16 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
if (scene.contours) {
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
const everyM = scene.contourStepM;
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
// 1m 선은 5m 선이 솎이지 않는 배율에서만 — 5m 도 솎이는 데서 1m 를 다 그리면 선으로
// 덮이고 무겁다. 화면 밖 가닥은 `drawPreparedLayer` 가 건너뛴다(보이는 범위만).
if (scene.minorContours && everyM <= scene.contours.intervalM) {
context.strokeStyle =
style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)";
context.lineWidth = 0.5;
drawPreparedLayer(context, scene.minorContours, view, "dot");
}
// 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②).
context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8";
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
@@ -241,6 +254,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.restore();
drawStationMarks(context, scene, line);
scene.overStations?.(context);
drawMeasureMarks(context, scene, line);
// 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
// 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
@@ -41,6 +41,8 @@ export interface MapRotation {
unrotateDelta: (dx: number, dy: number) => [number, number];
/** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */
uprightRad: () => number;
/** 기억해 둔 각으로 되돌린다(계획서 18 ③). 다시 그리기는 부르는 쪽 몫. */
set: (radians: number) => void;
}
export function createMapRotation(params: MapRotationParams): MapRotation {
@@ -73,6 +75,9 @@ export function createMapRotation(params: MapRotationParams): MapRotation {
unrotate: (px, py) => spin(px, py, -radians),
rerotate: (px, py) => spin(px, py, radians),
uprightRad: () => (UPRIGHT_LABELS ? -radians : 0),
set: (next) => {
radians = Number.isFinite(next) ? next : 0;
},
unrotateDelta: (dx, dy) => {
if (!radians) return [dx, dy];
const cos = Math.cos(-radians);
@@ -0,0 +1,224 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Slope.ts
* 계획노선 편집 모달의 **측점 사이 종단기울기 라벨**(계획서 18 ①).
*
* 확대해 측점 라벨이 하나하나 다 보이는 배율부터(`drawStationTicks` 가 솎지 않는 간격),
* 측점과 측점 사이 노선 위에 그 구간의 기울기(%)를 적는다. 멀리 보면 숨긴다.
*
* 기울기는 **지반고** 기준이다 — 구간 재기(`_Measure`)와 같은 통로(`/route/elevations`)로
* 측점 자리 높이를 한 번에 묻는다. 확정 지표면을 읽기만 하는 통로라 「편집 중에는 계산이
* 안 나간다」와 부딪히지 않고, 노드를 끄는 동안에는 부르지 않는다(놓은 뒤 `refresh`).
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
type Vertex = [number, number];
/** 측점 간격이 화면에서 이만큼(px) 이상이면 라벨을 낸다 — 측점 라벨을 솎지 않는 간격과 같다. */
const SLOPE_MIN_GAP_PX = 90;
/** 노선에서 라벨을 띄우는 거리(px) — 측점 라벨(반대쪽 16px)과 안 겹치게 반대편에 둔다. */
const SLOPE_OFFSET_PX = 13;
/** 높이를 묻기 전 잠깐 기다린다 — 연달아 고칠 때 한 번만 묻게. */
const FETCH_DELAY_MS = 250;
const SLOPE_COLOR = "#0f766e";
/** 화면 가장자리 여유(px) — 반쯤 걸친 라벨도 그린다. */
const EDGE_PX = 20;
export interface SlopeLabelParams {
projectId: string;
/** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */
isClosed: () => boolean;
/** 높이를 받아 왔다 — 호출부가 다시 그린다. */
onChange: () => void;
}
export interface SlopeDrawArgs {
/** 지금 그려지는 노선(원호 포함). 편집마다 **새 배열**이라 이것으로 낡음을 가린다. */
line: ReadonlyArray<Vertex>;
intervalM: number;
pxPerMeter: number;
toScreen: (vertex: Vertex) => [number, number];
/** 글자만 되돌려 세울 각(라디안). */
uprightRad: number;
/** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */
dragging: boolean;
}
export interface SlopeLabels {
/** 측점 사이 라벨을 그린다. 그린 수를 돌려준다(검증 수치). */
draw: (context: CanvasRenderingContext2D, args: SlopeDrawArgs) => number;
/** 끌기를 마쳤다 — 다음 그리기에서 새 노선 높이를 묻게 한다. */
refresh: () => void;
}
/** 측점 누가거리 — 0, 간격, 2·간격, … 그리고 종점(끝 자투리 구간도 기울기가 있다). */
function stationChainages(total: number, intervalM: number): number[] {
const out: number[] = [];
for (let chainage = 0; chainage < total - 1e-6; chainage += intervalM) out.push(chainage);
out.push(total);
return out;
}
/** 누가거리 목록 자리의 노선 위 점. `drawStationTicks` 와 같은 보간이다. */
function pointsAt(line: ReadonlyArray<Vertex>, chainages: number[]): Vertex[] {
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
cumulative.push(
cumulative[index - 1] +
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
);
}
let cursor = 1;
return chainages.map((chainage) => {
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
return [back[0] + (front[0] - back[0]) * ratio, back[1] + (front[1] - back[1]) * ratio];
});
}
function lengthOf(line: ReadonlyArray<Vertex>): number {
let total = 0;
for (let index = 1; index < line.length; index += 1) {
total += Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]);
}
return total;
}
export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels {
/** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */
let cachedLine: ReadonlyArray<Vertex> | null = null;
let cachedInterval = 0;
let chainages: number[] = [];
let heights: Array<number | null> = [];
/** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */
let stationPoints: Vertex[] = [];
let middlePoints: Vertex[] = [];
/** 묻는 중인 노선 — 같은 것을 두 번 묻지 않는다. */
let asking: ReadonlyArray<Vertex> | null = null;
let timer: ReturnType<typeof setTimeout> | null = null;
function schedule(line: ReadonlyArray<Vertex>, intervalM: number): void {
if (asking === line) return;
if (timer) clearTimeout(timer);
asking = line;
timer = setTimeout(() => {
timer = null;
const wanted = stationChainages(lengthOf(line), intervalM);
const points = pointsAt(line, wanted);
void fetchRouteElevations(params.projectId, points)
.then((z) => {
if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다.
cachedLine = line;
cachedInterval = intervalM;
chainages = wanted;
heights = z;
stationPoints = points;
middlePoints = pointsAt(
line,
wanted.slice(1).map((end, index) => (wanted[index] + end) / 2),
);
params.onChange();
})
.catch(() => {
/* 높이를 못 읽으면 라벨을 안 낸다 — 구간 재기와 같은 처리. */
})
.finally(() => {
if (asking === line) asking = null;
});
}, FETCH_DELAY_MS);
}
/** 그린 라벨 수와 첫 라벨(글·자리)을 캔버스에 적어 둔다 — 화면 검증이 읽는 수치. */
function report(context: CanvasRenderingContext2D, count: number, first: string): number {
context.canvas.dataset.slopeLabels = String(count);
context.canvas.dataset.slopeFirst = first;
return count;
}
return {
refresh() {
asking = null;
},
draw(context, args) {
let first = "";
const count = paint(context, args, (text, x, y) => {
if (!first) first = `${text}@${Math.round(x)},${Math.round(y)}`;
});
return report(context, count, first);
},
};
function paint(
context: CanvasRenderingContext2D,
args: SlopeDrawArgs,
onLabel: (text: string, x: number, y: number) => void,
): number {
const { line, intervalM } = args;
if (line.length < 2 || !(intervalM > 0)) return 0;
if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다.
const fresh = cachedLine === line && cachedInterval === intervalM;
if (!fresh) {
if (!args.dragging) schedule(line, intervalM);
return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다.
}
// 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 —
// 돌린 지도에서도 실제로 보이는 라벨만 그리고 센다.
const toDevice = context.getTransform();
const ratio = context.canvas.width / (context.canvas.clientWidth || context.canvas.width);
const onScreen = (x: number, y: number): [number, number] | null => {
const point = toDevice.transformPoint(new DOMPoint(x, y));
const [px, py] = [point.x / ratio, point.y / ratio];
const inside =
px >= -EDGE_PX &&
py >= -EDGE_PX &&
px <= context.canvas.clientWidth + EDGE_PX &&
py <= context.canvas.clientHeight + EDGE_PX;
return inside ? [px, py] : null;
};
context.save();
context.font = "bold 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
let count = 0;
for (let index = 0; index + 1 < chainages.length; index += 1) {
const z0 = heights[index];
const z1 = heights[index + 1];
const run = chainages[index + 1] - chainages[index];
if (z0 === null || z1 === null || z0 === undefined || z1 === undefined) continue;
if (run <= 0.5) continue; // 종점 자투리가 너무 짧으면 기울기가 튄다.
const [sx, sy] = args.toScreen(middlePoints[index]);
// 노선 방향 — 구간 양 끝 측점으로 잡는다. 라벨은 그 직각 반대편(측점 라벨의 맞은편)에.
const [ax, ay] = args.toScreen(stationPoints[index]);
const [bx, by] = args.toScreen(stationPoints[index + 1]);
const length = Math.hypot(bx - ax, by - ay) || 1;
const lx = sx - (-(by - ay) / length) * SLOPE_OFFSET_PX;
const ly = sy - ((bx - ax) / length) * SLOPE_OFFSET_PX;
const seen = onScreen(lx, ly);
if (!seen) continue;
const percent = ((z1 - z0) / run) * 100;
const text = `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`;
context.save();
if (args.uprightRad) {
context.translate(lx, ly);
context.rotate(args.uprightRad);
context.translate(-lx, -ly);
}
const width = context.measureText(text).width + 8;
context.fillStyle = "rgba(255, 255, 255, 0.88)";
context.fillRect(lx - width / 2, ly - 8, width, 16);
context.strokeStyle = SLOPE_COLOR;
context.lineWidth = 1;
context.strokeRect(lx - width / 2, ly - 8, width, 16);
context.fillStyle = SLOPE_COLOR;
context.fillText(text, lx, ly);
context.restore();
onLabel(text, seen[0], seen[1]); // 화면에 보이는 자리(px)로 적는다.
count += 1;
}
context.restore();
return count;
}
}
@@ -0,0 +1,168 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_View.ts
* 계획노선 편집 모달의 **화면 좌표 셈**과 **보던 화면 기억**(계획서 18 ③).
*
* 기억하는 값은 화면 크기와 무관한 꼴로 둔다 — 가운데 자리(사업지 좌표 m)·1m 당 픽셀·
* 회전각. 창 크기가 달라져도 같은 자리를 같은 배율로 다시 연다(px 오프셋을 그대로 두면
* 창이 바뀔 때 엉뚱한 데가 뜬다).
*
* 자리는 화면 취향 통(`pref`, 프로젝트마다) — 브라우저 저장소에 두고 계정에도 올라간다
* (`b_page_state`). 설계값이 아니라 [저장] 없이 바로 적는다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
import {
computeMapRect,
computeMaxScale,
computeRouteView,
metricToScreen,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
type Vertex = [number, number];
/** 기억해 두는 화면 — 가운데(m) · 1m 당 픽셀 · 회전각(도). */
export interface SavedRouteEditView {
cx: number;
cy: number;
pxPerMeter: number;
rotationDeg: number;
}
/** 적기를 잠깐 모은다 — 휠·팬은 프레임마다 그리므로 매번 적으면 저장소를 두드린다. */
const SAVE_DELAY_MS = 400;
/** 배율 하한 — 지도 조작(`_Input`)과 같은 값. */
const MIN_SCALE = 0.5;
const MAX_SCALE_FALLBACK = 16;
/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */
export function screenToMetric(meta: VWorldMeta, view: ViewState, px: number, py: number): Vertex {
const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1, y1] = metricToScreen(
meta,
view,
meta.x_min + meta.width_meters,
meta.y_min + meta.height_meters,
);
const sx = (x1 - x0) / (meta.width_meters || 1);
const sy = (y1 - y0) / (meta.height_meters || 1);
return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)];
}
/** 화면 1m 당 픽셀 — `metricToScreen` 이 선형이라 100m 떨어진 두 점으로 잰다
* (1m 로 재면 반올림 오차가 그대로 비율에 실린다). */
export function viewPxPerMeter(meta: VWorldMeta, view: ViewState): number {
const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min);
const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min);
return Math.abs(x1 - x0) / 100;
}
export function readSavedView(projectId: string): SavedRouteEditView | null {
const saved = readState<Partial<SavedRouteEditView>>("routeedit-view", projectId);
if (!saved) return null;
const { cx, cy, pxPerMeter, rotationDeg } = saved;
if (![cx, cy, pxPerMeter, rotationDeg].every((value) => Number.isFinite(value))) return null;
if (!((pxPerMeter as number) > 0)) return null;
return saved as SavedRouteEditView;
}
/** 기억한 화면을 지금 창 크기에 맞춰 배율·오프셋으로 되돌린다. */
export function restoreSavedView(
meta: VWorldMeta,
view: ViewState,
saved: SavedRouteEditView,
): Pick<ViewState, "scale" | "offsetX" | "offsetY"> {
const mapRect = computeMapRect(meta, view.width, view.height);
const base: ViewState = { ...view, mapRect, scale: 1, offsetX: 0, offsetY: 0 };
const basePx = viewPxPerMeter(meta, base) || 1;
const maxScale = computeMaxScale(meta, mapRect.width, view.width, MAX_SCALE_FALLBACK);
const scale = Math.min(maxScale, Math.max(MIN_SCALE, saved.pxPerMeter / basePx));
// screen = 가운데 + (배율1 자리 − 가운데)·배율 + 오프셋 — 가운데 자리가 한가운데 오게 푼다.
const [bx, by] = metricToScreen(meta, base, saved.cx, saved.cy);
return {
scale,
offsetX: -(bx - view.width / 2) * scale,
offsetY: -(by - view.height / 2) * scale,
};
}
/** 지금 화면을 기억할 꼴로 — 캔버스 한가운데 자리(회전축이라 돌려도 같다)·배율·회전각. */
export function captureView(
meta: VWorldMeta,
view: ViewState,
rotationRad: number,
): SavedRouteEditView {
const [cx, cy] = screenToMetric(meta, view, view.width / 2, view.height / 2);
const rotationDeg = (rotationRad * 180) / Math.PI;
return { cx, cy, pxPerMeter: viewPxPerMeter(meta, view), rotationDeg };
}
/** 창을 열 때의 화면 — 보던 화면이 있으면 그대로, 없으면 노선 전체에 맞춘다(회전 0). */
export function openingView(
projectId: string,
meta: VWorldMeta,
view: ViewState,
nodes: ReadonlyArray<Vertex>,
): { view: ViewState; rotationRad: number } {
const saved = readSavedView(projectId);
if (saved) {
const rotationRad = (saved.rotationDeg * Math.PI) / 180;
return { view: { ...view, ...restoreSavedView(meta, view, saved) }, rotationRad };
}
const xs = nodes.map((vertex) => vertex[0]);
const ys = nodes.map((vertex) => vertex[1]);
const bounds = {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
return {
view: { ...view, ...computeRouteView(meta, bounds, view.width, view.height) },
rotationRad: 0,
};
}
/** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */
export function fitCanvas(
canvas: HTMLCanvasElement,
context: CanvasRenderingContext2D,
): { width: number; height: number } {
const wrap = canvas.parentElement!;
const ratio = window.devicePixelRatio || 1;
const width = wrap.clientWidth;
const height = wrap.clientHeight;
canvas.width = Math.round(width * ratio);
canvas.height = Math.round(height * ratio);
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
context.setTransform(ratio, 0, 0, ratio, 0, 0);
return { width, height };
}
/** 보던 화면을 잠깐 모아 적는 꾼. `touch` 는 그릴 때마다 불러도 된다. */
export function createViewMemory(
projectId: string,
capture: () => SavedRouteEditView | null,
): { touch: () => void; flush: () => void } {
let timer: ReturnType<typeof setTimeout> | null = null;
let last = "";
const flush = (): void => {
if (timer) clearTimeout(timer);
timer = null;
const now = capture();
if (!now) return;
const text = JSON.stringify(now);
if (text === last) return;
last = text;
writeState("routeedit-view", now, projectId);
};
return {
touch() {
if (timer) clearTimeout(timer);
timer = setTimeout(flush, SAVE_DELAY_MS);
},
flush,
};
}