Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_4' into main_laptop_1

This commit is contained in:
2026-10-31 22:29:50 +09:00
7 changed files with 86 additions and 35 deletions
+31 -14
View File
@@ -36,6 +36,7 @@ import {
import type { DrawPreview } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw";
import { planColors } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
import { requestJson, type RoutePlanResponse } from "./B05_Profile_Api_Replan";
import type { MapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { loadDesignTerrainGrid, type DesignTerrainGrid } from "./B05_Profile_Api_TerrainGrid";
import { flattenServerPlan, type FlattenedPlan } from "./logic/B05_Profile_UI_RouteEdit_Edits";
import {
@@ -63,10 +64,9 @@ export interface ExpectedDrawParams {
host: HTMLElement;
toScreen: (vertex: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
/** 보이는 화면 px → 그리기 px(돌린 지도). */
unrotate: (px: number, py: number) => [number, number];
/** 지도 돌림 — 보이는 px ↔ 그리기 px · 글자 세울 각(만든 뒤에 서므로 함수로). */
rotation: () => Pick<MapRotation, "unrotate" | "rerotate" | "uprightRad">;
pxPerMeter: () => number;
uprightRad: () => number;
redraw: () => void;
/** 판을 켬 — 편집기가 고른 노드 · 곡선 판을 풂. */
onOpen: () => void;
@@ -105,6 +105,8 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
let busy = false;
let stroke: Stroke | null = null;
let preview: DrawPreview | null = null;
/** 확인창이 떠 있는 동안 보일 새 예정노선(묻는 동안 무엇이 바뀌는지 보이게). */
let asking: Vertex[] | null = null;
let fan: Fan | null = null;
let hoverAt: { px: number; py: number; shift: boolean } | null = null;
let grid: DesignTerrainGrid | null = null;
@@ -155,7 +157,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
canvas.style.removeProperty("cursor");
if (open) {
params.onOpen();
void loadDesignTerrainGrid(params.projectId).then((loaded) => (grid = loaded));
void loadDesignTerrainGrid(params.projectId).then((loaded) => ((grid = loaded), rehover()));
}
params.redraw();
}
@@ -268,6 +270,8 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
/** 다시 추적 — 확인창 → `/route/follow`(저장 0) → 편집기 한 걸음. `next` = [] 면 파일입력 예정노선. */
async function follow(next: Vertex[]): Promise<void> {
stop();
asking = next.length ? next : original;
params.redraw();
const ok = await askConfirm({
title: "평면선 다시 추적",
message:
@@ -276,6 +280,8 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
"[저장] 전까지는 되돌리기로 물릴 수 있습니다.",
okLabel: "다시 추적",
});
asking = null;
params.redraw();
if (!ok) return;
busy = true;
try {
@@ -308,7 +314,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
// ── 누름 · 키 — 판이 켜진 동안 먼저 받음(계획선 · 구조물 · 재기로 안 샘) ──
const at = (event: MouseEvent): [number, number] => {
const rect = canvas.getBoundingClientRect();
return params.unrotate(event.clientX - rect.left, event.clientY - rect.top);
return params.rotation().unrotate(event.clientX - rect.left, event.clientY - rect.top);
};
const claim = (event: Event): void => {
event.stopImmediatePropagation();
@@ -376,7 +382,7 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
toScreen: params.toScreen,
pxPerMeter: 1,
upright(context: CanvasRenderingContext2D, x: number, y: number, paint: () => void): void {
const radians = params.uprightRad();
const radians = params.rotation().uprightRad();
if (!radians) return paint();
context.save();
context.translate(x, y);
@@ -405,8 +411,8 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
path(context, line()); // 고치는 예정노선 = 맨 위(D9)
strokeCased(context, colors.measure, 2.5, [8, 5]);
const points = strokePoints();
if (points.length > 1) {
path(context, points);
if ((asking ?? points).length > 1) {
path(context, asking ?? points); // 묻는 동안 = 새 예정노선 통째
strokeCased(context, colors.active, 3);
}
points.forEach((point, i) => {
@@ -421,6 +427,22 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
context.restore();
}
const state = (): ReturnType<ExpectedDraw["state"]> => ({
open,
stroke: stroke?.mode ?? null,
points: strokePoints().length,
preview,
});
// 검증 창구 — 판 상태 · 보이는 예정노선 · 사업지 좌표 → 보이는 화면 px
Object.assign(canvas, {
expectedProbe: {
state,
line,
edit: () => edit,
screen: (vertex: Vertex) => params.rotation().rerotate(...params.toScreen(vertex)),
},
});
return {
load(nextOriginal, nextSaved) {
original = nextOriginal;
@@ -432,11 +454,6 @@ export function createExpectedDraw(params: ExpectedDrawParams): ExpectedDraw {
},
line,
paint,
state: () => ({
open,
stroke: stroke?.mode ?? null,
points: strokePoints().length,
preview,
}),
state,
};
}
+1 -2
View File
@@ -126,9 +126,8 @@ export async function mountRouteEditor(
host: overlay.querySelector<HTMLElement>(".b05-routeedit__tools")!,
toScreen: (vertex) => toScreen(vertex),
toMetric: (px, py) => toMetric(px, py),
unrotate: (px, py) => rotation.unrotate(px, py),
rotation: () => rotation,
pxPerMeter: () => pxPerMeter(),
uprightRad: () => rotation.uprightRad(),
redraw: () => draw(),
onOpen: () => ((picked = -1), syncCurveBar()),
onFollow: (flat, line) =>
@@ -489,7 +489,10 @@ html.is-bm-route .b05-routeedit__tools {
}
/* 626-3 예정노선 그리기 판 — 도구 줄 아래(떠 있는 판) · 단추 줄 */
/* 도구 줄 칸(세로 줄) 안 차례대로 — 판 기본 absolute · 높이 100 % 를 풂 */
.b05-routeedit__expected-draw {
position: relative;
max-height: none;
pointer-events: auto;
width: 300px;
}
@@ -36,6 +36,8 @@ import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw";
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;
@@ -213,24 +215,33 @@ export function paintNetwork(
);
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)
paintNodes(context, map, network.lines, z);
drawPieces(context, map, network);
extra();
}
/** 꺾임점 = 들어온 구간 기울기 부호 색(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, 3, 0, Math.PI * 2);
context.fillStyle = slopeColor(groundSlopePct(line.vertices[i - 1], v, z));
context.fill();
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, 7, 0, Math.PI * 2);
context.arc(sx, sy, NODE_DOT_PX + 4, 0, Math.PI * 2);
context.lineWidth = 2;
context.strokeStyle = planColors().warn;
context.stroke();
});
drawPieces(context, map, network);
extra();
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
+13 -9
View File
@@ -20,7 +20,7 @@
import { showToast } from "@ui/ui_template_elements";
import { themeColor } from "@ui/ui_template_palette";
import { keyIs } from "../A00_Common/key_registry";
import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw";
import { distanceAlong, pointAtDistance, type HeightAt } from "./logic/D03_PlanAnalysis_Draw";
import {
insertVertex,
moveMark,
@@ -46,7 +46,9 @@ import {
} from "./logic/D03_PlanAnalysis_Select";
import {
lineAt,
NODE_DOT_PX,
paintMark,
paintNodes,
planLabel,
screenGap,
SNAP_PX,
@@ -70,6 +72,8 @@ export interface PlanEditParams {
/** 지점(긋기에 붙는 점 · 591-2). */
base: () => Vertex[];
commit: (next: PlanNetwork) => void;
/** 원지반 높이(고른 선 꺾임점 부호 색 · 625-3). */
heightAt: () => HeightAt;
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
show: (next: PlanNetwork) => void;
/** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */
@@ -329,18 +333,18 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
context.stroke();
strokeCased(context, colors.active, PICK_LINE_PX);
}
// 꺾임점 손잡이(고른 것이 든 선)
// 꺾임점 손잡이(고른 것이 든 선) = 부호 색 점(고름 강조 위에 다시) + 감싸는 빈 네모(625-3)
const lines = pickedLines(network);
context.lineWidth = 2;
context.fillStyle = "#ffffff";
for (const line of network.lines) {
if (!lines.has(line.id)) continue;
const handled = network.lines.filter((line) => lines.has(line.id));
paintNodes(context, map, handled, params.heightAt());
const half = NODE_DOT_PX + 3;
for (const line of handled)
for (const point of line.vertices) {
const [sx, sy] = map.toScreen(point);
context.fillRect(sx - 4, sy - 4, 8, 8);
context.strokeRect(sx - 4, sy - 4, 8, 8);
context.beginPath();
context.rect(sx - half, sy - half, half * 2, half * 2);
strokeCased(context, colors.active, 2);
}
}
// 고른 노드 · 마크 테
const node = picked?.kind === "node" ? picked.node : null;
const nodePoint = node
@@ -146,6 +146,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
base: () => points,
commit,
heightAt: () => z,
show: (next) => {
network = normalizePieces(next, yearStart);
map.redraw();
+16
View File
@@ -159,3 +159,19 @@ def test_가파른_땅_지형_밖_고를_자리_없음(r):
for key in ("steep", "offTerrain"):
f = r[key]
assert not f["flat"] and not f["nodes"] and not f["arcs"]
def test_놓인_노드_부호_색_선_위_고름_위에도_보임():
"""625-3 — 놓인 꺾임점 = 선 테(3 + 4 px)보다 굵은 부호 색 점 + 검은 테 · 고른 선은 노랑 강조 위에 점을 다시 그리고 손잡이 = 빈 네모."""
paint = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_DrawPaint.ts").read_text(
encoding="utf-8"
)
edit = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Edit.ts").read_text(encoding="utf-8")
assert int(re.search(r"NODE_DOT_PX = (\d+)", paint).group(1)) * 2 > 3 + 4
body = paint[paint.index("export function paintNodes") :]
body = body[: body.index("\n}\n")]
assert "fillCased(context, slopeColor(groundSlopePct(" in body
assert "paintNodes(context, map, network.lines, z)" in paint
draw = edit[edit.index("draw(context, map) {") :]
assert draw.index("sel.paths()") < draw.index("paintNodes(") < draw.index("context.rect(")
assert "fillRect" not in draw[: draw.index("고른 노드 · 마크 테")]