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:
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user