Merge remote-tracking branch 'origin/dev' into sub_laptop_4 — Api 는 dev 쪽 · DrawPaint import 둘 다 · Page 구역 목록 인자 + 재기 인자
This commit is contained in:
@@ -37,18 +37,17 @@ export interface PlanJob {
|
||||
percent: number;
|
||||
message: string | null;
|
||||
error: string | null;
|
||||
/** 서버 다시 켜짐 등으로 끊김(595-3). */
|
||||
interrupted?: boolean;
|
||||
}
|
||||
|
||||
/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */
|
||||
/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */
|
||||
export type TerrainStart =
|
||||
| (PlanJob & { status?: "accepted" })
|
||||
| ({ status: "accepted" } & PlanJob)
|
||||
| { status: "far"; distance_m: number; message: string }
|
||||
| { status: "band"; epsg: number; center_epsg: number; message: string };
|
||||
|
||||
/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */
|
||||
export interface TerrainCenter {
|
||||
/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */
|
||||
export interface TerrainAim {
|
||||
center?: { lon: number; lat: number } | null;
|
||||
reset?: boolean;
|
||||
}
|
||||
@@ -119,7 +118,7 @@ export const fetchTerrainExtent = (
|
||||
projectId: string,
|
||||
marginM: number | null,
|
||||
extra: Vertex[],
|
||||
center: TerrainCenter["center"] = null,
|
||||
center: { lon: number; lat: number } | null = null,
|
||||
): Promise<TerrainExtent> => {
|
||||
const query = new URLSearchParams();
|
||||
if (marginM !== null) query.set("margin_m", String(marginM));
|
||||
@@ -136,11 +135,11 @@ export const startTerrain = (
|
||||
projectId: string,
|
||||
marginM: number | null,
|
||||
extra: Vertex[],
|
||||
where: TerrainCenter = {},
|
||||
aim: TerrainAim = {},
|
||||
): Promise<TerrainStart> =>
|
||||
request(`${base(projectId)}/terrain`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ margin_m: marginM, extra, ...where }),
|
||||
body: JSON.stringify({ margin_m: marginM, extra, ...aim }),
|
||||
});
|
||||
|
||||
export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> =>
|
||||
|
||||
@@ -41,10 +41,11 @@ import {
|
||||
removeLine,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
type NodeRecord,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
|
||||
import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span";
|
||||
import {
|
||||
fmt,
|
||||
lineAt,
|
||||
@@ -122,8 +123,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let kind: DrawConstraint = "free";
|
||||
let lengthM = DRAW_DEFAULT_LENGTH_M;
|
||||
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
|
||||
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점
|
||||
let span: { line: string; from: number; to: number; through: Vertex[] } | null = null;
|
||||
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
|
||||
let span: {
|
||||
line: string;
|
||||
from: number;
|
||||
to: number;
|
||||
through: Vertex[];
|
||||
records: NodeRecord[];
|
||||
} | null = null;
|
||||
const spanLine = (): number =>
|
||||
span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1;
|
||||
|
||||
@@ -272,7 +279,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return false;
|
||||
}
|
||||
setMode("draw");
|
||||
span = { line: lineId, from, to, through: [] };
|
||||
span = { line: lineId, from, to, through: [], records: [] };
|
||||
panelState();
|
||||
params.onMode();
|
||||
return true;
|
||||
@@ -381,9 +388,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info");
|
||||
return false;
|
||||
}
|
||||
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
|
||||
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
|
||||
if (span) {
|
||||
if (!preview.snapped) return (span.through.push(preview.point), true);
|
||||
const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through);
|
||||
if (!preview.snapped) {
|
||||
span.through.push(preview.point);
|
||||
span.records.push(record);
|
||||
return true;
|
||||
}
|
||||
const next = replaceSpan(
|
||||
params.network(),
|
||||
spanLine(),
|
||||
span.from,
|
||||
span.to,
|
||||
span.through,
|
||||
span.records,
|
||||
);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
span = null;
|
||||
@@ -396,7 +416,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
let next = params.network();
|
||||
if (preview.onLine)
|
||||
next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point);
|
||||
params.commit(appendVertex(next, line, preview.point));
|
||||
params.commit(appendVertex(next, line, preview.point, record));
|
||||
if (ended) {
|
||||
showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success");
|
||||
setMode("idle");
|
||||
@@ -494,6 +514,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
return true;
|
||||
}
|
||||
if (mode === "draw" && span && keyIs(event, "plan.drawBack")) {
|
||||
span.records.pop();
|
||||
if (!span.through.pop()) setMode("idle");
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
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,
|
||||
@@ -160,15 +161,22 @@ export function paintNetwork(
|
||||
);
|
||||
if (map.slopes) segmentLabels(context, map, line.vertices, z);
|
||||
});
|
||||
// 꺾임점
|
||||
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
|
||||
context.fillStyle = "#ffffff";
|
||||
context.strokeStyle = colors.warn;
|
||||
context.lineWidth = 2;
|
||||
for (const line of network.lines)
|
||||
for (const v of line.vertices.slice(1)) {
|
||||
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.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();
|
||||
}
|
||||
|
||||
@@ -2,40 +2,72 @@
|
||||
* D03_PlanAnalysis_UI_Measure.ts
|
||||
* D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m).
|
||||
* 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`).
|
||||
* 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라
|
||||
* (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로.
|
||||
* ========================================================================== */
|
||||
|
||||
import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
nearestOnRoute,
|
||||
routeSpan,
|
||||
type RoutePos,
|
||||
type RouteSpan,
|
||||
} from "./logic/D03_PlanAnalysis_Measure";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
|
||||
export interface PlanMeasure {
|
||||
layer: PlanMapLayer;
|
||||
/** 지움(Esc · 끔). */
|
||||
clear: () => void;
|
||||
/** 검증 창구 — 잰 값(두 점이 다 있으면). */
|
||||
result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null;
|
||||
/** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */
|
||||
result: () => {
|
||||
lengthM: number;
|
||||
slopePct: number | null;
|
||||
riseM: number | null;
|
||||
alongRoute: boolean;
|
||||
} | null;
|
||||
}
|
||||
|
||||
const fmt = (value: number, digits = 1): string => value.toFixed(digits);
|
||||
const signed = (value: number, digits = 1): string =>
|
||||
`${value >= 0 ? "+" : ""}${fmt(value, digits)}`;
|
||||
|
||||
export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure {
|
||||
export function createPlanMeasure(
|
||||
heightAt: () => (x: number, y: number) => number,
|
||||
network: () => PlanNetwork,
|
||||
): PlanMeasure {
|
||||
let from: Vertex | null = null;
|
||||
let to: Vertex | null = null;
|
||||
let fixed = false;
|
||||
/** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */
|
||||
let fromPos: RoutePos | null = null;
|
||||
let span: RouteSpan | null = null;
|
||||
|
||||
const measure = (a: Vertex, b: Vertex) => {
|
||||
const z = heightAt();
|
||||
const rise = z(b[0], b[1]) - z(a[0], a[1]);
|
||||
const lengthM = span ? span.lengthM : segmentLength(a, b);
|
||||
return {
|
||||
lengthM: segmentLength(a, b),
|
||||
slopePct: groundSlopePct(a, b, z),
|
||||
lengthM,
|
||||
slopePct: span
|
||||
? Number.isFinite(rise) && lengthM > 0
|
||||
? (rise / lengthM) * 100
|
||||
: null
|
||||
: groundSlopePct(a, b, z),
|
||||
riseM: Number.isFinite(rise) ? rise : null,
|
||||
alongRoute: !!span,
|
||||
};
|
||||
};
|
||||
|
||||
/** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */
|
||||
const place = (point: Vertex): void => {
|
||||
const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null;
|
||||
span = near && fromPos ? routeSpan(network(), fromPos, near) : null;
|
||||
to = near && span ? near.point : point;
|
||||
};
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map: ScreenMap) {
|
||||
if (!from) return;
|
||||
@@ -50,18 +82,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
|
||||
context.strokeStyle = colors.casing;
|
||||
context.stroke();
|
||||
};
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
if (to) {
|
||||
const [bx, by] = map.toScreen(to);
|
||||
context.beginPath();
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
strokeCased(context, colors.measure, 2.5, [6, 4]);
|
||||
if (span) {
|
||||
span.path.forEach((point, i) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
strokeCased(context, colors.measure, 4);
|
||||
} else {
|
||||
context.moveTo(ax, ay);
|
||||
context.lineTo(bx, by);
|
||||
strokeCased(context, colors.measure, 2.5, [6, 4]);
|
||||
}
|
||||
dot(ax, ay);
|
||||
dot(bx, by);
|
||||
const { lengthM, slopePct, riseM } = measure(from, to);
|
||||
const text =
|
||||
`${fmt(lengthM)} m · ` +
|
||||
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`);
|
||||
const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`;
|
||||
const text = span
|
||||
? `노선 따라 ${fmt(lengthM)} m · ` +
|
||||
(span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") +
|
||||
rise
|
||||
: `${fmt(lengthM)} m · ` +
|
||||
(slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`);
|
||||
const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14];
|
||||
map.upright(context, mx, my, () => {
|
||||
context.font = "12px system-ui, sans-serif";
|
||||
@@ -81,21 +128,26 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb
|
||||
if (button !== 0) return false;
|
||||
const point = map.toMetric(px, py);
|
||||
if (!from || fixed) {
|
||||
[from, to, fixed] = [point, null, false];
|
||||
// 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리
|
||||
const net = network();
|
||||
const hit = lineAt(net, px, py, map);
|
||||
fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null;
|
||||
[from, to, fixed, span] = [hit ? hit.point : point, null, false, null];
|
||||
} else {
|
||||
[to, fixed] = [point, true];
|
||||
place(point);
|
||||
fixed = true;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (from && !fixed) to = map.toMetric(px, py);
|
||||
if (from && !fixed) place(map.toMetric(px, py));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layer,
|
||||
clear: () => {
|
||||
[from, to, fixed] = [null, null, false];
|
||||
[from, to, fixed, fromPos, span] = [null, null, false, null, null];
|
||||
},
|
||||
result: () => (from && to && fixed ? measure(from, to) : null),
|
||||
};
|
||||
|
||||
@@ -153,7 +153,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
yearOptions: props.yearOptions,
|
||||
commit,
|
||||
});
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const measure = createPlanMeasure(
|
||||
() => z,
|
||||
() => network,
|
||||
);
|
||||
const marks = createPlanPoints({
|
||||
points: () => points,
|
||||
network: () => network,
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Measure.ts
|
||||
* D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지
|
||||
* 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로.
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
distanceAlong,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
segmentLength,
|
||||
sliceLine,
|
||||
} from "./D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */
|
||||
export interface RoutePos {
|
||||
line: string;
|
||||
atM: number;
|
||||
}
|
||||
|
||||
export interface RouteSpan {
|
||||
lengthM: number;
|
||||
/** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */
|
||||
path: Vertex[];
|
||||
/** 같은 선 위일 때만 — 두 누가(m). */
|
||||
stations: [number, number] | null;
|
||||
}
|
||||
|
||||
/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */
|
||||
function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] {
|
||||
const chain: RoutePos[] = [pos];
|
||||
const seen = new Set([pos.line]);
|
||||
let line = network.lines.find((entry) => entry.id === pos.line);
|
||||
while (line?.branch) {
|
||||
const parent = network.lines.find((entry) => entry.id === line!.branch!.line);
|
||||
if (!parent || seen.has(parent.id)) break;
|
||||
seen.add(parent.id);
|
||||
chain.push({
|
||||
line: parent.id,
|
||||
atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)),
|
||||
});
|
||||
line = parent;
|
||||
}
|
||||
return chain;
|
||||
}
|
||||
|
||||
/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */
|
||||
export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null {
|
||||
const up = upChain(network, a);
|
||||
const down = upChain(network, b);
|
||||
const ia = up.findIndex((step) => down.some((other) => other.line === step.line));
|
||||
if (ia < 0) return null;
|
||||
const ib = down.findIndex((step) => step.line === up[ia].line);
|
||||
const legs: Array<[string, number, number]> = [];
|
||||
// a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선
|
||||
for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]);
|
||||
legs.push([up[ia].line, up[ia].atM, down[ib].atM]);
|
||||
// 만나는 선 → … → b
|
||||
for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]);
|
||||
const path: Vertex[] = [];
|
||||
let lengthM = 0;
|
||||
for (const [id, from, to] of legs) {
|
||||
const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? [];
|
||||
if (vertices.length < 2) continue;
|
||||
lengthM += Math.abs(to - from);
|
||||
const piece = sliceLine(vertices, from, to);
|
||||
if (from > to) piece.reverse();
|
||||
for (const point of piece) {
|
||||
const last = path[path.length - 1];
|
||||
if (!last || segmentLength(last, point) > 1e-9) path.push(point);
|
||||
}
|
||||
}
|
||||
const same = ia === 0 && ib === 0;
|
||||
return { lengthM, path, stations: same ? [a.atM, b.atM] : null };
|
||||
}
|
||||
|
||||
/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */
|
||||
export function nearestOnRoute(
|
||||
network: PlanNetwork,
|
||||
point: Vertex,
|
||||
from: RoutePos,
|
||||
): (RoutePos & { point: Vertex }) | null {
|
||||
let best: (RoutePos & { point: Vertex }) | null = null;
|
||||
let bestGap = Infinity;
|
||||
for (const line of network.lines) {
|
||||
if (line.vertices.length < 2) continue;
|
||||
const atM = distanceAlong(line.vertices, point);
|
||||
const gap = segmentLength(pointAtDistance(line.vertices, atM), point);
|
||||
if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue;
|
||||
best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) };
|
||||
bestGap = gap;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
* D03_PlanAnalysis_Plan.ts
|
||||
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
|
||||
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
|
||||
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1).
|
||||
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
|
||||
* 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`).
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
|
||||
* ========================================================================== */
|
||||
@@ -21,6 +22,15 @@ export interface NodeRecord {
|
||||
slope_pct: number | null;
|
||||
}
|
||||
|
||||
/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */
|
||||
function withNodes(
|
||||
line: PlanLine,
|
||||
vertices: Vertex[],
|
||||
edit: (nodes: Array<NodeRecord | null>) => Array<NodeRecord | null>,
|
||||
): PlanLine {
|
||||
return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices };
|
||||
}
|
||||
|
||||
/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) ·
|
||||
* 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) ·
|
||||
* `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */
|
||||
@@ -201,7 +211,7 @@ export function insertVertex(
|
||||
if (i === line) {
|
||||
const vertices = [...entry.vertices];
|
||||
vertices.splice(segment + 1, 0, point);
|
||||
return { ...entry, vertices };
|
||||
return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes));
|
||||
}
|
||||
const branch = entry.branch;
|
||||
return branch && branch.line === id && branch.vertex > segment
|
||||
@@ -211,13 +221,21 @@ export function insertVertex(
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 끝에 점을 더함. */
|
||||
export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork {
|
||||
/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */
|
||||
export function appendVertex(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
point: Vertex,
|
||||
record: NodeRecord | null = null,
|
||||
): PlanNetwork {
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((entry, i) =>
|
||||
i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry,
|
||||
),
|
||||
lines: network.lines.map((entry, i) => {
|
||||
if (i !== line) return entry;
|
||||
const vertices = [...entry.vertices, point];
|
||||
const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null);
|
||||
return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((other, i) =>
|
||||
i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other,
|
||||
i === line
|
||||
? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last))
|
||||
: other,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -430,7 +450,8 @@ export function removeVertex(
|
||||
};
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices };
|
||||
if (i === line)
|
||||
return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex));
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id && branch.vertex > vertex
|
||||
? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } }
|
||||
@@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s
|
||||
const length = round3(lineLength(entry.vertices));
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices: [...other.vertices].reverse() };
|
||||
if (i === line)
|
||||
return withNodes(other, [...other.vertices].reverse(), (nodes) =>
|
||||
nodes.map((_, k) => (k ? nodes[n - k] : null)),
|
||||
);
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id
|
||||
? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } }
|
||||
@@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork {
|
||||
);
|
||||
const lines = network.lines
|
||||
.map((line, i) => {
|
||||
if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] };
|
||||
if (i === a) {
|
||||
const vertices = [...line.vertices, ...tail.vertices.slice(1)];
|
||||
if (!line.nodes && !tail.nodes) return { ...line, vertices };
|
||||
const nodesOf = (entry: PlanLine): Array<NodeRecord | null> =>
|
||||
entry.nodes ?? entry.vertices.map(() => null);
|
||||
return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] };
|
||||
}
|
||||
const branch = line.branch;
|
||||
return branch?.line === tail.id
|
||||
? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } }
|
||||
|
||||
@@ -3,14 +3,35 @@
|
||||
* D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈.
|
||||
* 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 ·
|
||||
* 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠).
|
||||
* 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유.
|
||||
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { lineLength } from "./D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw";
|
||||
import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
||||
|
||||
/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */
|
||||
export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord {
|
||||
return {
|
||||
kind,
|
||||
length_m: kind === "length" || kind === "both" ? lengthM : null,
|
||||
slope_pct: kind === "slope" || kind === "both" ? slopePct : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */
|
||||
export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean {
|
||||
const record = line.nodes?.[vertex];
|
||||
if (!record || vertex < 1) return false;
|
||||
const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]];
|
||||
if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01)
|
||||
return true;
|
||||
const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z);
|
||||
return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1;
|
||||
}
|
||||
|
||||
/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */
|
||||
export function respanBlocked(
|
||||
network: PlanNetwork,
|
||||
@@ -30,13 +51,15 @@ export function respanBlocked(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */
|
||||
/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글.
|
||||
* `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */
|
||||
export function replaceSpan(
|
||||
network: PlanNetwork,
|
||||
line: number,
|
||||
from: number,
|
||||
to: number,
|
||||
through: ReadonlyArray<Vertex>,
|
||||
records: ReadonlyArray<NodeRecord | null> = [],
|
||||
): PlanNetwork | string {
|
||||
const blocked = respanBlocked(network, line, from, to);
|
||||
if (blocked) return blocked;
|
||||
@@ -56,13 +79,22 @@ export function replaceSpan(
|
||||
: m >= oldEndM
|
||||
? m - oldEndM + newEndM
|
||||
: startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM);
|
||||
const branched = (other: PlanLine): PlanLine =>
|
||||
other.branch?.line === entry.id && other.branch.vertex >= to
|
||||
? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } }
|
||||
: other;
|
||||
return {
|
||||
lines: network.lines.map((other, i) => {
|
||||
if (i === line) return { ...other, vertices };
|
||||
const branch = other.branch;
|
||||
return branch?.line === entry.id && branch.vertex >= to
|
||||
? { ...other, branch: { ...branch, vertex: branch.vertex + shift } }
|
||||
: other;
|
||||
if (i !== line) return branched(other);
|
||||
if (!other.nodes && !records.some(Boolean)) return { ...other, vertices };
|
||||
const nodes = other.nodes ?? old.map(() => null);
|
||||
const inner = through.map((_, k) => records[k] ?? null);
|
||||
const end = nodeRecord("free", 0, 0);
|
||||
return {
|
||||
...other,
|
||||
vertices,
|
||||
nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)],
|
||||
};
|
||||
}),
|
||||
pieces: network.pieces.map((piece) =>
|
||||
piece.line === entry.id
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`.
|
||||
"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`.
|
||||
|
||||
두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 ·
|
||||
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로.
|
||||
마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감
|
||||
(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q
|
||||
"""
|
||||
@@ -34,7 +35,32 @@ const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
|
||||
const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]);
|
||||
const bad = span.replaceSpan(n, 0, 2, 2, []);
|
||||
const straight = span.replaceSpan(n, 0, 0, 2, []);
|
||||
writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight }));
|
||||
// 599-2 노드 기록
|
||||
const L = span.nodeRecord("length", 20, 9);
|
||||
const S = span.nodeRecord("slope", 20, 9);
|
||||
let r = plan.newLine(plan.emptyNetwork(), [0, 0]);
|
||||
r = plan.appendVertex(r, 0, [10, 0]);
|
||||
r = plan.appendVertex(r, 0, [30, 0], L);
|
||||
r = plan.appendVertex(r, 0, [30, 5], S);
|
||||
const recorded = r;
|
||||
const inserted = plan.insertVertex(r, 0, 0, [5, 0]);
|
||||
const dropped = plan.dropLastVertex(r, 0);
|
||||
const removed = plan.removeVertex(r, 0, 1);
|
||||
const reversed = plan.reverseLine(r, 0);
|
||||
let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L);
|
||||
const joined = plan.attachHead(t, 1);
|
||||
const moved = plan.moveVertex(r, 0, 2, [33, 0]);
|
||||
const flat = () => 0;
|
||||
const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat));
|
||||
const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]);
|
||||
const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]);
|
||||
writeFileSync(
|
||||
process.argv[2],
|
||||
JSON.stringify({
|
||||
before: n, done, wide, bad, straight,
|
||||
recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain,
|
||||
}),
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
@@ -99,3 +125,29 @@ def test_blocked_and_straight(tmp_path: Path) -> None:
|
||||
straight = r["straight"]["lines"][0]["vertices"]
|
||||
assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게
|
||||
assert r["straight"]["lines"][1]["branch"]["vertex"] == 1
|
||||
|
||||
|
||||
L = {"kind": "length", "length_m": 20, "slope_pct": None}
|
||||
S = {"kind": "slope", "length_m": None, "slope_pct": 9}
|
||||
F = {"kind": "free", "length_m": None, "slope_pct": None}
|
||||
|
||||
|
||||
def _nodes(network: dict, i: int = 0) -> list:
|
||||
line = network["lines"][i]
|
||||
assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음
|
||||
return line.get("nodes")
|
||||
|
||||
|
||||
def test_node_records_follow_vertices(tmp_path: Path) -> None:
|
||||
r = _run_ts(tmp_path)
|
||||
assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음
|
||||
assert _nodes(r["inserted"]) == [None, None, None, L, S]
|
||||
assert _nodes(r["dropped"]) == [None, None, L]
|
||||
assert _nodes(r["removed"]) == [None, L, S]
|
||||
assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림
|
||||
assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L]
|
||||
assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음
|
||||
# 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음
|
||||
assert r["off"] == [False, True, True]
|
||||
assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유
|
||||
assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`.
|
||||
|
||||
같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null.
|
||||
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선
|
||||
NETWORK = {
|
||||
"pieces": [],
|
||||
"lines": [
|
||||
{"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None},
|
||||
{"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}},
|
||||
{"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}},
|
||||
{"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None},
|
||||
],
|
||||
}
|
||||
|
||||
CASES = {
|
||||
"same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}],
|
||||
"same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}],
|
||||
"branch_main_after": [
|
||||
"routeSpan",
|
||||
NETWORK,
|
||||
{"line": "b1", "atM": 30},
|
||||
{"line": "main", "atM": 150},
|
||||
],
|
||||
"branch_main_before": [
|
||||
"routeSpan",
|
||||
NETWORK,
|
||||
{"line": "b1", "atM": 30},
|
||||
{"line": "main", "atM": 60},
|
||||
],
|
||||
"twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}],
|
||||
"twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}],
|
||||
"apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}],
|
||||
"near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}],
|
||||
"near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}],
|
||||
}
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = {};
|
||||
for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args);
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("measure")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
[
|
||||
"node",
|
||||
str(TSC),
|
||||
str(TS_FILE),
|
||||
"--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")
|
||||
job, result = tmp / "job.json", tmp / "out.json"
|
||||
job.write_text(json.dumps(CASES), encoding="utf-8")
|
||||
subprocess.run( # noqa: S603
|
||||
["node", str(out / "runner.mjs"), str(job), str(result)],
|
||||
cwd=str(ROOT),
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
return json.loads(result.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def test_같은_선은_누가_차(r):
|
||||
assert r["same"]["lengthM"] == pytest.approx(110)
|
||||
assert r["same"]["stations"] == [40, 150]
|
||||
assert r["same"]["path"][0] == [40, 0]
|
||||
assert r["same"]["path"][-1] == [100, 50]
|
||||
assert r["same_back"]["lengthM"] == pytest.approx(110)
|
||||
assert r["same_back"]["path"][0] == [100, 50]
|
||||
|
||||
|
||||
def test_갈래에서_본선으로(r):
|
||||
"""갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70."""
|
||||
assert r["branch_main_after"]["lengthM"] == pytest.approx(80)
|
||||
assert r["branch_main_after"]["stations"] is None
|
||||
assert r["branch_main_after"]["path"][0] == [130, 0]
|
||||
assert r["branch_main_before"]["lengthM"] == pytest.approx(70)
|
||||
|
||||
|
||||
def test_갈래의_갈래는_고리를_따라_올라감(r):
|
||||
"""b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m."""
|
||||
assert r["twig_root"]["lengthM"] == pytest.approx(200)
|
||||
# b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50
|
||||
assert r["twig_twig"]["lengthM"] == pytest.approx(50)
|
||||
|
||||
|
||||
def test_이어지지_않은_본선은_길_없음(r):
|
||||
assert r["apart"] is None
|
||||
|
||||
|
||||
def test_가까운_자리는_이어진_선_안에서만(r):
|
||||
assert r["near_branch"]["line"] == "b1"
|
||||
assert r["near_branch"]["atM"] == pytest.approx(30)
|
||||
assert r["near_other_excluded"]["line"] == "main"
|
||||
Reference in New Issue
Block a user