Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts
T

341 lines
12 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_DrawPaint.ts
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
groundSlopePct,
lineLength,
pointAtDistance,
segmentLength,
stationChainages,
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
PLAN_STATION_INTERVAL_M,
type PlanMapLayer,
type ScreenMap,
} from "./D03_PlanAnalysis_UI_Map";
import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
/** 지점 · 꺾임점에 붙는 반경(px). */
export const SNAP_PX = 12;
/** 선 위 누름으로 볼 반경(px). */
export const LINE_HIT_PX = 8;
/** 구간 길이 라벨을 보일 화면 길이(px). */
const LABEL_MIN_PX = 70;
/** 지도 위 그림 검은 테(600-1) — 지금 길을 검은 선 → 색 선 두 번 긋고 점선 무늬를 되돌림 · 길은 부르는 쪽이 먼저 만듦. */
export function strokeCased(
context: CanvasRenderingContext2D,
color: string,
width: number,
dash: number[] = [],
): void {
context.setLineDash(dash);
context.lineWidth = width + 3;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = width;
context.strokeStyle = color;
context.stroke();
context.setLineDash([]);
}
/** 지도 위 점 — 색 채움 + 검은 테. */
function fillCased(context: CanvasRenderingContext2D, color: string): void {
context.fillStyle = color;
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = planColors().casing;
context.stroke();
}
export const fmt = (value: number): string => value.toFixed(1);
const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`;
/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */
export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`;
/** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */
export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => {
const [sx, sy] = map.toScreen(point);
return Math.hypot(sx - px, sy - py);
};
/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */
export function lineAt(
network: PlanNetwork,
px: number,
py: number,
map: ScreenMap,
skip: number | null = null,
): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null {
const cursor = map.toMetric(px, py);
let best: ReturnType<typeof lineAt> = null;
let bestPx = LINE_HIT_PX;
network.lines.forEach((line, index) => {
if (line.vertices.length < 2 || index === skip) return;
// 붙는 반경 안 꺾임점 중 가장 가까운 것
let vertex = -1;
let vertexPx = SNAP_PX;
line.vertices.forEach((v, i) => {
const [vx, vy] = map.toScreen(v);
const d = Math.hypot(vx - px, vy - py);
if (d <= vertexPx) [vertex, vertexPx] = [i, d];
});
const atM =
vertex >= 0
? lineLength(line.vertices.slice(0, vertex + 1))
: distanceAlong(line.vertices, cursor);
const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM);
const [sx, sy] = map.toScreen(point);
const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py);
if (gap > bestPx || (best && gap === bestPx)) return;
// 누가가 든 구간(꺾임점 끼울 자리)
let segment = 0;
for (let walked = 0; segment < line.vertices.length - 2; segment += 1) {
walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]);
if (walked >= atM) break;
}
best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment };
bestPx = gap;
});
return best;
}
/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기).
* `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */
export function paintNetwork(
context: CanvasRenderingContext2D,
map: ScreenMap,
network: PlanNetwork,
active: number | null,
z: HeightAt,
extra: () => void,
dim: { line: number; from: number; to: number } | null = null,
): void {
const colors = planColors();
context.lineCap = "round";
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
vertices.forEach((v, i) => {
const [sx, sy] = map.toScreen(v);
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
};
if (dim?.line === index) {
path(line.vertices.slice(0, dim.from + 1));
path(line.vertices.slice(dim.to));
context.globalAlpha = 0.3;
path(line.vertices.slice(dim.from, dim.to + 1));
context.globalAlpha = 1;
} else path(line.vertices);
if (map.stations)
drawStationTicks(
context,
line.vertices.map(([x, y]) => ({ x, y })),
{
intervalM: PLAN_STATION_INTERVAL_M,
pxPerMeter: map.pxPerMeter,
toScreen: (x, y) => map.toScreen([x, y]),
uprightRad: map.uprightRad,
chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M),
},
);
if (map.slopes) segmentLabels(context, map, line.vertices, z);
});
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
context.fillStyle = "#ffffff";
context.strokeStyle = colors.warn;
context.lineWidth = 2;
for (const line of network.lines)
line.vertices.forEach((v, i) => {
if (!i) return;
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fill();
if (!nodeOff(line, i, z)) return;
context.beginPath();
context.arc(sx, sy, 7, 0, Math.PI * 2);
context.stroke();
});
drawPieces(context, map, network);
extra();
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
const [sx, sy] = map.toScreen(point);
map.upright(context, sx, sy, () => {
const head = sy - 18;
context.beginPath();
context.moveTo(sx, sy);
context.lineTo(sx - 7, head + 5);
context.arc(sx, head, 9, Math.PI * 0.8, Math.PI * 0.2);
context.closePath();
context.fillStyle = pointColor(i);
context.fill();
context.lineWidth = 5;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = 2;
context.strokeStyle = "#ffffff";
context.stroke();
context.beginPath();
context.arc(sx, head, 3.5, 0, Math.PI * 2);
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
});
},
};
}
function segmentLabels(
context: CanvasRenderingContext2D,
map: ScreenMap,
vertices: Vertex[],
z: HeightAt,
): void {
for (let i = 1; i < vertices.length; i += 1) {
const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])];
if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue;
const slope = groundSlopePct(vertices[i - 1], vertices[i], z);
const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`;
planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px");
}
}
function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void {
const colors = planColors();
network.pieces.forEach((piece) => {
const vertices = network.lines.find((line) => line.id === piece.line)?.vertices;
if (!vertices || vertices.length < 2) return;
if (piece.from_m > 0) {
// 앞 구간과 같은 해 = 구역만 나눔(속 빈 마름모 · 585-3)
const before = network.pieces.find(
(p) => p.line === piece.line && Math.abs(p.to_m - piece.from_m) < 1e-3,
);
const hollow = before?.year === piece.year;
paintMark(
context,
map.toScreen(pointAtDistance(vertices, piece.from_m)),
colors.active,
hollow,
);
}
const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2));
planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px");
});
}
/** 연도 구역 마커 마름모 — 속 빈 = 같은 해 구역 나눔. */
export function paintMark(
context: CanvasRenderingContext2D,
[sx, sy]: [number, number],
color: string,
hollow = false,
): void {
context.beginPath();
context.moveTo(sx, sy - 8);
context.lineTo(sx + 6, sy);
context.lineTo(sx, sy + 8);
context.lineTo(sx - 6, sy);
context.closePath();
context.fillStyle = hollow ? "#ffffff" : color;
context.fill();
context.lineWidth = hollow ? 5.5 : 4.5;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = hollow ? 2.5 : 1.5;
context.strokeStyle = hollow ? color : "#ffffff";
context.stroke();
}
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
export function paintPreview(
context: CanvasRenderingContext2D,
map: ScreenMap,
from: Vertex,
preview: DrawPreview,
compass: { lengthM: number; candidates: Vertex[] } | null,
): void {
const colors = planColors();
const [ax, ay] = map.toScreen(from);
const [bx, by] = map.toScreen(preview.point);
if (compass) {
context.beginPath();
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
strokeCased(context, colors.active, 1.5, [3, 4]);
for (const c of compass.candidates) {
const [cx, cy] = map.toScreen(c);
context.beginPath();
context.arc(cx, cy, 3.5, 0, Math.PI * 2);
fillCased(context, colors.active);
}
}
const color = preview.blocked ? colors.warn : colors.active;
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
strokeCased(context, color, 2.5, [6, 4]);
context.beginPath();
context.arc(bx, by, 5, 0, Math.PI * 2);
strokeCased(context, color, 2.5);
const value =
`${fmt(preview.lengthM)} m · ` +
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
planLabel(
context,
map,
bx + 14,
by - 14,
preview.note ? `${value} · ${preview.note}` : value,
"12px",
"left",
);
}
export function planLabel(
context: CanvasRenderingContext2D,
map: ScreenMap,
x: number,
y: number,
text: string,
font: string,
align: CanvasTextAlign = "center",
): void {
map.upright(context, x, y, () => {
context.font = `${font} system-ui, sans-serif`;
context.textAlign = align;
context.textBaseline = "middle";
context.lineWidth = 3;
context.strokeStyle = planColors().halo;
context.strokeText(text, x, y);
context.fillStyle = "#ffffff";
context.fillText(text, x, y);
});
}