feat(635-4): B05 흐린 다른 세트 겹 · 이음점 셈 · 키트 붙기 칸(요구 546 · 547)
- 새 logic/B05_Profile_PlanContext.ts — 흐릴 세트(내 것 빼고) · 이음점(남 세트 끝) · 받을 때 이어진 이음점 · 벗어남 안내 · 이름표 자리 - 새 B05_Profile_UI_PlanContext.ts — plan-context 실시간 읽기 · 옅은 선 + 세트 이름표 · 누름 칸 없음(보기만) - 키트 · 긋기 · 고르기 snaps 덧칸 — 긋기 · 노드 끌기가 이음점에 붙기만 - 기능 표 others 줄(B05 보기만) - 시험 test_635_4(6) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_PlanContext.ts
|
||||
* B05 흐린 다른 세트 겹(PLAN 635-4 · 요구 546 · 547 · 결정 5 · 6 · 7) — 같은 예정노선의 다른 세트(다른 해 · 같은 해 다른 업체).
|
||||
* 자료 = `GET /api/projects/{B}/plan-context` 실시간 읽기(보기 전용 · 파일 안 만듦 · 업체 이름 없음).
|
||||
* 그림 = 옅은 선 + 세트 이름표 「2028-1」 · 누름 · 고르기 · 고침 없음(겹에 누름 받는 칸 없음 · 기능 표 `others` = 보기만).
|
||||
* 붙기 = 남 세트 끝(이음점)을 긋기 · 노드 끌기 붙을 자리로 내놓음(`joints`) · 고정 안 함 → 벗어나면 `note` 한 줄.
|
||||
* 셈 = `logic/B05_Profile_PlanContext.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { planLabel, strokeCased } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { ScreenMap } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Map";
|
||||
import { requestJson } from "./B05_Profile_Api_Replan";
|
||||
import {
|
||||
jointGaps,
|
||||
jointNote,
|
||||
jointPoints,
|
||||
labelPoint,
|
||||
linkedJoints,
|
||||
otherSets,
|
||||
type PlanContext,
|
||||
type Vertex,
|
||||
} from "./logic/B05_Profile_PlanContext";
|
||||
|
||||
/** 흐린 세트 불투명. */
|
||||
const FAINT_ALPHA = 0.45;
|
||||
|
||||
export interface PlanContextLayer {
|
||||
/** 문맥 다시 읽음(연결 없음 · 실패 = 빈 겹). */
|
||||
load: () => Promise<void>;
|
||||
/** 붙을 자리(남 세트 끝). */
|
||||
joints: () => Vertex[];
|
||||
/** 지금 예정노선이 받을 때 이어졌던 이음점에서 벗어났으면 안내 한 줄. */
|
||||
note: (line: ReadonlyArray<Vertex>) => string | null;
|
||||
paint: (context: CanvasRenderingContext2D, map: Pick<ScreenMap, "toScreen" | "upright">) => void;
|
||||
/** 검증 창구 — 흐린 세트 열쇠 · 꼭짓점 수 · 이음점. */
|
||||
probe: () => {
|
||||
sets: Array<{ key: string; points: number }>;
|
||||
joints: Vertex[];
|
||||
mine: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlanContextLayer(projectId: string, redraw: () => void): PlanContextLayer {
|
||||
let context: PlanContext | null = null;
|
||||
|
||||
return {
|
||||
async load() {
|
||||
try {
|
||||
context = await requestJson<PlanContext>(
|
||||
`/projects/${projectId}/plan-context`,
|
||||
{ method: "GET" },
|
||||
30000,
|
||||
);
|
||||
} catch {
|
||||
context = null; // 보기 전용 겹 — 못 읽으면 안 그림
|
||||
}
|
||||
redraw();
|
||||
},
|
||||
joints: () => jointPoints(context).map((joint) => joint.point),
|
||||
note: (line) => jointNote(jointGaps(line, linkedJoints(context))),
|
||||
paint(ctx, map) {
|
||||
const sets = otherSets(context);
|
||||
if (!sets.length) return;
|
||||
const color = themeColor("--draw-text-secondary", "#9ca3af");
|
||||
ctx.save();
|
||||
ctx.globalAlpha = FAINT_ALPHA;
|
||||
for (const set of sets)
|
||||
for (const line of set.lines) {
|
||||
if (line.length < 2) continue;
|
||||
ctx.beginPath();
|
||||
line.forEach((point, i) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) ctx.lineTo(sx, sy);
|
||||
else ctx.moveTo(sx, sy);
|
||||
});
|
||||
strokeCased(ctx, color, 3);
|
||||
}
|
||||
ctx.globalAlpha = Math.min(1, FAINT_ALPHA + 0.3);
|
||||
for (const set of sets) {
|
||||
const at = labelPoint(set);
|
||||
if (!at) continue;
|
||||
const [sx, sy] = map.toScreen(at);
|
||||
planLabel(ctx, map, sx, sy - 12, set.key, "600 12px");
|
||||
}
|
||||
ctx.restore();
|
||||
},
|
||||
probe: () => ({
|
||||
sets: otherSets(context).map((set) => ({
|
||||
key: set.key,
|
||||
points: set.lines.reduce((sum, line) => sum + line.length, 0),
|
||||
})),
|
||||
joints: jointPoints(context).map((joint) => joint.point),
|
||||
mine: context?.mine ?? null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_PlanContext.ts
|
||||
* B05 흐린 다른 세트 셈(PLAN 635-4 · 요구 546 · 547 · 결정 5 · 6 · 7) — 순수 함수.
|
||||
* 입력 = `GET /api/projects/{B}/plan-context`(같은 예정노선 세트 모두 · B 좌표 · 업체 이름 없음).
|
||||
* 흐릴 세트 = 내 세트 아님 · 이음점 = 남 세트 꺾은선 끝 · 받을 때 이어진 이음점 = 내 받은 선 끝 1 m 안.
|
||||
* 이음은 고정 안 함 — 벗어나면 안내 글 한 줄(판단 = 설계자).
|
||||
* ========================================================================== */
|
||||
|
||||
import { lineLength, pointAtDistance } from "../../D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw";
|
||||
|
||||
export type Vertex = [number, number];
|
||||
|
||||
/** plan-context 세트 한 줄(635-2 꼴). */
|
||||
export interface ContextSet {
|
||||
key: string;
|
||||
year: number;
|
||||
no: number;
|
||||
mine: boolean;
|
||||
parts: number;
|
||||
branch: boolean;
|
||||
length_m: number;
|
||||
lines: Vertex[][];
|
||||
}
|
||||
|
||||
export interface PlanContext {
|
||||
linked: boolean;
|
||||
plan_name?: string;
|
||||
mine?: string | null;
|
||||
sets?: ContextSet[];
|
||||
}
|
||||
|
||||
/** 이음점 · 끝 맞음 거리(m). */
|
||||
export const JOINT_TOL_M = 1;
|
||||
|
||||
export interface Joint {
|
||||
key: string;
|
||||
point: Vertex;
|
||||
}
|
||||
|
||||
const dist = (a: Vertex, b: Vertex): number => Math.hypot(a[0] - b[0], a[1] - b[1]);
|
||||
const drawn = (line: Vertex[]): boolean => line.length > 1;
|
||||
const ends = (line: Vertex[]): Vertex[] => [line[0], line[line.length - 1]];
|
||||
|
||||
/** 흐릴 세트 — 내 세트 아님 · 그릴 선 있음. */
|
||||
export function otherSets(context: PlanContext | null): ContextSet[] {
|
||||
if (!context?.linked) return [];
|
||||
return (context.sets ?? []).filter((set) => !set.mine && set.lines.some(drawn));
|
||||
}
|
||||
|
||||
/** 남 세트 꺾은선 끝 모두(붙을 자리). */
|
||||
export function jointPoints(context: PlanContext | null): Joint[] {
|
||||
return otherSets(context).flatMap((set) =>
|
||||
set.lines.filter(drawn).flatMap((line) => ends(line).map((point) => ({ key: set.key, point }))),
|
||||
);
|
||||
}
|
||||
|
||||
/** 받을 때 이어진 이음점 — 남 세트 끝 중 내 받은 선 끝 1 m 안. */
|
||||
export function linkedJoints(context: PlanContext | null): Joint[] {
|
||||
const mine = (context?.sets ?? []).find((set) => set.mine);
|
||||
const tips = (mine?.lines ?? []).filter(drawn).flatMap(ends);
|
||||
return jointPoints(context).filter((joint) =>
|
||||
tips.some((tip) => dist(tip, joint.point) <= JOINT_TOL_M),
|
||||
);
|
||||
}
|
||||
|
||||
/** 이어졌던 이음점마다 지금 선 끝까지 거리 — 1 m 밖만 · 선 없음 = 빈. */
|
||||
export function jointGaps(
|
||||
line: ReadonlyArray<Vertex>,
|
||||
joints: ReadonlyArray<Joint>,
|
||||
): Array<{ key: string; gapM: number }> {
|
||||
if (line.length < 2) return [];
|
||||
const tips = ends(line as Vertex[]);
|
||||
return joints
|
||||
.map((joint) => ({
|
||||
key: joint.key,
|
||||
gapM: Math.min(...tips.map((tip) => dist(tip, joint.point))),
|
||||
}))
|
||||
.filter((gap) => gap.gapM > JOINT_TOL_M);
|
||||
}
|
||||
|
||||
/** 벗어남 안내 한 줄(없음 = null). */
|
||||
export function jointNote(gaps: ReadonlyArray<{ key: string; gapM: number }>): string | null {
|
||||
if (!gaps.length) return null;
|
||||
const list = gaps.map((gap) => `${gap.key} ${gap.gapM.toFixed(1)} m`).join(" · ");
|
||||
return `이웃 세트 이음점에서 벗어남(${list}) — 이음은 고정이 아님 · 끝을 이음점에 놓으면 붙습니다.`;
|
||||
}
|
||||
|
||||
/** 세트 이름표 자리 — 가장 긴 꺾은선 가운데. */
|
||||
export function labelPoint(set: ContextSet): Vertex | null {
|
||||
const line = set.lines.filter(drawn).sort((a, b) => lineLength(b) - lineLength(a))[0];
|
||||
return line ? pointAtDistance(line, lineLength(line) / 2) : null;
|
||||
}
|
||||
@@ -100,6 +100,8 @@ export interface PlanDrawParams {
|
||||
page: PlanPage;
|
||||
/** 지점(긋기에 붙는 점 · 591-2) — 없음 = 지점 없는 페이지. */
|
||||
base?: () => Vertex[];
|
||||
/** 이음점(B05 흐린 다른 세트 끝 · 635-4) — 붙기만 · 긋기 시작 자리 아님. */
|
||||
snaps?: () => Vertex[];
|
||||
network: () => PlanNetwork;
|
||||
heightAt: () => (x: number, y: number) => number;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
@@ -191,6 +193,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
|
||||
const offers = basePoints()
|
||||
.filter((point) => !head || !samePoint(point, head))
|
||||
.map((point) => ({ point, note: "지점에 붙음" }));
|
||||
for (const point of params.snaps?.() ?? []) offers.push({ point, note: "이음점에 붙음" });
|
||||
network.lines.forEach((line, i) => {
|
||||
if (i !== active)
|
||||
for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" });
|
||||
|
||||
@@ -73,6 +73,8 @@ export interface PlanEditParams {
|
||||
network: () => PlanNetwork;
|
||||
/** 지점(긋기에 붙는 점 · 591-2). */
|
||||
base: () => Vertex[];
|
||||
/** 이음점(B05 흐린 다른 세트 끝 · 635-4) — 노드 끌기가 붙기만(같이 안 감). */
|
||||
snaps?: () => Vertex[];
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 원지반 높이(고른 선 꺾임점 부호 색 · 625-3). */
|
||||
heightAt: () => HeightAt;
|
||||
@@ -277,8 +279,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
|
||||
/** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */
|
||||
function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null {
|
||||
const near = params
|
||||
.base()
|
||||
const near = [...params.base(), ...(params.snaps?.() ?? [])]
|
||||
.filter((p) => !samePoint(p, skip))
|
||||
.map((p) => ({ p, gap: screenGap(map, p, px, py) }))
|
||||
.filter((item) => item.gap <= SNAP_PX)
|
||||
|
||||
@@ -22,6 +22,7 @@ export type PlanFeature =
|
||||
| "years"
|
||||
| "set"
|
||||
| "join"
|
||||
| "others"
|
||||
| "points"
|
||||
| "plan-work";
|
||||
|
||||
@@ -95,6 +96,12 @@ export const PLAN_FEATURES: readonly PlanFeatureRow[] = [
|
||||
pages: { d03: "use", b05: "hide" },
|
||||
why: "B 계획선 한 줄",
|
||||
},
|
||||
{
|
||||
key: "others",
|
||||
name: "같은 예정노선 다른 세트(다른 해 · 같은 해 다른 업체) 흐린 겹 · 이음점 붙기",
|
||||
pages: { d03: "hide", b05: "view" },
|
||||
why: "B 는 받은 세트만 고침 · 예정노선은 모든 세트를 고침",
|
||||
},
|
||||
{
|
||||
key: "points",
|
||||
name: "지점 칸 · 핀 · 지점에서 긋기",
|
||||
|
||||
@@ -52,6 +52,8 @@ export interface PlanKitParams {
|
||||
/** 마지막 서버 세트 셈(왼쪽 세트 줄 덩이 수 · 고친 뒤 = null). */
|
||||
sets?: () => ReadonlyArray<{ key: string; parts: number }> | null;
|
||||
points?: PlanKitPoints;
|
||||
/** 이음점(B05 흐린 다른 세트 끝 · 635-4) — 긋기 · 노드 끌기 붙기만. */
|
||||
snaps?: () => Vertex[];
|
||||
/** 페이지 키(되돌리기 · 다시하기 · 재기 끔) — 긋기 · 고르기보다 먼저. */
|
||||
onKey?: (event: KeyboardEvent) => boolean;
|
||||
cards: {
|
||||
@@ -87,6 +89,7 @@ export function createPlanKit(params: PlanKitParams): PlanKit {
|
||||
const draw = createPlanDraw({
|
||||
page,
|
||||
base: points?.list,
|
||||
snaps: params.snaps,
|
||||
network,
|
||||
heightAt,
|
||||
commit,
|
||||
@@ -95,6 +98,7 @@ export function createPlanKit(params: PlanKitParams): PlanKit {
|
||||
const edit = createPlanEdit({
|
||||
network,
|
||||
base: () => points?.list() ?? [],
|
||||
snaps: params.snaps,
|
||||
commit,
|
||||
heightAt,
|
||||
show: params.show,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
"""B05 흐린 다른 세트 겹 · 이음점 붙기(PLAN 635-4 · 요구 546 · 547) — TS `logic/B05_Profile_PlanContext.ts`.
|
||||
|
||||
tsc 로 옮겨 Node 로 돌림.
|
||||
- 흐릴 세트 = 내 세트 아님 · 연결 없음 = 빈 · 꺾은선 1 점 = 안 그림.
|
||||
- 이음점 = 남 세트 끝 · 받을 때 이어진 이음점 = 내 받은 선 끝 1 m 안.
|
||||
- 벗어남 = 이어졌던 이음점에서 지금 선 끝까지 1 m 밖만 안내 · 붙이면 안내 없음.
|
||||
- 이름표 = 가장 긴 꺾은선 가운데.
|
||||
- 지킴: 겹은 보기만(GET 만 · 파일 · 누름 칸 없음) · 기능 표 `others` = B05 보기만 · 키트가 이음점을 긋기 · 끌기에 넘김.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_635_4_plan_context_layer.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
LOGIC = ROOT / "B05_Profile" / "logic" / "B05_Profile_PlanContext.ts"
|
||||
LAYER = ROOT / "B05_Profile" / "B05_Profile_UI_PlanContext.ts"
|
||||
D03 = ROOT / "D03_PlanAnalysis"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as c from "./B05_Profile/logic/B05_Profile_PlanContext.js";
|
||||
const set = (key, mine, lines) => ({ key, year: +key.slice(0, 4), no: +key.slice(5), mine, parts: 1,
|
||||
branch: false, length_m: 0, lines });
|
||||
const ctx = { linked: true, mine: "2027-1", sets: [
|
||||
set("2027-1", true, [[[0, 0], [100, 0]]]),
|
||||
set("2028-1", false, [[[-50, 0], [0, 0.4]]]),
|
||||
set("2027-2", false, [[[100, 0], [130, 0], [160, 30]]]),
|
||||
set("2029-1", false, [[[500, 500], [600, 500]], [[700, 700]]]),
|
||||
set("2029-2", false, [[[1, 1]]]),
|
||||
] };
|
||||
const out = {
|
||||
others: c.otherSets(ctx).map((s) => s.key),
|
||||
unlinked: c.otherSets({ linked: false }),
|
||||
none: c.otherSets(null),
|
||||
joints: c.jointPoints(ctx),
|
||||
linked: c.linkedJoints(ctx),
|
||||
gapsSame: c.jointGaps([[0, 0], [50, 0], [100, 0]], c.linkedJoints(ctx)),
|
||||
gapsMoved: c.jointGaps([[0, 0], [50, 0], [90, 0]], c.linkedJoints(ctx)),
|
||||
gapsEmpty: c.jointGaps([[0, 0]], c.linkedJoints(ctx)),
|
||||
note: c.jointNote(c.jointGaps([[0, 0], [50, 0], [90, 0]], c.linkedJoints(ctx))),
|
||||
noteNone: c.jointNote([]),
|
||||
label: c.labelPoint(ctx.sets[2]),
|
||||
labelLong: c.labelPoint(set("x-1", false, [[[0, 0], [10, 0]], [[0, 5], [0, 105]]])),
|
||||
};
|
||||
writeFileSync(process.argv[2], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("ctx")
|
||||
out = tmp / "js"
|
||||
subprocess.run( # noqa: S603 — 고정 실행 파일
|
||||
["node", str(TSC), str(LOGIC), "--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 test_흐릴_세트_내_것_빼고_연결_없으면_빈(r):
|
||||
assert r["others"] == ["2028-1", "2027-2", "2029-1"]
|
||||
assert r["unlinked"] == [] and r["none"] == []
|
||||
|
||||
|
||||
def test_이음점_남_세트_끝_받을_때_이어진_것만(r):
|
||||
keys = [(j["key"], j["point"]) for j in r["joints"]]
|
||||
assert ("2028-1", [-50, 0]) in keys and ("2027-2", [160, 30]) in keys
|
||||
assert ("2029-1", [600, 500]) in keys and len(keys) == 6 # 1 점 꺾은선 빼고
|
||||
assert [(j["key"], j["point"]) for j in r["linked"]] == [
|
||||
("2028-1", [0, 0.4]),
|
||||
("2027-2", [100, 0]),
|
||||
]
|
||||
|
||||
|
||||
def test_벗어남_1m_밖만_안내(r):
|
||||
assert r["gapsSame"] == [] and r["gapsEmpty"] == []
|
||||
assert [(g["key"], round(g["gapM"], 3)) for g in r["gapsMoved"]] == [("2027-2", 10.0)]
|
||||
assert "2027-2 10.0 m" in r["note"] and "고정이 아님" in r["note"]
|
||||
assert r["noteNone"] is None
|
||||
|
||||
|
||||
def test_이름표_가장_긴_선_가운데(r):
|
||||
assert r["labelLong"] == [0, 55]
|
||||
step = (30 + 30 * 2**0.5) / 2 - 30 # 반 길이 = 둘째 토막 안
|
||||
assert r["label"] == pytest.approx([130 + step / 2**0.5, step / 2**0.5], abs=1e-6)
|
||||
|
||||
|
||||
def test_겹은_보기만_파일_누름_없음():
|
||||
src = LAYER.read_text("utf-8")
|
||||
assert 'method: "GET"' in src and not re.search(r'method:\s*"(POST|PUT|DELETE|PATCH)"', src)
|
||||
assert "sessionStorage" not in src and "press" not in src
|
||||
assert not (ROOT / "B05_Profile" / "route").exists() or not any(
|
||||
"PlanContext" in p.name for p in (ROOT / "B05_Profile" / "route").rglob("*")
|
||||
)
|
||||
logic = LOGIC.read_text("utf-8")
|
||||
assert not re.search(r"\b(document|window|fetch|sessionStorage)\b", logic)
|
||||
|
||||
|
||||
def test_기능_표_others_B05_보기만_키트_이음점_넘김():
|
||||
features = (D03 / "D03_PlanAnalysis_UI_Features.ts").read_text("utf-8")
|
||||
row = re.search(r'key: "others",[^}]*pages: \{ d03: "(\w+)", b05: "(\w+)" \}', features, re.S)
|
||||
assert row and row.groups() == ("hide", "view")
|
||||
kit = (D03 / "D03_PlanAnalysis_UI_Kit.ts").read_text("utf-8")
|
||||
assert kit.count("snaps: params.snaps") == 2
|
||||
assert 'note: "이음점에 붙음"' in (D03 / "D03_PlanAnalysis_UI_Draw.ts").read_text("utf-8")
|
||||
assert "params.snaps?.()" in (D03 / "D03_PlanAnalysis_UI_Edit.ts").read_text("utf-8")
|
||||
Reference in New Issue
Block a user