Merge remote-tracking branch 'origin/dev' into sub_desktop_1 — 계획노선 편집 창 dmain(기울기 라벨 · 1m 등고선 · 보던 화면) 과 합침
재기 배선은 dmain 의 bindMeasureControls 를 취하고, 두 노선 재기(lines) · 횡단 반폭 선은 살림. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
This commit is contained in:
@@ -60,6 +60,8 @@ export const STATE_REGISTRY = {
|
||||
/** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */
|
||||
"m02-combo-panel-collapsed": { bucket: "pref", scope: "global" },
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -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 { bindMeasureTool } 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 {
|
||||
@@ -100,7 +111,8 @@ export async function openRouteEditModal(
|
||||
): Promise<void> {
|
||||
const stationIntervalM = options.stationIntervalM ?? 20;
|
||||
const chrome = createRouteEditChrome();
|
||||
const { overlay, canvas, status, busy } = chrome;
|
||||
const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome;
|
||||
const { minorContourButton } = chrome;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
let expected: Vertex[] = [];
|
||||
@@ -153,20 +165,31 @@ export async function openRouteEditModal(
|
||||
}),
|
||||
onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다.
|
||||
});
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·배선은 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = bindMeasureTool({
|
||||
...chrome,
|
||||
/** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
projectId,
|
||||
stationIntervalM,
|
||||
lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
isClosed: () => closed,
|
||||
onShow: () => {
|
||||
onChange: () => {
|
||||
measureControls.sync();
|
||||
draw();
|
||||
},
|
||||
});
|
||||
/** 재기 단추·작은 창 — 배선은 `_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();
|
||||
},
|
||||
draw: () => draw(),
|
||||
redraw: () => draw(),
|
||||
});
|
||||
let view: ViewState = {
|
||||
width: 0,
|
||||
@@ -186,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)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
@@ -200,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();
|
||||
}
|
||||
@@ -217,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 =>
|
||||
@@ -258,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(),
|
||||
crossWidth: crossPreview.watched(),
|
||||
expected,
|
||||
@@ -271,6 +289,7 @@ export async function openRouteEditModal(
|
||||
});
|
||||
// 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게.
|
||||
syncCurveBar();
|
||||
viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다.
|
||||
}
|
||||
|
||||
/** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */
|
||||
@@ -421,7 +440,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 || measure.mode()) {
|
||||
if (event.shiftKey || measureControls.mode()) {
|
||||
// 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤).
|
||||
void measure.pick(px, py);
|
||||
return;
|
||||
@@ -560,9 +579,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);
|
||||
@@ -572,13 +597,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("새 노드를 넣었습니다(직선 추가).");
|
||||
});
|
||||
@@ -592,12 +611,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("노드를 지웠습니다(직선 삭제).");
|
||||
});
|
||||
@@ -656,39 +670,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 등고선" : "도엽 등고선"} · ` +
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -64,6 +64,48 @@ export interface MeasureTool {
|
||||
|
||||
const LINE_NAME: Record<MeasureLine, string> = { planned: "", expected: "예상노선 " };
|
||||
|
||||
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[] = [];
|
||||
@@ -146,48 +188,3 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeasureBinding extends MeasureTool {
|
||||
/** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */
|
||||
mode: () => boolean;
|
||||
}
|
||||
|
||||
/** 재기 도구와 단추·작은 창 배선. 작은 창이 뜨면 `onShow` — 곡선 패널과 같이 뜨지 않게(㉔). */
|
||||
export function bindMeasureTool(
|
||||
params: Omit<MeasureToolParams, "onChange"> & {
|
||||
overlay: HTMLElement;
|
||||
measureBox: HTMLElement;
|
||||
measureText: HTMLElement;
|
||||
measureButton: HTMLButtonElement;
|
||||
onShow: () => void;
|
||||
draw: () => void;
|
||||
},
|
||||
): MeasureBinding {
|
||||
let measureMode = false;
|
||||
const sync = (): void => {
|
||||
const on = tool.active();
|
||||
params.measureBox.hidden = !on;
|
||||
params.measureText.textContent = tool.hint();
|
||||
if (on) params.onShow();
|
||||
};
|
||||
const tool = createMeasureTool({
|
||||
...params,
|
||||
onChange: () => {
|
||||
sync();
|
||||
params.draw();
|
||||
},
|
||||
});
|
||||
params.measureButton.addEventListener("click", () => {
|
||||
measureMode = !measureMode;
|
||||
params.measureButton.classList.toggle("is-active", measureMode);
|
||||
if (!measureMode) tool.clear();
|
||||
});
|
||||
params.overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => {
|
||||
tool.clear(); // 닫으면 잰 것이 지워진다(㉔).
|
||||
measureMode = false;
|
||||
params.measureButton.classList.remove("is-active");
|
||||
sync();
|
||||
params.draw();
|
||||
});
|
||||
return { ...tool, mode: () => measureMode };
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ export interface RouteEditScene {
|
||||
rotationRad: number;
|
||||
/** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */
|
||||
uprightRad: number;
|
||||
/** 1m 간격 등고선 — 켰을 때만 준다. 끄면 null(계획서 18 ②). */
|
||||
minorContours: PreparedLayer | null;
|
||||
/** 측점 눈금 바로 위에 얹을 것(측점 사이 기울기 라벨, 계획서 18 ①). 회전 안에서 부른다. */
|
||||
overStations?: (context: CanvasRenderingContext2D) => void;
|
||||
}
|
||||
|
||||
/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */
|
||||
@@ -159,7 +163,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);
|
||||
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
||||
@@ -249,6 +262,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
|
||||
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/* 계획노선 편집 창 **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 단위검증 헬퍼 —
|
||||
* `B05_Profile_UI_RouteEdit_View.ts` · `_Edits.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
|
||||
* 브라우저 전용인 저장소·팔레트는 가짜로 채운다 — 셈만 본다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리.
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const local = memoryStorage();
|
||||
globalThis.window = { localStorage: local, sessionStorage: memoryStorage() };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
if (cache.has(full)) return cache.get(full);
|
||||
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = { exports: {} };
|
||||
cache.set(full, box.exports);
|
||||
const localRequire = (request) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const viewMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_View.ts"));
|
||||
const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"));
|
||||
const edits = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Edits.ts"));
|
||||
|
||||
// 도엽 한 장 — 1200m × 900m 사업지 좌표.
|
||||
const meta = {
|
||||
x_min: 200000,
|
||||
y_min: 500000,
|
||||
width_meters: 1200,
|
||||
height_meters: 900,
|
||||
lon_min: 127,
|
||||
lon_max: 127.01,
|
||||
lat_min: 37,
|
||||
lat_max: 37.008,
|
||||
};
|
||||
function viewOf(width, height, scale, offsetX, offsetY) {
|
||||
return {
|
||||
width,
|
||||
height,
|
||||
scale,
|
||||
offsetX,
|
||||
offsetY,
|
||||
mapRect: render.computeMapRect(meta, width, height),
|
||||
};
|
||||
}
|
||||
|
||||
// ① 한 창 크기에서 잡은 화면을 **다른 창 크기**로 되살린다 — 가운데 자리와 1m 당 픽셀이 같아야 한다.
|
||||
const before = viewOf(1000, 700, 3.2, -140, 85);
|
||||
const saved = viewMod.captureView(meta, before, (30 * Math.PI) / 180);
|
||||
const resized = viewOf(1400, 820, 1, 0, 0);
|
||||
const restored = { ...resized, ...viewMod.restoreSavedView(meta, resized, saved) };
|
||||
const again = viewMod.captureView(meta, restored, (30 * Math.PI) / 180);
|
||||
|
||||
// ② 저장소 왕복 — 적은 값을 같은 프로젝트로 다시 읽고, 다른 프로젝트에는 없다.
|
||||
const memory = viewMod.createViewMemory("p-1", () => saved);
|
||||
memory.flush();
|
||||
const readBack = viewMod.readSavedView("p-1");
|
||||
const otherProject = viewMod.readSavedView("p-2");
|
||||
const storedKeys = [...local._map.keys()];
|
||||
|
||||
// ③ 보던 화면이 없으면 노선 전체에 맞춘다(회전 0).
|
||||
const fresh = viewMod.openingView("p-none", meta, viewOf(1000, 700, 1, 0, 0), [
|
||||
[200300, 500300],
|
||||
[200600, 500500],
|
||||
]);
|
||||
const withSaved = viewMod.openingView("p-1", meta, viewOf(1000, 700, 1, 0, 0), [[200300, 500300]]);
|
||||
|
||||
// ④ 노드 끼우기·빼기 — 편집값 여섯 줄이 같은 자리에서 함께 움직인다.
|
||||
const columns = {
|
||||
planned: [
|
||||
[0, 0],
|
||||
[10, 0],
|
||||
[20, 0],
|
||||
],
|
||||
curveOn: [true, false, true],
|
||||
curveRadius: [null, 30, null],
|
||||
curveLock: [null, "radius", null],
|
||||
curveArc: [null, 12, null],
|
||||
apexLock: [false, true, false],
|
||||
};
|
||||
edits.spliceNode(columns, 1, [5, 5]);
|
||||
const afterInsert = JSON.parse(JSON.stringify(columns));
|
||||
edits.spliceNode(columns, 1);
|
||||
const afterRemove = JSON.parse(JSON.stringify(columns));
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
saved,
|
||||
again,
|
||||
readBack,
|
||||
otherProject,
|
||||
storedKeys,
|
||||
fresh: { rotationRad: fresh.rotationRad, scale: fresh.view.scale },
|
||||
withSaved: { rotationRad: withSaved.rotationRad },
|
||||
afterInsert,
|
||||
afterRemove,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,63 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계획노선 편집 창 — **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 회귀검증.
|
||||
|
||||
화면은 창 크기와 무관한 꼴(가운데 m · 1m 당 픽셀 · 회전각)로 기억한다. 창 크기가 바뀌어도
|
||||
같은 자리를 같은 배율로 다시 열어야 한다. 자리는 화면 취향 통(`aislo:pref:`)의 프로젝트 칸.
|
||||
|
||||
Node 헬퍼(`helper_b05_routeedit_view.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_view.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_다른_창_크기에서도_같은_가운데와_배율(results):
|
||||
saved, again = results["saved"], results["again"]
|
||||
assert again["cx"] == pytest.approx(saved["cx"], abs=1e-6)
|
||||
assert again["cy"] == pytest.approx(saved["cy"], abs=1e-6)
|
||||
assert again["pxPerMeter"] == pytest.approx(saved["pxPerMeter"], rel=1e-9)
|
||||
assert saved["rotationDeg"] == pytest.approx(30.0)
|
||||
|
||||
|
||||
def test_저장소_왕복은_프로젝트마다(results):
|
||||
assert results["readBack"] == pytest.approx(results["saved"])
|
||||
assert results["otherProject"] is None
|
||||
assert results["storedKeys"] == ["aislo:pref:routeedit-view:p-1"]
|
||||
|
||||
|
||||
def test_보던_화면이_없으면_노선에_맞추고_회전_0(results):
|
||||
assert results["fresh"]["rotationRad"] == 0
|
||||
assert results["fresh"]["scale"] > 0
|
||||
assert results["withSaved"]["rotationRad"] == pytest.approx(math.radians(30))
|
||||
|
||||
|
||||
def test_노드_끼우기_빼기는_편집값이_같은_자리로(results):
|
||||
inserted = results["afterInsert"]
|
||||
assert inserted["planned"][1] == [5, 5]
|
||||
assert inserted["curveOn"] == [True, True, False, True]
|
||||
assert inserted["curveRadius"] == [None, None, 30, None]
|
||||
assert inserted["curveLock"] == [None, None, "radius", None]
|
||||
assert inserted["curveArc"] == [None, None, 12, None]
|
||||
assert inserted["apexLock"] == [False, False, True, False]
|
||||
removed = results["afterRemove"]
|
||||
assert removed["planned"] == [[0, 0], [10, 0], [20, 0]]
|
||||
assert removed["curveRadius"] == [None, 30, None]
|
||||
assert removed["apexLock"] == [False, True, False]
|
||||
@@ -225,6 +225,8 @@
|
||||
--map-eupmyeondong: #22c55e;
|
||||
--map-contour: #fdba74;
|
||||
--map-sheet-contour: #a5b4fc;
|
||||
--map-contour-major: #818cf8; /* 계획노선 편집 5m 등고선 — 도엽색보다 한 단 짙게 */
|
||||
--map-contour-minor: rgba(165, 180, 252, 0.45); /* 계획노선 편집 1m 등고선 — 얇고 옅게 */
|
||||
--map-sheet-stream: #2563eb;
|
||||
--map-sheet-elev-point: #f9a8d4;
|
||||
--map-sheet-cutfill: #f43f5e;
|
||||
|
||||
Reference in New Issue
Block a user