Merge remote-tracking branch 'origin/sub_laptop_4' into sub_laptop_3
This commit is contained in:
@@ -13,6 +13,8 @@
|
||||
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
|
||||
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
|
||||
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
|
||||
* 길이·기울기 고정 부채꼴(625-2 · `logic/D03_PlanAnalysis_Fan`) — 정방향 = 앞 구간(갈래 첫 구간 = 붙은 선) ·
|
||||
* 완만한 땅 = 어느 방향이나 · Shift 누른 채 = 허용 호 위 마우스 방위(길이 그대로 · 기록 = 길이 고정).
|
||||
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
|
||||
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
|
||||
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
|
||||
@@ -24,13 +26,12 @@
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { keyIs } from "../A00_Common/key_registry";
|
||||
import {
|
||||
compassCandidates,
|
||||
compassPoint,
|
||||
fixedLengthPoint,
|
||||
fixedSlopePoint,
|
||||
groundSlopePct,
|
||||
segmentLength,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import { compassFan, fanBetween, fanPick, type Fan } from "./logic/D03_PlanAnalysis_Fan";
|
||||
import {
|
||||
addBranch,
|
||||
appendVertex,
|
||||
@@ -75,6 +76,8 @@ export interface DrawPreview {
|
||||
note: string | null;
|
||||
blocked: boolean;
|
||||
snapped: boolean;
|
||||
/** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅) — 노드 기록 = 길이 고정(B2). */
|
||||
lengthOnly: boolean;
|
||||
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
|
||||
onLine: { line: number; segment: number } | null;
|
||||
}
|
||||
@@ -119,9 +122,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
|
||||
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
|
||||
let preview: DrawPreview | null = null;
|
||||
let candidates: Vertex[] = [];
|
||||
let fan: Fan | null = null;
|
||||
let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9)
|
||||
let hoverAt: { px: number; py: number; map: ScreenMap } | null = null; // Tab 뒤 미리보기 다시
|
||||
let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시
|
||||
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
|
||||
@@ -187,8 +190,34 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
: null;
|
||||
}
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
|
||||
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
|
||||
/** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */
|
||||
function prevOf(): Vertex | null {
|
||||
const network = params.network();
|
||||
if (span) {
|
||||
const vertices = network.lines[spanLine()]?.vertices ?? [];
|
||||
const through = span.through;
|
||||
return through.length > 1
|
||||
? through[through.length - 2]
|
||||
: through.length
|
||||
? vertices[span.from]
|
||||
: (vertices[span.from - 1] ?? null);
|
||||
}
|
||||
const line = active === null ? undefined : network.lines[active];
|
||||
if (!line) return null;
|
||||
if (line.vertices.length > 1) return line.vertices[line.vertices.length - 2];
|
||||
const parent = network.lines.find((other) => other.id === line.branch?.line)?.vertices;
|
||||
if (!parent || !line.branch) return null;
|
||||
const [node, before, after] = [
|
||||
parent[line.branch.vertex],
|
||||
parent[line.branch.vertex - 1],
|
||||
parent[line.branch.vertex + 1],
|
||||
];
|
||||
if (before) return before;
|
||||
return node && after ? [2 * node[0] - after[0], 2 * node[1] - after[1]] : null;
|
||||
}
|
||||
|
||||
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저 · `shift` = 부채꼴 사이 고르기. */
|
||||
function previewAt(px: number, py: number, map: ScreenMap, shift = false): DrawPreview | null {
|
||||
const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined;
|
||||
if (active === null && !spanVertices) return null;
|
||||
const from = spanVertices
|
||||
@@ -197,7 +226,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
if (!from) return null;
|
||||
const z = params.heightAt();
|
||||
const cursor = map.toMetric(px, py);
|
||||
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
|
||||
fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null;
|
||||
// 다시 긋기 = 원래 뒤 노드에만 붙음
|
||||
const end = spanVertices?.[span!.to];
|
||||
const snap = end
|
||||
@@ -207,6 +236,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
: snapAt(px, py, map);
|
||||
let point: Vertex | null = cursor;
|
||||
let note: string | null = null;
|
||||
let lengthOnly = false;
|
||||
if (snap) {
|
||||
[point, note] = [snap.point, snap.note];
|
||||
} else if (kind === "length") {
|
||||
@@ -214,8 +244,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
} else if (kind === "slope") {
|
||||
point = fixedSlopePoint(from, cursor, slopePct, z);
|
||||
if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
||||
} else if (kind === "both") {
|
||||
point = compassPoint(from, cursor, lengthM, slopePct, z);
|
||||
} else if (fan) {
|
||||
point = shift ? fanBetween(fan, from, cursor, lengthM) : fanPick(fan, from, cursor, lengthM);
|
||||
lengthOnly = shift || fan.flat;
|
||||
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
|
||||
}
|
||||
const at = point ?? cursor;
|
||||
@@ -227,6 +258,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
note,
|
||||
blocked: !point || length <= 0,
|
||||
snapped: !!snap,
|
||||
lengthOnly: lengthOnly && !snap,
|
||||
onLine: snap?.onLine ?? null,
|
||||
};
|
||||
}
|
||||
@@ -267,7 +299,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
flip = false;
|
||||
strokeStart = null;
|
||||
preview = null;
|
||||
candidates = [];
|
||||
fan = null;
|
||||
span = null;
|
||||
}
|
||||
|
||||
@@ -390,7 +422,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return false;
|
||||
}
|
||||
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
|
||||
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
|
||||
const record = preview.snapped
|
||||
? nodeRecord("free", 0, 0)
|
||||
: nodeRecord(preview.lengthOnly ? "length" : kind, lengthM, slopePct);
|
||||
if (span) {
|
||||
if (!preview.snapped) {
|
||||
span.through.push(preview.point);
|
||||
@@ -447,18 +481,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
() => {
|
||||
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
|
||||
if (from && preview)
|
||||
paintPreview(
|
||||
context,
|
||||
map,
|
||||
from,
|
||||
preview,
|
||||
kind === "both" ? { lengthM, candidates } : null,
|
||||
);
|
||||
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
|
||||
},
|
||||
dim,
|
||||
);
|
||||
},
|
||||
press(px, py, map, button) {
|
||||
press(px, py, map, button, shift) {
|
||||
if (!editable) return false;
|
||||
if (mode === "draw" && button === 0) {
|
||||
if (active === null && !span) {
|
||||
@@ -467,14 +495,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
params.onMode();
|
||||
return started;
|
||||
}
|
||||
preview = previewAt(px, py, map);
|
||||
preview = previewAt(px, py, map, shift);
|
||||
return addPoint();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
hoverAt = { px, py, map };
|
||||
preview = mode === "draw" ? previewAt(px, py, map) : null;
|
||||
hover(px, py, map, shift) {
|
||||
hoverAt = { px, py, map, shift };
|
||||
preview = mode === "draw" ? previewAt(px, py, map, shift) : null;
|
||||
},
|
||||
leave() {
|
||||
hoverAt = null;
|
||||
@@ -508,7 +536,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
if (mode === "draw" && keyIs(event, "plan.drawKind")) {
|
||||
kind = kind === "both" ? "free" : "both";
|
||||
kindFields.set({ kind });
|
||||
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map);
|
||||
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, hoverAt.shift);
|
||||
return true;
|
||||
}
|
||||
// Shift 누름 = 마우스 안 움직여도 사이 고르기로(뗌은 다음 움직임 · 누름이 Shift 를 다시 봄)
|
||||
if (mode === "draw" && event.key === "Shift" && hoverAt) {
|
||||
hoverAt.shift = true;
|
||||
preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, true);
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
|
||||
|
||||
@@ -3,10 +3,13 @@
|
||||
* 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` = ScreenMap 의 `toScreen` 만 씀 — B05 평면(626-3)이 그대로 씀.
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointColor, routeColor } from "@ui/ui_template_palette";
|
||||
import { pointColor, routeColor, themeColor } from "@ui/ui_template_palette";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
distanceAlong,
|
||||
@@ -17,6 +20,7 @@ import {
|
||||
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";
|
||||
@@ -52,6 +56,54 @@ export function strokeCased(
|
||||
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;
|
||||
@@ -161,8 +213,7 @@ export function paintNetwork(
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices, z);
|
||||
});
|
||||
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
|
||||
context.fillStyle = "#ffffff";
|
||||
// 꺾임점 = 들어온 구간 기울기 부호 색(625-2 B4) · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
|
||||
context.strokeStyle = colors.warn;
|
||||
context.lineWidth = 2;
|
||||
for (const line of network.lines)
|
||||
@@ -171,6 +222,7 @@ export function paintNetwork(
|
||||
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();
|
||||
@@ -274,13 +326,14 @@ export function paintMark(
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
|
||||
/** 미리보기 — 점선 · 길이·기울기 고정 원 · 부채꼴 · 노드 · 글(거리 · 가상 종단 기울기 % · °) ·
|
||||
* 고리 = 고른 점 기울기 부호 색(놓인 뒤 꺾임점과 같은 색). */
|
||||
export function paintPreview(
|
||||
context: CanvasRenderingContext2D,
|
||||
map: ScreenMap,
|
||||
from: Vertex,
|
||||
preview: DrawPreview,
|
||||
compass: { lengthM: number; candidates: Vertex[] } | null,
|
||||
compass: { lengthM: number; fan: Fan } | null,
|
||||
): void {
|
||||
const colors = planColors();
|
||||
const [ax, ay] = map.toScreen(from);
|
||||
@@ -289,12 +342,7 @@ export function paintPreview(
|
||||
context.beginPath();
|
||||
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
|
||||
strokeCased(context, colors.active, 1.5, [3, 4]);
|
||||
for (const c of compass.candidates) {
|
||||
const [cx, cy] = map.toScreen(c);
|
||||
context.beginPath();
|
||||
context.arc(cx, cy, 3.5, 0, Math.PI * 2);
|
||||
fillCased(context, colors.active);
|
||||
}
|
||||
paintFan(context, map, from, compass.lengthM, compass.fan);
|
||||
}
|
||||
const color = preview.blocked ? colors.warn : colors.active;
|
||||
context.beginPath();
|
||||
@@ -303,7 +351,7 @@ export function paintPreview(
|
||||
strokeCased(context, color, 2.5, [6, 4]);
|
||||
context.beginPath();
|
||||
context.arc(bx, by, 5, 0, Math.PI * 2);
|
||||
strokeCased(context, color, 2.5);
|
||||
strokeCased(context, preview.blocked ? colors.warn : slopeColor(preview.slopePct), 2.5);
|
||||
const value =
|
||||
`${fmt(preview.lengthM)} m · ` +
|
||||
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Fan.ts
|
||||
* D 예정노선 길이·기울기 고정 긋기 — 부채꼴 고르기 셈(PLAN 625-2 · 요구 530 · 531 B1~B7).
|
||||
* 노드 = 반지름 L 원 위 |기울기| = s 자리(양각기 후보 · 562-1 짝 `compassCandidates`) · 부호 = 오르막 + · 내리막 −.
|
||||
* 부채꼴 = 각도 차례 이웃 두 노드 사이 호 중 가운데가 |기울기| ≤ s 인 호(노드 2 · 4 · 6 · 8 개 모두 같은 규칙).
|
||||
* 정방향 = 앞 구간 방향과 가장 가까운 호(앞 구간 없음 = 없음 · 거의 같으면 마우스 쪽) · 나머지 = 역방향.
|
||||
* 완만한 땅(노드 0 · 원 전체 ≤ s) = 어느 방향이나(길이 고정처럼).
|
||||
* Shift 사이 고르기 = 마우스 방위를 허용 호 안으로 묶음 · 길이 그대로 · 기울기 = 그 자리 실제 지형.
|
||||
* 화면 고르기 전용 — 서버는 [저장] 때 꼭짓점으로 다시 셈(파이썬 짝 없음) · DOM · fetch · three 없음.
|
||||
* B05 평면(626-3)이 그대로 씀.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
compassCandidates,
|
||||
segmentLength,
|
||||
type HeightAt,
|
||||
type Vertex,
|
||||
} from "./D03_PlanAnalysis_Draw";
|
||||
|
||||
const TAU = 2 * Math.PI;
|
||||
/** 정방향 두 호 점수 차가 이보다 작으면 마우스 쪽 호(cos 차 · 약 3°). */
|
||||
const FORWARD_TIE = 0.05;
|
||||
|
||||
/** 원 위 노드 — 방위(라디안 · 동쪽 0 · 반시계 · 작업 좌표 y 위) · 부호(+1 오르막 · −1 내리막). */
|
||||
export interface FanNode {
|
||||
point: Vertex;
|
||||
angle: number;
|
||||
sign: 1 | -1;
|
||||
}
|
||||
|
||||
/** 허용 호 — `from` 에서 반시계로 `to`(to > from · 2π 넘을 수 있음). */
|
||||
export interface FanArc {
|
||||
from: number;
|
||||
to: number;
|
||||
forward: boolean;
|
||||
}
|
||||
|
||||
export interface Fan {
|
||||
nodes: FanNode[];
|
||||
arcs: FanArc[];
|
||||
/** 원 전체가 |기울기| ≤ s(노드 0) — 어느 방향이나. */
|
||||
flat: boolean;
|
||||
}
|
||||
|
||||
const norm = (angle: number): number => ((angle % TAU) + TAU) % TAU;
|
||||
const bearing = (from: Vertex, to: Vertex): number =>
|
||||
norm(Math.atan2(to[1] - from[1], to[0] - from[0]));
|
||||
/** 두 방위 사이 각(0 ~ π). */
|
||||
const gap = (a: number, b: number): number => Math.min(norm(a - b), norm(b - a));
|
||||
|
||||
/** 원 위 방위 자리. */
|
||||
export function fanPoint(from: Vertex, lengthM: number, angle: number): Vertex {
|
||||
return [from[0] + Math.cos(angle) * lengthM, from[1] + Math.sin(angle) * lengthM];
|
||||
}
|
||||
|
||||
/** 부채꼴 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `cursor` = 정방향이 거의 같을 때 가름. */
|
||||
export function compassFan(
|
||||
from: Vertex,
|
||||
lengthM: number,
|
||||
slopePct: number,
|
||||
heightAt: HeightAt,
|
||||
prev: Vertex | null,
|
||||
cursor: Vertex | null = null,
|
||||
): Fan {
|
||||
const z0 = heightAt(from[0], from[1]);
|
||||
if (!(lengthM > 0) || !Number.isFinite(z0)) return { nodes: [], arcs: [], flat: false };
|
||||
const rise = (Math.abs(slopePct) / 100) * lengthM;
|
||||
const allowed = (angle: number): boolean => {
|
||||
const p = fanPoint(from, lengthM, angle);
|
||||
return Math.abs(heightAt(p[0], p[1]) - z0) <= rise; // NaN = 지형 밖 = 거짓
|
||||
};
|
||||
const nodes: FanNode[] = compassCandidates(from, lengthM, slopePct, heightAt)
|
||||
.map((point): FanNode => ({
|
||||
point,
|
||||
angle: bearing(from, point),
|
||||
sign: heightAt(point[0], point[1]) >= z0 ? 1 : -1,
|
||||
}))
|
||||
.sort((a, b) => a.angle - b.angle);
|
||||
if (!nodes.length) return { nodes, arcs: [], flat: allowed(0) };
|
||||
const arcs: FanArc[] = [];
|
||||
nodes.forEach((node, i) => {
|
||||
const to = i + 1 < nodes.length ? nodes[i + 1].angle : nodes[0].angle + TAU;
|
||||
if (allowed((node.angle + to) / 2)) arcs.push({ from: node.angle, to, forward: false });
|
||||
});
|
||||
if (prev && arcs.length && segmentLength(prev, from) > 1e-9) {
|
||||
const heading = bearing(prev, from);
|
||||
const mid = (arc: FanArc): number => (arc.from + arc.to) / 2;
|
||||
const ranked = arcs
|
||||
.map((arc) => ({ arc, score: Math.cos(mid(arc) - heading) }))
|
||||
.sort((a, b) => b.score - a.score);
|
||||
let pick = ranked[0].arc;
|
||||
if (cursor && ranked.length > 1 && ranked[0].score - ranked[1].score < FORWARD_TIE) {
|
||||
const toward = bearing(from, cursor);
|
||||
if (gap(mid(ranked[1].arc), toward) < gap(mid(pick), toward)) pick = ranked[1].arc;
|
||||
}
|
||||
pick.forward = true;
|
||||
}
|
||||
return { nodes, arcs, flat: false };
|
||||
}
|
||||
|
||||
/** 고르기 — 완만한 땅 = 마우스 방향 원 위(길이 고정처럼) · 아니면 마우스에 가장 가까운 노드 · 없으면 null. */
|
||||
export function fanPick(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
||||
if (fan.flat)
|
||||
return segmentLength(from, cursor) > 1e-9
|
||||
? fanPoint(from, lengthM, bearing(from, cursor))
|
||||
: null;
|
||||
let best: Vertex | null = null;
|
||||
let bestD = Infinity;
|
||||
for (const node of fan.nodes) {
|
||||
const d = segmentLength(node.point, cursor);
|
||||
if (d < bestD) [best, bestD] = [node.point, d];
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Shift 사이 고르기 — 마우스 방위가 허용 호 안이면 그 방위 · 밖이면 가장 가까운 호 끝(= 노드) · 길이 그대로. */
|
||||
export function fanBetween(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
|
||||
if (segmentLength(from, cursor) < 1e-9) return null;
|
||||
const toward = bearing(from, cursor);
|
||||
if (fan.flat) return fanPoint(from, lengthM, toward);
|
||||
let best: number | null = null;
|
||||
let bestGap = Infinity;
|
||||
for (const arc of fan.arcs) {
|
||||
if (norm(toward - arc.from) <= arc.to - arc.from) return fanPoint(from, lengthM, toward);
|
||||
for (const end of [arc.from, arc.to])
|
||||
if (gap(end, toward) < bestGap) [best, bestGap] = [end, gap(end, toward)];
|
||||
}
|
||||
return best === null ? null : fanPoint(from, lengthM, best);
|
||||
}
|
||||
@@ -173,9 +173,11 @@ def test_map_drawing_colors_fixed_and_readable_on_dark_map():
|
||||
"""600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1."""
|
||||
blocks = _blocks(THEME.read_text(encoding="utf-8"))
|
||||
names = [
|
||||
k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k)
|
||||
k
|
||||
for k in blocks["light"]
|
||||
if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d|slope-(up|down))", k)
|
||||
]
|
||||
assert len(names) == 8, names
|
||||
assert len(names) == 10, names
|
||||
ground = _lum("#1f2937")
|
||||
for name in names:
|
||||
for mode in ("dark", "auto"):
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
"""D03 길이·기울기 고정 부채꼴 셈(PLAN 625-2 · 요구 530 · 531 B1~B7) — TS `logic/D03_PlanAnalysis_Fan.ts` 순수 시험.
|
||||
|
||||
화면 고르기 전용(파이썬 짝 없음) — tsc 로 옮겨 Node 로 돌림.
|
||||
- 경사면 65 % · 반지름 20 m · 9 % = 노드 4 · 호 2 · 호마다 + 하나 − 하나 · 부채꼴 폭 ≈ 2·arcsin(9/65).
|
||||
- 꽃잎 땅 cos(nθ) = 노드 2 · 4 · 6 · 8 · 호 n.
|
||||
- 정방향 = 앞 구간 쪽 호 · 첫 구간 없음 · 거의 같으면 마우스 쪽.
|
||||
- 완만한 땅 = 노드 0 · 어느 방향이나 · Shift 사이 = 길이 그대로 · 기울기 = 실제 지형 · 호 밖 = 호 끝.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_625_draw.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILES = [
|
||||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts",
|
||||
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts",
|
||||
]
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as fan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.js";
|
||||
import { groundSlopePct, segmentLength } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js";
|
||||
const L = 20, S = 9, O = [0, 0];
|
||||
const plane = (x, y) => 0.65 * x;
|
||||
const gentle = (x, y) => 0.01 * x;
|
||||
// 꽃잎 — 원 위 높이 차 = 10·(cos nθ − 1)/2 · 시작점 0
|
||||
const petal = (n) => (x, y) => (Math.hypot(x, y) / L) * 10 * (Math.cos(n * Math.atan2(y, x)) - 1) / 2;
|
||||
const at = (deg) => [Math.cos((deg * Math.PI) / 180) * 50, Math.sin((deg * Math.PI) / 180) * 50];
|
||||
const deg = (p) => ((Math.atan2(p[1], p[0]) * 180) / Math.PI + 360) % 360;
|
||||
const view = (f) => ({
|
||||
nodes: f.nodes.map((n) => ({ deg: (n.angle * 180) / Math.PI, sign: n.sign })),
|
||||
arcs: f.arcs.map((a) => ({ from: (a.from * 180) / Math.PI, to: (a.to * 180) / Math.PI, forward: a.forward })),
|
||||
flat: f.flat,
|
||||
});
|
||||
const pick = (f, z, p) => p && { deg: deg(p), length: segmentLength(O, p), slope: groundSlopePct(O, p, z) };
|
||||
const out = {};
|
||||
// 경사면 — 앞 구간 북쪽으로(앞 점 = 남쪽) · 첫 구간 · 동쪽(오르막) 앞 구간 = 마우스 쪽
|
||||
out.plane = view(fan.compassFan(O, L, S, plane, [0, -20]));
|
||||
out.planeFirst = view(fan.compassFan(O, L, S, plane, null));
|
||||
out.planeTieSouth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(-80)));
|
||||
out.planeTieNorth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(80)));
|
||||
const f = fan.compassFan(O, L, S, plane, [0, -20]);
|
||||
out.between = pick(f, plane, fan.fanBetween(f, O, at(93), L));
|
||||
out.betweenOut = pick(f, plane, fan.fanBetween(f, O, at(60), L));
|
||||
out.pick = pick(f, plane, fan.fanPick(f, O, at(60), L));
|
||||
out.pickReverse = pick(f, plane, fan.fanPick(f, O, at(-95), L));
|
||||
for (const n of [1, 2, 3, 4]) out[`petal${n}`] = view(fan.compassFan(O, L, S, petal(n), null));
|
||||
const g = fan.compassFan(O, L, S, gentle, [0, -20]);
|
||||
out.gentle = view(g);
|
||||
out.gentlePick = pick(g, gentle, fan.fanPick(g, O, at(200), L));
|
||||
out.gentleBetween = pick(g, gentle, fan.fanBetween(g, O, at(200), L));
|
||||
out.steep = view(fan.compassFan(O, L, S, (x, y) => 0.5 * Math.hypot(x, y), null));
|
||||
out.offTerrain = view(fan.compassFan(O, L, S, () => NaN, null));
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("fan")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)]
|
||||
+ ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"]
|
||||
+ ["--ignoreConfig"],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
for emitted in out.rglob("*.js"):
|
||||
emitted.write_text(
|
||||
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
|
||||
result = tmp / "out.json"
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _inside(arc: dict, deg: float) -> bool:
|
||||
return (deg - arc["from"]) % 360 <= arc["to"] - arc["from"]
|
||||
|
||||
|
||||
def test_경사면_노드_넷_호_둘_부호_짝(r):
|
||||
f = r["plane"]
|
||||
assert len(f["nodes"]) == 4 and len(f["arcs"]) == 2 and not f["flat"]
|
||||
width = 2 * math.degrees(math.asin(9 / 65))
|
||||
by_deg = {round(n["deg"], 3): n["sign"] for n in f["nodes"]}
|
||||
for arc in f["arcs"]:
|
||||
assert abs((arc["to"] - arc["from"]) - width) < 0.05
|
||||
ends = {by_deg[round(arc["from"], 3)], by_deg[round(arc["to"] % 360, 3)]}
|
||||
assert ends == {1, -1} # 호마다 + 하나 − 하나
|
||||
assert _inside(arc, 90) or _inside(arc, 270) # 등고선 방향
|
||||
# + = 오르막(동쪽 반) · − = 서쪽 반
|
||||
for n in f["nodes"]:
|
||||
assert (n["sign"] == 1) == (math.cos(math.radians(n["deg"])) > 0)
|
||||
|
||||
|
||||
def test_정방향_앞_구간_쪽_첫_구간_없음_같으면_마우스_쪽(r):
|
||||
forward = [a for a in r["plane"]["arcs"] if a["forward"]]
|
||||
assert len(forward) == 1 and _inside(forward[0], 90)
|
||||
assert not any(a["forward"] for a in r["planeFirst"]["arcs"])
|
||||
south = [a for a in r["planeTieSouth"]["arcs"] if a["forward"]]
|
||||
north = [a for a in r["planeTieNorth"]["arcs"] if a["forward"]]
|
||||
assert _inside(south[0], 270) and _inside(north[0], 90)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("n", [1, 2, 3, 4])
|
||||
def test_꽃잎_땅_노드_2_4_6_8(r, n):
|
||||
f = r[f"petal{n}"]
|
||||
assert len(f["nodes"]) == 2 * n and len(f["arcs"]) == n
|
||||
for k in range(n): # 허용 호 = 꽃잎 꼭대기(cos nθ = 1)마다
|
||||
assert sum(_inside(a, 360 * k / n) for a in f["arcs"]) == 1
|
||||
assert all(node["sign"] == -1 for node in f["nodes"])
|
||||
|
||||
|
||||
def test_shift_사이_길이_그대로_기울기_실제_지형(r):
|
||||
b = r["between"]
|
||||
assert abs(b["deg"] - 93) < 1e-6 and abs(b["length"] - 20) < 1e-9
|
||||
assert abs(b["slope"] - 65 * math.cos(math.radians(93))) < 1e-6 and abs(b["slope"]) < 9
|
||||
# 호 밖 = 가까운 호 끝(= ±9 % 노드)
|
||||
o = r["betweenOut"]
|
||||
assert abs(o["length"] - 20) < 1e-9 and abs(abs(o["slope"]) - 9) < 0.01
|
||||
assert abs(o["deg"] - (90 - math.degrees(math.asin(9 / 65)))) < 0.05
|
||||
|
||||
|
||||
def test_보통_고르기_가까운_노드_역방향도(r):
|
||||
assert abs(abs(r["pick"]["slope"]) - 9) < 0.01
|
||||
rev = r["pickReverse"]
|
||||
assert abs(abs(rev["slope"]) - 9) < 0.01 and 260 < rev["deg"] < 280 # 역방향 호 노드도 고름
|
||||
|
||||
|
||||
def test_완만한_땅_어느_방향이나(r):
|
||||
g = r["gentle"]
|
||||
assert g["flat"] and not g["nodes"] and not g["arcs"]
|
||||
for key in ("gentlePick", "gentleBetween"):
|
||||
p = r[key]
|
||||
assert abs(p["deg"] - 200) < 1e-6 and abs(p["length"] - 20) < 1e-9
|
||||
assert abs(p["slope"] - 1 * math.cos(math.radians(200))) < 1e-6
|
||||
|
||||
|
||||
def test_가파른_땅_지형_밖_고를_자리_없음(r):
|
||||
for key in ("steep", "offTerrain"):
|
||||
f = r[key]
|
||||
assert not f["flat"] and not f["nodes"] and not f["arcs"]
|
||||
@@ -601,6 +601,8 @@ html.is-bm-route .m01lab-src__body * {
|
||||
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
|
||||
--draw-map-measure: #ffffff; /* 거리재기 */
|
||||
--draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */
|
||||
--draw-map-slope-up: #ff9e2c; /* 긋기 기울기 부호 — + 오르막(따뜻한 색 · 노드 · 꺾임점) */
|
||||
--draw-map-slope-down: #29e0ff; /* − 내리막(찬 색) */
|
||||
--draw-map-route-0: #3df5c4; /* 예정노선 선마다 차례 색(개발제한 · 산사태 주황과 가름) */
|
||||
--draw-map-route-1: #6db8ff;
|
||||
--draw-map-route-2: #cf9dff;
|
||||
|
||||
Reference in New Issue
Block a user