feat(D03): 예정노선 그리기 — 시점 · 종점 여럿 · 들어올 때 자동 받기 · [긋기] 양끝 · 부분 수정 · 기울기 % · ° 미리보기 · 방향 바꾸기 · 선 고르기 · 지우기 · 연도 (PLAN 579-3 · 580-1~3 · 요구 440 · 441)
- 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함) - 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험 - 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율) - 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기] - 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기 - 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔) - UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐) - 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,258 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_DrawPaint.ts
|
||||
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
|
||||
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
|
||||
* 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
|
||||
* ========================================================================== */
|
||||
|
||||
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 { planColors, PLAN_STATION_INTERVAL_M, 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;
|
||||
|
||||
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` = 그리는 선(굵은 보라). */
|
||||
export function paintNetwork(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
network: PlanNetwork,
|
||||
base: { starts: Vertex[]; ends: Vertex[] },
|
||||
active: number | null,
|
||||
z: HeightAt,
|
||||
extra: () => void,
|
||||
): void {
|
||||
const { starts, ends } = base;
|
||||
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 : colors.line;
|
||||
context.lineWidth = line.branch ? 2.5 : 3;
|
||||
context.beginPath();
|
||||
line.vertices.forEach((v, i) => {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
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);
|
||||
});
|
||||
// 꺾임점
|
||||
context.fillStyle = "#ffffff";
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
const [sx, sy] = map.toScreen(v);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
drawPieces(context, map, network);
|
||||
extra();
|
||||
// 시점(초록) · 종점(빨강)
|
||||
for (const [points, fill] of [
|
||||
[starts, "#16a34a"],
|
||||
[ends, "#dc2626"],
|
||||
] as const)
|
||||
for (const point of points) {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 7, 0, Math.PI * 2);
|
||||
context.fillStyle = fill;
|
||||
context.fill();
|
||||
context.lineWidth = 2;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m));
|
||||
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 = colors.active;
|
||||
context.fill();
|
||||
context.lineWidth = 1.5;
|
||||
context.strokeStyle = "#ffffff";
|
||||
context.stroke();
|
||||
}
|
||||
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 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.strokeStyle = colors.active;
|
||||
context.lineWidth = 1;
|
||||
context.setLineDash([3, 4]);
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
context.fillStyle = colors.active;
|
||||
for (const c of compass.candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
}
|
||||
}
|
||||
context.strokeStyle = preview.blocked ? colors.warn : colors.active;
|
||||
context.lineWidth = 2;
|
||||
context.setLineDash([6, 4]);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
context.stroke();
|
||||
context.setLineDash([]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
context.stroke();
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user