feat(B05 평면): 맴돔 = 고름 20 m 법선 · 고른 곡선 3점 · 곡선 없는 교각점 삼각형 · 잠금 황금 (391)

- 391-1 측점 맴돔 · 고름 = 한 함수(drawStationNormal) · 좌우 20 m 고정 하늘색 · 짧은 12 px 선 · 절성토 색 지움 · 구조물 마름모 그대로
- 391-2 고른 곡선 = 굵은 호 + 교각점→BC · EC 접선 + BC · MC · EC 세 점(새 파일 RouteEdit_Curve_Pick · MC = curveMidVertex) · 곡선 없는 교각점 = 같은 크기 삼각형
- 391-3 잠금 = 황금(#ca8a04 · 어두움 #facc15) · 굵기 4 + 흰 테
- 시험 test_391_routeedit_plan_draw 새로 · test_381_3 맴돔 배선 고쳐 씀

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013QyNXY2TUKXCFMwAca2APG
This commit is contained in:
2026-10-17 17:16:22 +09:00
co-authored by Claude Opus 5.5
parent 1b327b3613
commit a0a29fd002
8 changed files with 397 additions and 99 deletions
@@ -0,0 +1,74 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Curve_Pick.ts
* 노선 편집 평면 **고른 곡선 밝힘 · 곡선 없는 교각점 삼각형**(요구 242-12 · 13 · PLAN 391-2).
*
* 고른 곡선(교각점 고름 · 호 누름 = 같은 `picked`) = 호 굵게 + 교각점→BC · EC 접선 + BC · MC · EC
* 세 점(하늘색). `_Render` 가 노드 · 손잡이 뒤 맨 위에서 부름 — 집기(`_Input`)는 그대로.
* ========================================================================== */
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import { curveArcVertices, curveMidVertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
/** 고름 색 — 측점 법선(`_Hover`)과 같은 하늘색. */
const PICK_COLOR = "#0ea5e9";
/** 삼각형 외접 반지름 = 노드 반지름 × 이 값 — 원과 넓이가 비슷해 보이게. */
const TRIANGLE_SCALE = 1.4;
/** 곡선 없는 교각점 삼각형(391-2) — 꼭짓점 위 · 원 대신 같은 자리 · 크기. 경로만 그림. */
export function traceNodeTriangle(
context: CanvasRenderingContext2D,
x: number,
y: number,
radius: number,
): void {
const r = radius * TRIANGLE_SCALE;
context.moveTo(x, y - r);
context.lineTo(x + r * Math.cos(Math.PI / 6), y + r / 2);
context.lineTo(x - r * Math.cos(Math.PI / 6), y + r / 2);
context.closePath();
}
/** 고른 곡선 밝힘(391-2) — 곡선을 지운 교각점 · 고름 없음이면 안 그림. */
export function drawPickedCurve(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
style: CSSStyleDeclaration,
): void {
const curve = scene.curveInfo.find((item) => item.node_first === scene.picked);
if (!curve || scene.curveOn[curve.node_first] === false) return;
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
const path = (points: ReadonlyArray<Vertex>): void => {
context.beginPath();
points.forEach((vertex, index) => {
const [x, y] = scene.toScreen(vertex);
if (index === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
};
context.save();
context.strokeStyle = PICK_COLOR;
context.lineWidth = 2;
context.setLineDash([6, 4]);
path([curve.start, curve.apex, curve.end]);
context.stroke();
context.setLineDash([]);
path(curveArcVertices(curve));
context.strokeStyle = halo;
context.lineWidth = 8;
context.stroke();
context.strokeStyle = PICK_COLOR;
context.lineWidth = 5;
context.stroke();
context.fillStyle = PICK_COLOR;
context.strokeStyle = halo;
context.lineWidth = 2;
[curve.start, curveMidVertex(curve), curve.end].forEach((point) => {
const [x, y] = scene.toScreen(point);
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.stroke();
});
context.restore();
}
+48 -25
View File
@@ -3,7 +3,8 @@
* 노선 편집 평면 **맴돔 밝힘**(요구 241-14 · PLAN 381-3).
*
* 맴도는 자리는 `_Pointer` 가 `_Input.pickAtScreen`(집기와 같은 셈)으로 적고, 여기서 이길 쪽만
* 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. `_Render` 가 노드 그린 뒤 부름.
* 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 고른 측점과 같은 좌우 20 m 법선(`drawStationNormal` ·
* 391-1). `_Render` 가 노드 그린 뒤 부름.
* ========================================================================== */
import { pickHover } from "./B05_Profile_UI_RouteEdit_Input";
@@ -11,10 +12,52 @@ import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
/** 횡단 눈금 맴돔 색 — 횡단 반폭 선(`_Render`)과 같은 하늘색. */
const STATION_HOVER_COLOR = "#0ea5e9";
/** 측점 법선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
const STATION_NORMAL_COLOR = "#0ea5e9";
/** 법선 좌우 길이(m) — 서버 `SECTION_CROSS_HALF_WIDTH_M` 과 같은 값 · 고정(391-1). */
const STATION_NORMAL_HALF_M = 20;
/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. */
/** 측점 법선(391-1) — 맴돔 · 고름 한 그림: 측점에서 노선에 직각 좌우 20 m · 끝 점 · 측점 고리.
* 방향은 지금 그려진 노선으로 매 프레임 다시 짚음(`stationFrameAt`) — 끄는 동안 바로 따라감. */
export function drawStationNormal(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
chainageM: number,
): void {
const frame = stationFrameAt(line, chainageM);
if (!frame) return;
const [ox, oy] = frame.origin;
const [lx, ly] = frame.left;
const half = STATION_NORMAL_HALF_M;
const [cx, cy] = scene.toScreen(frame.origin);
const [ax, ay] = scene.toScreen([ox + lx * half, oy + ly * half]);
const [bx, by] = scene.toScreen([ox - lx * half, oy - ly * half]);
context.save();
context.setLineDash([]);
context.strokeStyle = STATION_NORMAL_COLOR;
context.fillStyle = STATION_NORMAL_COLOR;
context.lineWidth = 2.4;
context.beginPath();
context.moveTo(ax, ay);
context.lineTo(bx, by);
context.stroke();
for (const [x, y] of [
[ax, ay],
[bx, by],
]) {
context.beginPath();
context.arc(x, y, 3, 0, Math.PI * 2);
context.fill();
}
context.lineWidth = 2;
context.beginPath();
context.arc(cx, cy, 6, 0, Math.PI * 2);
context.stroke();
context.restore();
}
/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 측점 법선. */
export function drawPickHover(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
@@ -41,27 +84,7 @@ export function drawPickHover(
context.stroke();
}
} else {
const frame = stationFrameAt(line, hover.chainage);
if (frame) {
const [ax, ay] = scene.toScreen(frame.origin);
const [bx, by] = scene.toScreen([
frame.origin[0] + frame.left[0],
frame.origin[1] + frame.left[1],
]);
const length = Math.hypot(bx - ax, by - ay) || 1;
const ux = ((bx - ax) / length) * 12;
const uy = ((by - ay) / length) * 12;
context.strokeStyle = STATION_HOVER_COLOR;
context.fillStyle = STATION_HOVER_COLOR;
context.lineWidth = 3;
context.beginPath();
context.moveTo(ax - ux, ay - uy);
context.lineTo(ax + ux, ay + uy);
context.stroke();
context.beginPath();
context.arc(ax, ay, 4, 0, Math.PI * 2);
context.fill();
}
drawStationNormal(context, scene, line, hover.chainage);
}
context.restore();
}
+39 -70
View File
@@ -26,7 +26,8 @@ import {
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input";
import { drawPickHover } from "./B05_Profile_UI_RouteEdit_Hover";
import { drawPickHover, drawStationNormal } from "./B05_Profile_UI_RouteEdit_Hover";
import { drawPickedCurve, traceNodeTriangle } from "./B05_Profile_UI_RouteEdit_Curve_Pick";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
@@ -37,8 +38,8 @@ import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
const NODE_R = 6;
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */
const LOCKED_WIDTH = 3;
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 굵게 · 흰 테(`--map-halo`)를 밑에 깔아 도드라지게(391-3). */
const LOCKED_WIDTH = 4;
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
*
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
@@ -52,8 +53,6 @@ const CURVE_HANDLE_PX = 5;
const OUTWARD_PX = 26;
/** 구간 재기 표시 색 — 노선(주황)·등고선(연보라)·고른 등고선(보라)과 겹치지 않는 초록. */
const MEASURE_COLOR = "#22c55e";
/** 횡단 반폭 선 색 — 노선(주황)·재기(초록)·등고선(연보라)과 겹치지 않는 하늘색. */
const CROSS_WIDTH_COLOR = "#0ea5e9";
/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */
export function polylineLengthM(points: ReadonlyArray<Vertex>): number {
@@ -245,8 +244,9 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777";
strokeLockedRoute(context, scene, line, lockedColor);
const lockedColor = style.getPropertyValue("--map-route-locked") || "#ca8a04";
const halo = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
strokeLockedRoute(context, scene, line, lockedColor, halo);
drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
drawStationMarks(context, scene, line);
@@ -270,21 +270,23 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
: scene.locked[index]
? lockedColor
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
context.stroke();
// 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다.
if (
scene.curveOn.length &&
// 곡선 없는 교각점(155° 생략 · 손 지움)은 같은 크기 삼각형 · 가운데 비움(391-2).
const noCurve =
scene.curveOn.length > 0 &&
!scene.curveOn[index] &&
index > 0 &&
index < scene.planned.length - 1
) {
index < scene.planned.length - 1;
const radius = index === scene.picked ? NODE_R + 2 : NODE_R;
context.beginPath();
if (noCurve) traceNodeTriangle(context, x, y, radius);
else context.arc(x, y, radius, 0, Math.PI * 2);
context.fill();
context.stroke();
if (noCurve) {
context.save();
context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
context.fillStyle = halo;
context.beginPath();
context.arc(x, y, NODE_R - 2, 0, Math.PI * 2);
traceNodeTriangle(context, x, y, NODE_R - 2);
context.fill();
context.restore();
}
@@ -314,6 +316,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
});
});
context.restore();
drawPickedCurve(context, scene, style);
drawPickHover(context, scene, line, style, NODE_R);
context.restore(); // 회전 끝
}
@@ -392,14 +395,8 @@ function drawMeasureMarks(
context.restore();
}
/**
* 고른 측점의 **좌·우 법선**(382-2) — 측점에서 노선에 직각, 길이 = 그 측점 발자국 비탈 끝 좌 · 우 ·
* 색 = 깎기(절토) · 쌓기(성토) 토큰(`_Footprint` 와 같은 것) · 기록이 없으면 「횡단 반폭」 양쪽 파랑 ·
* 측점 자리에 고리(눈금 강조). 구조물 · 관 고름 = 그 자리 마름모(배수유역도 `markStation` 꼴)만.
*
* 자리·방향은 **지금 그려진 노선**으로 매 프레임 다시 짚는다(`stationFrameAt` — 서버
* `generate_sections` 짝) — 노드를 끄는 동안 선이 네트워크 없이 바로 따라간다.
*/
/** 고른 측점 — 맴돔과 같은 좌우 20 m 법선(391-1 · `drawStationNormal`) · 구조물 · 관 고름 =
* 그 자리 마름모(배수유역도 `markStation` 꼴)만. 절성토 색은 발자국 선이 보여 줌. */
function drawCrossWidth(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
@@ -407,53 +404,23 @@ function drawCrossWidth(
): void {
const watch = scene.crossWidth;
if (!watch) return;
if (!watch.structure) {
drawStationNormal(context, scene, line, watch.chainageM);
return;
}
const frame = stationFrameAt(line, watch.chainageM);
if (!frame) return;
const [ox, oy] = frame.origin;
const [lx, ly] = frame.left;
const [cx, cy] = scene.toScreen(frame.origin);
context.save();
context.setLineDash([]);
if (watch.structure) {
context.translate(cx, cy);
context.rotate(Math.PI / 4);
context.beginPath();
context.rect(-7, -7, 14, 14);
context.fillStyle = "rgba(255, 196, 0, 0.35)";
context.fill();
context.lineWidth = 2.5;
context.strokeStyle = "#ff9800";
context.stroke();
context.restore();
return;
}
const style = getComputedStyle(document.documentElement);
const tone = (cut: boolean | undefined): string =>
cut === undefined
? CROSS_WIDTH_COLOR
: style.getPropertyValue(cut ? "--b05-footprint-cut" : "--b05-footprint-fill").trim() ||
(cut ? "#dc2626" : "#2563eb");
const ends: Array<[Vertex, string]> = [
[[ox + lx * watch.leftM, oy + ly * watch.leftM], tone(watch.leftCut)],
[[ox - lx * watch.rightM, oy - ly * watch.rightM], tone(watch.rightCut)],
];
context.lineWidth = 2.4;
ends.forEach(([end, color]) => {
const [x, y] = scene.toScreen(end);
context.strokeStyle = color;
context.fillStyle = color;
context.beginPath();
context.moveTo(cx, cy);
context.lineTo(x, y);
context.stroke();
context.beginPath();
context.arc(x, y, 3, 0, Math.PI * 2);
context.fill();
});
context.strokeStyle = CROSS_WIDTH_COLOR;
context.lineWidth = 2;
context.translate(cx, cy);
context.rotate(Math.PI / 4);
context.beginPath();
context.arc(cx, cy, 6, 0, Math.PI * 2);
context.rect(-7, -7, 14, 14);
context.fillStyle = "rgba(255, 196, 0, 0.35)";
context.fill();
context.lineWidth = 2.5;
context.strokeStyle = "#ff9800";
context.stroke();
context.restore();
}
@@ -597,6 +564,7 @@ function strokeLockedRoute(
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
color: string,
halo: string,
): void {
const { planned, locked } = scene;
if (!locked.some(Boolean)) return;
@@ -616,9 +584,10 @@ function strokeLockedRoute(
curveAt.forEach((curve, index) => {
if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end));
});
pieces.forEach((piece) =>
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH),
);
pieces.forEach((piece) => {
strokePolyline(context, scene.toScreen, piece, [], halo, LOCKED_WIDTH + 3);
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH);
});
}
/** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */
@@ -196,6 +196,23 @@ export function curveArcVertices(curve: EditedCurve): Vertex[] {
return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end];
}
/** 곡선 가운데 MC — 교각점에서 이등분선 따라 외할(R/sin(I/2) − R). 화면 표시 전용 · 짝 아님(391-2). */
export function curveMidVertex(curve: EditedCurve): Vertex {
const toStart = unit(curve.apex, curve.start);
const toEnd = unit(curve.apex, curve.end);
const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]];
const bisectorLength = Math.hypot(bisector[0], bisector[1]);
const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2);
if (bisectorLength <= 1e-9 || !(half > 1e-9)) {
return [(curve.start[0] + curve.end[0]) / 2, (curve.start[1] + curve.end[1]) / 2];
}
const external = curve.radius_m / half - curve.radius_m;
return [
curve.apex[0] + (bisector[0] / bisectorLength) * external,
curve.apex[1] + (bisector[1] / bisectorLength) * external,
];
}
/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */
export interface EditedNode {
inner_angle_deg: number | null;
@@ -0,0 +1,117 @@
/* 391 B05 평면 그림 단위검증 헬퍼 — 측점 법선(`_Hover.drawStationNormal`) · 고른 곡선 밝힘 ·
* 삼각형(`_Curve_Pick`) · MC 점(`logic/_Curve.curveMidVertex`)을 트랜스파일해 가짜 캔버스로 돌린다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const B05 = path.join(__dirname, "..", "..", "B05_Profile");
const STUBS = { "./B05_Profile_UI_RouteEdit_Input": { pickHover: () => null } };
const cache = new Map();
function loadTs(full) {
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
/** 그린 것만 적는 가짜 캔버스 — 선 = moveTo/lineTo 점 · 점 = arc 가운데 · 획 색. */
function fakeContext() {
const log = { lines: [], dots: [], strokes: [] };
let path = [];
const ctx = {
save() {},
restore() {},
setLineDash() {},
beginPath: () => (path = []),
moveTo: (x, y) => path.push([x, y]),
lineTo: (x, y) => path.push([x, y]),
closePath() {},
arc: (x, y, r) => log.dots.push([x, y, r]),
stroke: () => {
log.strokes.push(ctx.strokeStyle);
if (path.length) log.lines.push({ color: ctx.strokeStyle, width: ctx.lineWidth, path });
},
fill() {},
};
return { ctx, log, path: () => path };
}
const round = (v) => Math.round(v * 1000) / 1000;
const hover = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Hover.ts"));
const pick = loadTs(path.join(B05, "B05_Profile_UI_RouteEdit_Curve_Pick.ts"));
const curve = loadTs(path.join(B05, "logic", "B05_Profile_UI_RouteEdit_Curve.ts"));
const style = { getPropertyValue: () => "" };
const out = {};
// 측점 법선 — 동서 노선 50 m 자리 · 화면 = 2 px/m
const scene = { toScreen: ([x, y]) => [x * 2, y * 2] };
const normal = fakeContext();
hover.drawStationNormal(
normal.ctx,
scene,
[
[0, 0],
[100, 0],
],
50,
);
out.normal = normal.log.lines.map((l) => ({
color: l.color,
path: l.path.map((p) => p.map(round)),
}));
// 직각 곡선 R 10 — 교각점 (0,0) · BC (-10,0) · EC (0,10)
const right = {
apex: [0, 0],
radius_m: 10,
tangent_m: 10,
inner_angle_deg: 90,
start: [-10, 0],
end: [0, 10],
node_first: 1,
node_last: 1,
violations: [],
};
out.mid = curve.curveMidVertex(right).map(round);
const unit = { toScreen: ([x, y]) => [x, y] };
const picked = fakeContext();
pick.drawPickedCurve(
picked.ctx,
{ ...unit, picked: 1, curveInfo: [right], curveOn: [true, true, true] },
style,
);
out.picked = {
dots: picked.log.dots.map(([x, y]) => [round(x), round(y)]),
tangent: picked.log.lines[0].path,
arcWidths: picked.log.lines.slice(1).map((l) => l.width),
};
const off = fakeContext();
pick.drawPickedCurve(
off.ctx,
{ ...unit, picked: 1, curveInfo: [right], curveOn: [true, false, true] },
style,
);
const none = fakeContext();
pick.drawPickedCurve(
none.ctx,
{ ...unit, picked: -1, curveInfo: [right], curveOn: [true, true, true] },
style,
);
out.offDrawn = off.log.strokes.length + none.log.strokes.length;
const tri = fakeContext();
pick.traceNodeTriangle(tri.ctx, 100, 100, 6);
out.triangle = tri.path().map((p) => p.map(round));
console.log(JSON.stringify(out));
@@ -47,4 +47,9 @@ def test_집기_맴돔_한_함수():
def test_맴돔_그림():
hover = _read("B05_Profile_UI_RouteEdit_Hover.ts")
assert "pickHover(context.canvas)" in hover
assert '"node" in hover' in hover and "stationFrameAt(line, hover.chainage)" in hover
assert (
'"node" in hover' in hover
and "drawStationNormal(context, scene, line, hover.chainage)" in hover
)
# 391-1 맴돔 = 고름 한 함수 · 좌우 20 m 고정
assert "const STATION_NORMAL_HALF_M = 20;" in hover
@@ -0,0 +1,93 @@
"""391 B05 평면 그림(요구 242-5 · 6 · 12 · 13 · 15) — 20 m 법선 · 곡선 3점 · 삼각형 · 잠금 색.
Node 헬퍼(`helper_391_routeedit_plan_draw.cjs`)가 TS 를 트랜스파일해 가짜 캔버스로 돌림.
./venv/Scripts/python.exe -m pytest resources/tester/test_391_routeedit_plan_draw.py -q
"""
import json
import math
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
B05 = ROOT / "B05_Profile"
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", str(HERE / "helper_391_routeedit_plan_draw.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def _read(name: str) -> str:
return (B05 / name).read_text(encoding="utf-8")
def test_법선은_좌우_20m_하늘색(results):
line = results["normal"][0]
assert line["color"] == "#0ea5e9"
# 2 px/m · 동서 노선 50 m 자리 → 좌(북) 20 m · 우(남) 20 m
assert line["path"] == [[100, 40], [100, -40]]
def test_맴돔과_고름이_한_함수():
hover = _read("B05_Profile_UI_RouteEdit_Hover.ts")
assert "drawStationNormal(context, scene, line, hover.chainage);" in hover
assert "* 12" not in hover # 짧은 12 px 선 지움
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
assert "drawStationNormal(context, scene, line, watch.chainageM);" in render
assert "leftCut" not in render and "--b05-footprint-cut" not in render
assert 'context.strokeStyle = "#ff9800";' in render # 구조물 마름모 그대로
def test_MC_는_호_가운데(results):
r = 10 * (1 - 1 / math.sqrt(2))
assert results["mid"] == [round(-r, 3), round(r, 3)]
def test_고른_곡선_세_점_접선_굵은_호(results):
picked = results["picked"]
assert picked["dots"][0] == [-10, 0] and picked["dots"][2] == [0, 10]
assert picked["dots"][1] == results["mid"]
assert picked["tangent"] == [[-10, 0], [0, 0], [0, 10]]
assert picked["arcWidths"] == [8, 5] # 흰 테 · 하늘색
def test_곡선_없음_고름_없음은_안_그림(results):
assert results["offDrawn"] == 0
def test_곡선_없는_교각점은_삼각형(results):
tri = results["triangle"]
assert len(tri) == 3 and tri[0] == [100, round(100 - 6 * 1.4, 3)]
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
assert "if (noCurve) traceNodeTriangle(context, x, y, radius);" in render
assert "drawPickedCurve(context, scene, style);" in render
assert render.index("drawPickedCurve(") < render.index("drawPickHover(context")
def test_잠금_황금_굵기4_흰테():
css = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8")
assert css.count("--map-route-locked: #ca8a04;") == 1
assert css.count("--map-route-locked: #facc15;") == 2 # 어두움 · 시스템 어두움
render = _read("B05_Profile_UI_RouteEdit_Render.ts")
assert "const LOCKED_WIDTH = 4;" in render
assert "piece, [], halo, LOCKED_WIDTH + 3);" in render
def test_파일_700줄_안():
for name in (
"B05_Profile_UI_RouteEdit_Render.ts",
"B05_Profile_UI_RouteEdit_Hover.ts",
"B05_Profile_UI_RouteEdit_Curve_Pick.ts",
):
assert len(_read(name).splitlines()) <= 700, name
+3 -3
View File
@@ -232,7 +232,7 @@
* 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다.
* ------------------------------------------------------------------------ */
--map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */
--map-route-locked: #db2777; /* 계획노선 편집 — 고정한 선 · 호 · 점 (주황 · 초록 · 연보라 · 하늘색과 구별) */
--map-route-locked: #ca8a04; /* 계획노선 편집 — 고정한 선 · 호 · 점 황금 (평면의 주황 · 절토 붉음 · 성토 청록 · 하늘색과 구별) */
--map-cadastral: #f97316;
--map-sigungu: #7c3aed;
--map-eupmyeondong: #22c55e;
@@ -333,7 +333,7 @@
--color-warning: #e0b64a;
--color-danger: #e8695a;
--panel-handle-border: #e0b64a;
--map-route-locked: #f472b6;
--map-route-locked: #facc15;
--shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px;
--shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px;
@@ -374,7 +374,7 @@
--color-success: #3fbb6f;
--color-danger: #e8695a;
--panel-handle-border: #e0b64a;
--map-route-locked: #f472b6;
--map-route-locked: #facc15;
}
}