Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts

400 lines
15 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* D03_PlanAnalysis_UI_DrawPaint.ts
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* 길이·기울기 고정 부채꼴(625-2) — 정방향 = 채움 · 역방향 = 테두리 · 노드 · 놓인 꺾임점 · 미리보기 고리 =
* 기울기 부호 색(+ 오르막 `--draw-map-slope-up` 따뜻한 색 · − 내리막 `--draw-map-slope-down` 진한 파랑 · 막힘 = 경고 빨강).
* `paintFan` · `paintPreview` = ScreenMap 의 `toScreen` · `pxPerMeter` · `upright` 만 씀 — B05 평면(626-3)이 그대로 씀.
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor, routeColor, themeColor } 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 { fanPoint, type Fan } from "./logic/D03_PlanAnalysis_Fan";
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) — 선 테(3 + 4 px)보다 굵게 · 고른 선 손잡이는 이 점을 감싸는 빈 네모. */
export const NODE_DOT_PX = 5;
/** 구간 길이 라벨을 보일 화면 길이(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 + 4;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = width;
context.strokeStyle = color;
context.stroke();
context.setLineDash([]);
}
/** 기울기 부호 색(625-2 B3 · B4) — + 오르막 따뜻한 색 · − 내리막 찬 색 · null(지형 밖) = 흰색. */
export function slopeColor(pct: number | null): string {
if (pct === null) return "#ffffff";
return pct >= 0
? themeColor("--draw-map-slope-up", "#ff9e2c")
: themeColor("--draw-map-slope-down", "#3b6bff");
}
/** 부채꼴 호 그림 걸음(라디안 · 약 3°). */
const FAN_STEP = Math.PI / 60;
/** 길이·기울기 고정 부채꼴 — 허용 호(정방향 = 반투명 채움 · 역방향 = 테두리만) · 노드 = 부호 색. */
export function paintFan(
context: CanvasRenderingContext2D,
map: Pick<ScreenMap, "toScreen">,
from: Vertex,
lengthM: number,
fan: Fan,
): void {
const colors = planColors();
const center = map.toScreen(from);
for (const arc of fan.arcs) {
context.beginPath();
context.moveTo(center[0], center[1]);
const steps = Math.max(2, Math.ceil((arc.to - arc.from) / FAN_STEP));
for (let k = 0; k <= steps; k += 1) {
const [sx, sy] = map.toScreen(
fanPoint(from, lengthM, arc.from + ((arc.to - arc.from) * k) / steps),
);
context.lineTo(sx, sy);
}
context.closePath();
if (arc.forward) {
context.globalAlpha = 0.3;
context.fillStyle = colors.active;
context.fill();
context.globalAlpha = 1;
}
strokeCased(context, colors.active, arc.forward ? 2 : 1.5);
}
for (const node of fan.nodes) {
const [cx, cy] = map.toScreen(node.point);
context.beginPath();
context.arc(cx, cy, 4, 0, Math.PI * 2);
fillCased(context, slopeColor(node.sign));
}
}
/** 지도 위 점 — 색 채움 + 검은 테. */
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;
const color = index === active ? colors.active : routeColor(index);
const width = 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);
});
strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24)
};
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);
});
extra(); // 미리보기(부채꼴 · 원) 먼저 — 놓인 점이 그 위에 보임(625-4)
paintNodes(context, map, network.lines, z);
drawPieces(context, map, network);
}
/** 꺾임점 = 들어온 구간 기울기 부호 색(625-2 B4) · 선보다 굵은 점 + 검은 테 = 선 색 · 위성 위에서도 가름(625-3) ·
* 기록과 어긋난 노드(옮김) = 경고 고리(599-2) · 고른 선은 고름 강조 위에 다시 그림(`D03_PlanAnalysis_UI_Edit`). */
export function paintNodes(
context: CanvasRenderingContext2D,
map: Pick<ScreenMap, "toScreen">,
lines: ReadonlyArray<PlanNetwork["lines"][number]>,
z: HeightAt,
): void {
for (const line of lines)
line.vertices.forEach((v, i) => {
if (!i) return;
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, NODE_DOT_PX, 0, Math.PI * 2);
fillCased(context, slopeColor(groundSlopePct(line.vertices[i - 1], v, z)));
if (!nodeOff(line, i, z)) return;
context.beginPath();
context.arc(sx, sy, NODE_DOT_PX + 4, 0, Math.PI * 2);
context.lineWidth = 2;
context.strokeStyle = planColors().warn;
context.stroke();
});
}
/** 지점 핀 겹(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: Pick<ScreenMap, "toScreen" | "pxPerMeter" | "upright">,
from: Vertex,
preview: DrawPreview,
compass: { lengthM: number; fan: Fan } | 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]);
paintFan(context, map, from, compass.lengthM, compass.fan);
}
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, preview.blocked ? colors.warn : slopeColor(preview.slopePct), 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: Pick<ScreenMap, "upright">,
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);
});
}