Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts
T
eomsangdonandClaude Opus 5.5 62a8ee362e refactor(B05 평면 · 지도 공용): RouteEdit 마우스 배선 · 재기 한 벌 떼어냄 · 긋기 방식 칸 공용 자리로 (PLAN 626-3 · 요구 530 · 531)
- B05_Profile_UI_RouteEdit.ts 684 → 655 줄 — 두 번 클릭 삽입 · 오른쪽 클릭 삭제 · 지도 이동 · 휠 = 새 `_RouteEdit_Mouse.ts` · 재기 도구 + 단추 한 벌 = `_Measure.ts` `createRouteMeasure` (동작 그대로)
- 긋기 방식 칸(`createDrawKindFields` · 기본값 · 이름) = 새 `A00_Common/map/map_draw_kind.ts` · `.css` — D03 DrawPanel 은 다시 내보냄(D03 화면 · 동작 같음 · 칸 꼴 선택자만 공용 이름)
- D03 `paintPreview` · `planLabel` 받는 지도 꼴을 쓰는 칸만(Pick)으로 — B05 평면이 그대로 부름
- 시험 test_553_4 — 방식 칸 대조를 공용 파일로 옮김(낡은 자리 대조)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 21:51:58 +09:00

389 lines
14 KiB
TypeScript
Raw 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). */
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", "#29e0ff");
}
/** 부채꼴 호 그림 걸음(라디안 · 약 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);
});
// 꺾임점 = 들어온 구간 기울기 부호 색(625-2 B4) · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
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.fillStyle = slopeColor(groundSlopePct(line.vertices[i - 1], v, z));
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: 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);
});
}