- 상태줄에 계획노선 길이 실시간 표기 (계획서 0-9 ①) - 시점·종점 이름표와 규칙측점 눈금 표기, B04 지도와 같은 `drawStationTicks` 재사용 (②) - 곡선 조작 패널을 모달 지도 칸 안으로 가둠 — 상자 밖·하단 정보행 위로 안 나감 (⑨) - 그리기를 `B05_Profile_UI_RouteEdit_Render.ts` 로 분리 — 본체 703줄 → 627줄 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Render.ts
|
|
* 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이,
|
|
* 그 위에 시점·종점·규칙측점 눈금.
|
|
*
|
|
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
|
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다.
|
|
*
|
|
* 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가
|
|
* 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②).
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
drawPreparedLayer,
|
|
type PreparedLayer,
|
|
type ViewState,
|
|
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
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;
|
|
|
|
/** 노선을 따라간 길이(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;
|
|
sheets: ReadonlyArray<PreparedLayer>;
|
|
expected: ReadonlyArray<Vertex>;
|
|
/** 그려 보이는 계획노선(원호 포함). */
|
|
plannedLine: ReadonlyArray<Vertex>;
|
|
/** 잡아 옮기는 노드(꺾임점). */
|
|
planned: ReadonlyArray<Vertex>;
|
|
nodeInfo: ReadonlyArray<EditedNode>;
|
|
curveInfo: ReadonlyArray<EditedCurve>;
|
|
curveOn: ReadonlyArray<boolean>;
|
|
/** 지금 고른 꺾임점. 없으면 -1. */
|
|
picked: number;
|
|
/** 규칙 측점 간격(m). */
|
|
stationIntervalM: number;
|
|
}
|
|
|
|
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);
|
|
|
|
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-contour") || "#a5b4fc";
|
|
context.lineWidth = 0.8;
|
|
for (const layer of scene.sheets) drawPreparedLayer(context, layer, view, "dot");
|
|
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);
|
|
}
|
|
|
|
/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 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]),
|
|
},
|
|
);
|
|
const total = polylineLengthM(line);
|
|
const last = line.length - 1;
|
|
endLabel(context, scene, line[0], line[1], "시점 0+0.0");
|
|
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();
|
|
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();
|
|
}
|