443 lines
18 KiB
TypeScript
443 lines
18 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Render.ts
|
|
* 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이,
|
|
* 그 위에 시점·종점·규칙측점 눈금.
|
|
*
|
|
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
|
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다.
|
|
*
|
|
* 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가
|
|
* 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②).
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
drawPreparedFeature,
|
|
drawPreparedLabels,
|
|
drawPreparedLayer,
|
|
layerScreenBounds,
|
|
normalizedToScreen,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
|
|
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
|
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
|
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
|
|
import { formatStation } from "./B05_Profile_Util_Station";
|
|
|
|
/** 노드 반지름(px). */
|
|
const NODE_R = 4;
|
|
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
|
*
|
|
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
|
* 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을
|
|
* 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */
|
|
const CONTOUR_BAND_M = 300;
|
|
/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다.
|
|
* 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */
|
|
const CURVE_HANDLE_PX = 5;
|
|
/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */
|
|
const OUTWARD_PX = 26;
|
|
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
|
|
const MEASURE_COLOR = "#22c55e";
|
|
|
|
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
|
|
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
|
|
let total = 0;
|
|
for (let index = 1; index < points.length; index += 1) {
|
|
total += Math.hypot(
|
|
points[index][0] - points[index - 1][0],
|
|
points[index][1] - points[index - 1][1],
|
|
);
|
|
}
|
|
return total;
|
|
}
|
|
|
|
export interface RouteEditScene {
|
|
view: ViewState;
|
|
/** 사업지 좌표(m) → 캔버스 px. */
|
|
toScreen: (vertex: Vertex) => [number, number];
|
|
/** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */
|
|
pxPerMeter: number;
|
|
/** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */
|
|
hasMeta: boolean;
|
|
/** 바탕 등고선 한 벌 — LAS 것이거나 도엽 것(`_Contour` 가 고른다). */
|
|
contours: RouteEditContours | null;
|
|
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
|
|
otherSheets: ReadonlyArray<PreparedLayer>;
|
|
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
|
pickedContour: number;
|
|
/** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 **같은 값**을 봐야 한다. */
|
|
contourStepM: number;
|
|
expected: ReadonlyArray<Vertex>;
|
|
/** 그려 보이는 계획노선(원호 포함). */
|
|
plannedLine: ReadonlyArray<Vertex>;
|
|
/** 잡아 옮기는 노드(꺾임점). */
|
|
planned: ReadonlyArray<Vertex>;
|
|
nodeInfo: ReadonlyArray<EditedNode>;
|
|
curveInfo: ReadonlyArray<EditedCurve>;
|
|
curveOn: ReadonlyArray<boolean>;
|
|
/** 지금 고른 꺾임점. 없으면 -1. */
|
|
picked: number;
|
|
/** 규칙 측점 간격(m). */
|
|
stationIntervalM: number;
|
|
/** 구간 재기로 찍은 점(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 {
|
|
const { view, toScreen } = scene;
|
|
const style = getComputedStyle(document.documentElement);
|
|
const line = scene.plannedLine.length ? scene.plannedLine : scene.planned;
|
|
context.clearRect(0, 0, view.width, view.height);
|
|
context.fillStyle = style.getPropertyValue("--color-surface") || "#111";
|
|
context.fillRect(0, 0, view.width, view.height);
|
|
|
|
// 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯).
|
|
// 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다.
|
|
context.save();
|
|
if (scene.rotationRad) {
|
|
context.translate(view.width / 2, view.height / 2);
|
|
context.rotate(scene.rotationRad);
|
|
context.translate(-view.width / 2, -view.height / 2);
|
|
}
|
|
|
|
context.save();
|
|
// 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면
|
|
// 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥).
|
|
const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null;
|
|
if (band) {
|
|
context.beginPath();
|
|
context.rect(band.x, band.y, band.width, band.height);
|
|
context.clip();
|
|
}
|
|
context.strokeStyle = style.getPropertyValue("--map-sheet-stream") || "#2563eb";
|
|
context.lineWidth = 1.2;
|
|
// 바탕이 LAS 면 세류선도 **등고선이 있는 데까지만** 그린다(2026-09-12 사용자 지시 ⑮) —
|
|
// 도엽 하천중심선은 도엽 전체를 덮어 LAS 자료 밖까지 길게 뻗는다.
|
|
const lasBox =
|
|
scene.contours?.source === "las" ? layerScreenBounds(scene.contours.layer, view) : null;
|
|
context.save();
|
|
if (lasBox) {
|
|
context.beginPath();
|
|
context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height);
|
|
context.clip();
|
|
}
|
|
for (const layer of scene.otherSheets) drawPreparedLayer(context, layer, view, "dot");
|
|
context.restore();
|
|
if (scene.contours) {
|
|
// 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다.
|
|
const everyM = scene.contourStepM;
|
|
context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc";
|
|
context.lineWidth = 0.8;
|
|
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
|
|
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
|
|
if (scene.pickedContour >= 0) {
|
|
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";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
drawPreparedLabels(
|
|
context,
|
|
scene.contours.layer,
|
|
view,
|
|
style.getPropertyValue("--map-sheet-contour") || "#a5b4fc",
|
|
everyM,
|
|
scene.uprightRad,
|
|
);
|
|
}
|
|
context.restore();
|
|
|
|
strokePolyline(
|
|
context,
|
|
toScreen,
|
|
scene.expected,
|
|
[6, 5],
|
|
style.getPropertyValue("--color-text-secondary") || "#9ca3af",
|
|
1.6,
|
|
);
|
|
// 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다.
|
|
// 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다.
|
|
strokePolyline(
|
|
context,
|
|
toScreen,
|
|
line,
|
|
[],
|
|
style.getPropertyValue("--map-route") || "#f97316",
|
|
2.4,
|
|
);
|
|
|
|
context.save();
|
|
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
|
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
|
context.lineWidth = 1;
|
|
scene.planned.forEach((vertex, index) => {
|
|
const [x, y] = toScreen(vertex);
|
|
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
|
|
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
|
|
context.fillStyle = bad
|
|
? style.getPropertyValue("--color-danger") || "#dc2626"
|
|
: style.getPropertyValue("--map-route") || "#f97316";
|
|
context.beginPath();
|
|
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.stroke();
|
|
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
|
|
if (
|
|
scene.curveOn.length &&
|
|
!scene.curveOn[index] &&
|
|
index > 0 &&
|
|
index < scene.planned.length - 1
|
|
) {
|
|
context.save();
|
|
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
|
|
context.beginPath();
|
|
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
|
|
context.fill();
|
|
context.restore();
|
|
}
|
|
});
|
|
|
|
// 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시).
|
|
// **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다.
|
|
context.lineWidth = 2;
|
|
scene.curveInfo.forEach((curve) => {
|
|
// **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에
|
|
// **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다.
|
|
// 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다.
|
|
if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다.
|
|
// 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게.
|
|
const isPicked = curve.node_first === scene.picked;
|
|
[curve.start, curve.end].forEach((point) => {
|
|
const [x, y] = toScreen([point[0], point[1]]);
|
|
context.beginPath();
|
|
const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX;
|
|
context.rect(x - size, y - size, size * 2, size * 2);
|
|
context.fillStyle = isPicked
|
|
? style.getPropertyValue("--map-route") || "#f97316"
|
|
: style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)";
|
|
context.fill();
|
|
context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
|
|
context.stroke();
|
|
});
|
|
});
|
|
context.restore();
|
|
|
|
drawStationMarks(context, scene, line);
|
|
drawMeasureMarks(context, scene, line);
|
|
context.restore(); // 회전 끝
|
|
}
|
|
|
|
/** 구간 재기로 찍은 자리 — a·b 를 동그라미로 찍고 그 사이 노선을 굵게 덧그린다(계획서 0-9 ⑤). */
|
|
function drawMeasureMarks(
|
|
context: CanvasRenderingContext2D,
|
|
scene: RouteEditScene,
|
|
line: ReadonlyArray<Vertex>,
|
|
): void {
|
|
if (scene.measure.length === 0) return;
|
|
context.save();
|
|
if (scene.measure.length >= 2) {
|
|
const span = spanBetween(line, scene.measure[0], scene.measure[1]);
|
|
if (span.length >= 2) {
|
|
context.strokeStyle = MEASURE_COLOR;
|
|
context.lineWidth = 4;
|
|
context.beginPath();
|
|
span.forEach((vertex, index) => {
|
|
const [x, y] = scene.toScreen(vertex);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.stroke();
|
|
}
|
|
}
|
|
context.lineWidth = 2.4;
|
|
context.strokeStyle = MEASURE_COLOR;
|
|
context.font = "bold 11px system-ui, sans-serif";
|
|
context.textAlign = "center";
|
|
context.textBaseline = "middle";
|
|
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";
|
|
upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y));
|
|
});
|
|
context.restore();
|
|
}
|
|
|
|
/**
|
|
* 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 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 ②). */
|
|
function drawStationMarks(
|
|
context: CanvasRenderingContext2D,
|
|
scene: RouteEditScene,
|
|
line: ReadonlyArray<Vertex>,
|
|
): void {
|
|
if (line.length < 2) return;
|
|
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
|
|
drawStationTicks(
|
|
context,
|
|
line.map(([x, y]) => ({ x, y })),
|
|
{
|
|
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], `시점 ${formatStation(0, scene.stationIntervalM)}`);
|
|
endLabel(
|
|
context,
|
|
scene,
|
|
line[last],
|
|
line[last - 1],
|
|
`종점 ${formatStation(total, scene.stationIntervalM)}`,
|
|
);
|
|
}
|
|
|
|
/** 시점·종점 이름표 — 측점 라벨보다 크고 짙게 찍어 양 끝을 한눈에 알게 한다.
|
|
*
|
|
* 자리는 **노선 바깥쪽**(끝점에서 노선을 등진 방향)이다. 위로만 띄웠더니 같은 자리의 측점
|
|
* 라벨(0+0.0 · 50+0.0)과 겹쳐 두 글자가 포개졌다 — 측점 라벨은 노선에 **직각**으로 나가므로
|
|
* 노선을 따라 밀면 서로 안 물린다(2026-09-12 실화면). */
|
|
function endLabel(
|
|
context: CanvasRenderingContext2D,
|
|
scene: RouteEditScene,
|
|
at: Vertex,
|
|
inward: Vertex,
|
|
text: string,
|
|
): void {
|
|
const [x0, y0] = scene.toScreen(at);
|
|
const [x1, y1] = scene.toScreen(inward);
|
|
const length = Math.hypot(x0 - x1, y0 - y1) || 1;
|
|
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";
|
|
const width = context.measureText(text).width + 10;
|
|
context.fillStyle = "rgba(255, 255, 255, 0.9)";
|
|
context.fillRect(x - width / 2, y - 26, width, 17);
|
|
context.strokeStyle = "#f97316";
|
|
context.lineWidth = 1;
|
|
context.strokeRect(x - width / 2, y - 26, width, 17);
|
|
context.fillStyle = "#111111";
|
|
context.fillText(text, x, y - 17.5);
|
|
context.restore();
|
|
}
|
|
|
|
function strokePolyline(
|
|
context: CanvasRenderingContext2D,
|
|
toScreen: (vertex: Vertex) => [number, number],
|
|
points: ReadonlyArray<Vertex>,
|
|
dash: number[],
|
|
color: string,
|
|
width: number,
|
|
): void {
|
|
if (points.length < 2) return;
|
|
context.save();
|
|
context.setLineDash(dash);
|
|
context.strokeStyle = color;
|
|
context.lineWidth = width;
|
|
context.beginPath();
|
|
points.forEach((vertex, index) => {
|
|
const [x, y] = toScreen(vertex);
|
|
if (index === 0) context.moveTo(x, y);
|
|
else context.lineTo(x, y);
|
|
});
|
|
context.stroke();
|
|
context.restore();
|
|
}
|