feat(B05): 측점 눈금을 누르면 그 측점 횡단 미리보기 창
- 원지반 횡단선·기본 계획 횡단선·성토사면 길이 셋만 보임, 구조물은 안 그림 (계획서 0-9 ⑧) - 서버 `POST /route/cross-preview` 가 `generate_sections`+`compute_cross_design` 정본을 재사용 - 계획고는 편집 중에 없어 그 측점 지반고를 그대로 놓음(지반 추종) — 창에 그렇게 적음 - 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths` 를 그대로 부름 - 700줄 규정에 맞춰 곡선 패널 배선을 `_CurveBar.ts` 로 분리 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012jsXWphgRUHAG2mFupSKPX
This commit is contained in:
@@ -148,6 +148,44 @@ export async function fetchRouteElevations(
|
||||
return payload.z;
|
||||
}
|
||||
|
||||
/** 횡단 미리보기 한 장 — 고치던 노선 그대로 그 측점만 서버가 셈해 준다(계획서 0-9 ⑧). */
|
||||
export interface CrossPreviewResponse {
|
||||
status: string;
|
||||
chainage_m: number;
|
||||
label: string | null;
|
||||
uphill_side: string | null;
|
||||
plan_radius_m: number | null;
|
||||
curve_widening_m: number | null;
|
||||
/** 원지반 횡단 샘플. */
|
||||
samples: Array<{ offset_m?: number; elevation_m?: number | null; valid: boolean }>;
|
||||
/** 기본 계획 횡단 — B06 `compute_cross_design` 이 낸 것. 계획고를 못 세우면 null. */
|
||||
design: {
|
||||
design_line: Array<{ offset_m: number; elevation_m: number }>;
|
||||
cut_area_m2: number;
|
||||
fill_area_m2: number;
|
||||
[key: string]: unknown;
|
||||
} | null;
|
||||
}
|
||||
|
||||
export interface CrossPreviewRequest {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
chainage_m: number;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
}
|
||||
|
||||
/** 한 측점 횡단을 묻는다. 종·횡단을 한 번 돌리므로 **한두 초** 걸린다(사용자 확정: 괜찮음). */
|
||||
export async function fetchCrossPreview(
|
||||
projectId: string,
|
||||
request: CrossPreviewRequest,
|
||||
): Promise<CrossPreviewResponse> {
|
||||
return requestJson<CrossPreviewResponse>(
|
||||
`/projects/${projectId}/route/cross-preview`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
120000,
|
||||
);
|
||||
}
|
||||
|
||||
/** 계획노선을 예상노선으로 되돌리고 같은 재계산을 돈다(노선 초기화). */
|
||||
export async function resetRoutePlan(projectId: string): Promise<RouteReplanResponse> {
|
||||
return requestJson<RouteReplanResponse>(
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
표고 조회는 종·횡단 생성기가 쓰는 것과 **같은 sampler**(`build_surface_sampler`)를 연다.
|
||||
두 화면이 다른 표고를 보면 같은 자리의 기울기가 갈린다.
|
||||
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/elevations → 점 묶음의 지반고
|
||||
POST /api/projects/{id}/route/cross-preview → 고치던 노선의 한 측점 횡단 미리보기
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -22,6 +23,12 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B05_Profile.B05_Profile_Engine_Sections_Core import (
|
||||
SectionGenerationOptions,
|
||||
generate_sections,
|
||||
)
|
||||
from B06_Section.B06_Section_Engine_Design import compute_cross_design, curve_widening_args
|
||||
from common_util.common_util_route_polyline import build_planned_polyline
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_surface_sampler import build_surface_sampler
|
||||
@@ -92,3 +99,123 @@ async def read_route_elevations(project_id: UUID, request: ElevationRequest) ->
|
||||
"z": [None if not ok else round(float(value), 3) for value, ok in zip(z, valid)],
|
||||
"valid": [bool(ok) for ok in valid],
|
||||
}
|
||||
|
||||
|
||||
class PreviewVertex(BaseModel):
|
||||
"""편집 중인 꺾임점 하나 — `RouteVertexInput` 과 같은 꼴."""
|
||||
|
||||
x: float
|
||||
y: float
|
||||
curve: bool = True
|
||||
radius_m: float | None = None
|
||||
|
||||
|
||||
class CrossPreviewRequest(BaseModel):
|
||||
"""고치던 노선 그대로 한 측점의 횡단을 미리 본다."""
|
||||
|
||||
vertices: list[PreviewVertex] = Field(..., min_length=2)
|
||||
chainage_m: float = Field(..., ge=0)
|
||||
#: 법정 최소곡선반지름(m) — 화면이 `/route/plan` 에서 받은 값을 그대로 돌려준다.
|
||||
min_radius_m: float = Field(12.0, gt=0)
|
||||
station_interval_m: float | None = None
|
||||
|
||||
|
||||
def _cross_preview(
|
||||
project_root: Path,
|
||||
params: dict,
|
||||
request: CrossPreviewRequest,
|
||||
) -> dict | None:
|
||||
"""고치던 노선으로 종·횡단을 한 번 돌려 그 측점 한 장을 뽑는다.
|
||||
|
||||
**B05·B06 의 정본 로직을 그대로 재사용한다**(2026-09-12 사용자 확정 「기본 로직은 B06에
|
||||
존재함. 재사용」) — `generate_sections` 가 측점·접선·지반 샘플을, `compute_cross_design`
|
||||
이 설계선을 만든다. 여기서 기하를 새로 짜지 않는다.
|
||||
|
||||
⚠ **계획고는 아직 없다.** 계획고는 [확인] 뒤 전 체인이 낳는 값이라 편집 중에는 존재하지
|
||||
않는다. 그래서 그 측점의 **지반고를 그대로 계획고로 놓는다**(지반 추종) — 절·성토가 사면
|
||||
기울기만으로 서는 「기본 계획 횡단」이며, 사용자가 보기로 한 것도 그것이다.
|
||||
"""
|
||||
try:
|
||||
sampler = build_surface_sampler(
|
||||
project_root / _MODELS_SUBDIR,
|
||||
str(params["source_filter"]),
|
||||
str(params["method"]),
|
||||
bool(params["smooth"]),
|
||||
)
|
||||
except (FileNotFoundError, KeyError, OSError, ValueError) as exc:
|
||||
logger.warning("횡단 미리보기: 지표면을 열지 못했습니다 — %s", exc)
|
||||
return None
|
||||
|
||||
built = build_planned_polyline(
|
||||
[(vertex.x, vertex.y) for vertex in request.vertices],
|
||||
min_radius_m=request.min_radius_m,
|
||||
# 화면이 준 노드는 이미 꺾임점이다 — 다시 뽑으면 선이 깎인다(`_write_planned_polyline`).
|
||||
simplify=False,
|
||||
curve_flags=[vertex.curve for vertex in request.vertices],
|
||||
radii=[vertex.radius_m for vertex in request.vertices],
|
||||
)
|
||||
interval = request.station_interval_m
|
||||
options = (
|
||||
SectionGenerationOptions(station_interval_m=float(interval))
|
||||
if interval and interval > 0
|
||||
else SectionGenerationOptions()
|
||||
)
|
||||
result = generate_sections(built.vertices, sampler, options)
|
||||
sections = result["cross_sections"]
|
||||
if not sections:
|
||||
return None
|
||||
section = min(sections, key=lambda row: abs(float(row["chainage_m"]) - request.chainage_m))
|
||||
|
||||
design = None
|
||||
center_z = section.get("center_z")
|
||||
if center_z is not None:
|
||||
# 단면유형 기본값은 B06 화면과 같다 — 등고가 높은 쪽을 절토로 본다.
|
||||
section_mode = "right_cut" if section.get("uphill_side") == "right" else "left_cut"
|
||||
design = compute_cross_design(
|
||||
section["samples"],
|
||||
float(center_z),
|
||||
ground_type="soil",
|
||||
section_mode=section_mode,
|
||||
**curve_widening_args(section),
|
||||
)
|
||||
return {
|
||||
"chainage_m": round(float(section["chainage_m"]), 3),
|
||||
"label": section.get("label"),
|
||||
"uphill_side": section.get("uphill_side"),
|
||||
"plan_radius_m": section.get("plan_radius_m"),
|
||||
"curve_widening_m": section.get("curve_widening_m"),
|
||||
"samples": section["samples"],
|
||||
"design": design,
|
||||
"total_length_m": round(float(result["longitudinal"]["total_length_m"]), 3)
|
||||
if result.get("longitudinal", {}).get("total_length_m") is not None
|
||||
else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{project_id}/route/cross-preview", response_model=None)
|
||||
async def read_cross_preview(project_id: UUID, request: CrossPreviewRequest) -> dict | JSONResponse:
|
||||
"""고치던 계획노선의 **한 측점 횡단**을 돌려준다(계획서 0-9 ⑧).
|
||||
|
||||
정본을 건드리지 않는다 — 파일도 DB 도 쓰지 않고 그 자리에서 셈해 돌려주기만 한다.
|
||||
"""
|
||||
pool = get_db_pool()
|
||||
async with pool.acquire() as connection:
|
||||
stored = await get_project_storage_relative_path(connection, project_id)
|
||||
if not stored:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트 저장 경로를 찾을 수 없습니다."},
|
||||
)
|
||||
params = await get_surface_confirmation_params(connection, str(project_id))
|
||||
|
||||
project_root = Path(resolve_stored_project_path(stored))
|
||||
preview = await asyncio.to_thread(_cross_preview, project_root, params, request)
|
||||
if preview is None:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={
|
||||
"status": "error",
|
||||
"message": "확정된 지표면이 없어 횡단을 미리 볼 수 없습니다.",
|
||||
},
|
||||
)
|
||||
return {"status": "success", "project_id": str(project_id), **preview}
|
||||
|
||||
@@ -42,13 +42,11 @@ import {
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
stationAtScreen,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
applyArcLocks,
|
||||
applyCurveLimits,
|
||||
@@ -72,6 +70,8 @@ const NODE_HIT_PX = 9;
|
||||
const SEGMENT_HIT_PX = 12;
|
||||
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
|
||||
const CONTOUR_HIT_PX = 6;
|
||||
/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */
|
||||
const STATION_HIT_PX = 11;
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
@@ -100,7 +100,8 @@ export async function openRouteEditModal(
|
||||
<strong>계획노선 편집</strong>
|
||||
<span class="b05-routeedit__hint">
|
||||
노드 끌기 = 옮기기 · 노드 클릭 = R 라벨 · 선 두 번 클릭 = 노드 추가 ·
|
||||
노드 오른쪽 클릭 = 삭제 · <b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
|
||||
노드 오른쪽 클릭 = 삭제 · <b>측점 눈금 클릭 = 횡단 미리보기</b> ·
|
||||
<b>Shift+클릭 = 두 점 사이 거리·기울기</b> ·
|
||||
가운데(휠) 버튼 끌기 = 지도 이동 · 휠 = 확대
|
||||
</span>
|
||||
<button type="button" class="b05-routeedit__close" aria-label="닫기">✕</button>
|
||||
@@ -162,6 +163,21 @@ export async function openRouteEditModal(
|
||||
let otherSheets: PreparedLayer[] = [];
|
||||
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
|
||||
let pickedContour = -1;
|
||||
/** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */
|
||||
const crossPreview = createCrossPreview({
|
||||
projectId,
|
||||
bounds: () => canvas.getBoundingClientRect(),
|
||||
request: () => ({
|
||||
vertices: planned.map(([x, y], index) => ({
|
||||
x,
|
||||
y,
|
||||
curve: curveOn[index] !== false,
|
||||
radius_m: curveRadius[index] ?? null,
|
||||
})),
|
||||
min_radius_m: minRadiusM || 12,
|
||||
station_interval_m: stationIntervalM,
|
||||
}),
|
||||
});
|
||||
/** 구간 재기 — Shift+클릭으로 두 점을 찍는다. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */
|
||||
const measure = createMeasureTool({
|
||||
projectId,
|
||||
@@ -190,6 +206,7 @@ export async function openRouteEditModal(
|
||||
window.removeEventListener("resize", resize);
|
||||
historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다.
|
||||
curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다.
|
||||
crossPreview.destroy();
|
||||
overlay.remove();
|
||||
};
|
||||
overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close);
|
||||
@@ -340,88 +357,26 @@ export async function openRouteEditModal(
|
||||
fresh: nodeInfo.length === 0,
|
||||
});
|
||||
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆(곡선 중심 반대쪽)에 뜬다. 그리기는 `_Label` 몫 ──
|
||||
const curveLabelBox = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.");
|
||||
},
|
||||
onLock: (lock) => {
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function syncCurveBar(): void {
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
curveLabelBox.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = toScreen(planned[picked]);
|
||||
curveLabelBox.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
toScreen(pickedCurve.start),
|
||||
toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
// ── 곡선 라벨 — 고른 꺾임점 옆에 뜨는 조작 패널. 배선은 `_CurveBar` 몫 ──
|
||||
const curveBar = createCurveBar({
|
||||
canvas,
|
||||
state: () => ({
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveArc,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
});
|
||||
}
|
||||
}),
|
||||
toScreen: (vertex) => toScreen(vertex),
|
||||
applyEdit: (message) => applyEdit(message),
|
||||
});
|
||||
const curveLabelBox = curveBar.label;
|
||||
const syncCurveBar = curveBar.sync;
|
||||
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
@@ -485,6 +440,23 @@ export async function openRouteEditModal(
|
||||
picked = dragHandle.node;
|
||||
syncCurveBar();
|
||||
draw();
|
||||
} else if (
|
||||
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
|
||||
(() => {
|
||||
const chainage = stationAtScreen(
|
||||
plannedLine.length ? plannedLine : planned,
|
||||
toScreen,
|
||||
stationIntervalM,
|
||||
px,
|
||||
py,
|
||||
STATION_HIT_PX,
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
void crossPreview.open(chainage);
|
||||
return true;
|
||||
})()
|
||||
) {
|
||||
/* 횡단 창이 떴다 — 더 집지 않는다. */
|
||||
} else if (contours) {
|
||||
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
|
||||
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Cross.ts
|
||||
* 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧).
|
||||
*
|
||||
* 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 ·
|
||||
* 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다.
|
||||
*
|
||||
* ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의
|
||||
* 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의
|
||||
* 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다.
|
||||
*
|
||||
* 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은
|
||||
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch";
|
||||
import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit";
|
||||
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
|
||||
|
||||
/** 그림 가장자리 여백(px). */
|
||||
const PAD = 28;
|
||||
|
||||
export interface CrossPreviewParams {
|
||||
projectId: string;
|
||||
/** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */
|
||||
bounds: () => DOMRect;
|
||||
/** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */
|
||||
request: () => {
|
||||
vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>;
|
||||
min_radius_m: number;
|
||||
station_interval_m: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CrossPreviewWindow {
|
||||
/** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */
|
||||
open: (chainageM: number) => Promise<void>;
|
||||
/** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */
|
||||
destroy: () => void;
|
||||
}
|
||||
|
||||
export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-routeedit__cross";
|
||||
root.hidden = true;
|
||||
root.innerHTML = `
|
||||
<div class="b05-routeedit__cross-head">
|
||||
<strong class="b05-routeedit__cross-title">횡단 미리보기</strong>
|
||||
<button type="button" class="b05-routeedit__cross-close" aria-label="닫기">✕</button>
|
||||
</div>
|
||||
<canvas class="b05-routeedit__cross-canvas" width="420" height="260"></canvas>
|
||||
<div class="b05-routeedit__cross-foot"></div>`;
|
||||
document.body.append(root);
|
||||
|
||||
const head = root.querySelector<HTMLElement>(".b05-routeedit__cross-head")!;
|
||||
const title = root.querySelector<HTMLElement>(".b05-routeedit__cross-title")!;
|
||||
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
|
||||
const canvas = root.querySelector<HTMLCanvasElement>(".b05-routeedit__cross-canvas")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
|
||||
root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => {
|
||||
root.hidden = true;
|
||||
});
|
||||
// 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다.
|
||||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||||
root.addEventListener(type, (event) => event.stopPropagation());
|
||||
}
|
||||
|
||||
// ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ──
|
||||
let dragFrom: { x: number; y: number; left: number; top: number } | null = null;
|
||||
head.addEventListener("pointerdown", (event) => {
|
||||
if ((event.target as HTMLElement).closest("button")) return;
|
||||
dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop };
|
||||
head.setPointerCapture(event.pointerId);
|
||||
event.preventDefault();
|
||||
});
|
||||
head.addEventListener("pointermove", (event) => {
|
||||
if (!dragFrom) return;
|
||||
root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`;
|
||||
root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`;
|
||||
});
|
||||
const stopDrag = (event: PointerEvent): void => {
|
||||
if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId);
|
||||
dragFrom = null;
|
||||
};
|
||||
head.addEventListener("pointerup", stopDrag);
|
||||
head.addEventListener("pointercancel", stopDrag);
|
||||
|
||||
/** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */
|
||||
let asked = -1;
|
||||
|
||||
return {
|
||||
async open(chainageM) {
|
||||
asked = chainageM;
|
||||
root.hidden = false;
|
||||
if (!root.style.left) {
|
||||
// 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다.
|
||||
// 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다.
|
||||
const box = params.bounds();
|
||||
root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`;
|
||||
root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`;
|
||||
}
|
||||
title.textContent = "횡단 미리보기 — 읽는 중…";
|
||||
foot.textContent = "";
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
let preview: CrossPreviewResponse;
|
||||
try {
|
||||
preview = await fetchCrossPreview(params.projectId, {
|
||||
...params.request(),
|
||||
chainage_m: chainageM,
|
||||
});
|
||||
} catch (error) {
|
||||
if (asked !== chainageM) return;
|
||||
title.textContent = "횡단 미리보기";
|
||||
foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다.";
|
||||
return;
|
||||
}
|
||||
if (asked !== chainageM || root.hidden) return;
|
||||
title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`;
|
||||
drawCross(context, canvas, preview);
|
||||
foot.textContent = summarize(preview);
|
||||
},
|
||||
destroy() {
|
||||
root.remove();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */
|
||||
function summarize(preview: CrossPreviewResponse): string {
|
||||
const design = preview.design;
|
||||
if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다.";
|
||||
// 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를
|
||||
// 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다.
|
||||
const lengths = fillSlopeLengths({
|
||||
samples: preview.samples,
|
||||
design,
|
||||
} as unknown as CrossSection);
|
||||
const sides = (["left", "right"] as const)
|
||||
.filter((side) => lengths[side] !== null)
|
||||
.map((side) => {
|
||||
const value = lengths[side]!;
|
||||
const label = side === "left" ? "좌" : "우";
|
||||
// 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다.
|
||||
return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`;
|
||||
});
|
||||
const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음";
|
||||
return (
|
||||
`${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡` +
|
||||
" · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임"
|
||||
);
|
||||
}
|
||||
|
||||
/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */
|
||||
function drawCross(
|
||||
context: CanvasRenderingContext2D,
|
||||
canvas: HTMLCanvasElement,
|
||||
preview: CrossPreviewResponse,
|
||||
): void {
|
||||
const ground = preview.samples
|
||||
.filter((sample) => sample.valid && sample.elevation_m !== null)
|
||||
.map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]);
|
||||
const design = (preview.design?.design_line ?? []).map(
|
||||
(point) => [point.offset_m, point.elevation_m] as [number, number],
|
||||
);
|
||||
const all = [...ground, ...design];
|
||||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
if (all.length < 2) return;
|
||||
|
||||
const offsets = all.map((point) => point[0]);
|
||||
const heights = all.map((point) => point[1]);
|
||||
const minOffset = Math.min(...offsets);
|
||||
const maxOffset = Math.max(...offsets);
|
||||
const minZ = Math.min(...heights);
|
||||
const maxZ = Math.max(...heights);
|
||||
const spanX = maxOffset - minOffset || 1;
|
||||
const spanZ = maxZ - minZ || 1;
|
||||
// **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는
|
||||
// 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다).
|
||||
const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ);
|
||||
const centerOffset = (minOffset + maxOffset) / 2;
|
||||
const centerZ = (minZ + maxZ) / 2;
|
||||
// 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다.
|
||||
const toScreen = (point: [number, number]): [number, number] => [
|
||||
canvas.width / 2 + (centerOffset - point[0]) * scale,
|
||||
canvas.height / 2 + (centerZ - point[1]) * scale,
|
||||
];
|
||||
|
||||
const stroke = (points: Array<[number, number]>, color: string, width: number): void => {
|
||||
if (points.length < 2) return;
|
||||
context.beginPath();
|
||||
points.forEach((point, index) => {
|
||||
const [x, y] = toScreen(point);
|
||||
if (index === 0) context.moveTo(x, y);
|
||||
else context.lineTo(x, y);
|
||||
});
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = width;
|
||||
context.stroke();
|
||||
};
|
||||
|
||||
// 중심선 — 어디가 노선 가운데인지 먼저 보이게.
|
||||
const [centerX] = toScreen([0, centerZ]);
|
||||
context.save();
|
||||
context.setLineDash([4, 4]);
|
||||
context.strokeStyle = "rgba(148,163,184,0.7)";
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
context.moveTo(centerX, PAD / 2);
|
||||
context.lineTo(centerX, canvas.height - PAD / 2);
|
||||
context.stroke();
|
||||
context.restore();
|
||||
|
||||
stroke(ground, "#94a3b8", 1.6); // 원지반
|
||||
stroke(design, "#f97316", 2.2); // 기본 계획 횡단
|
||||
|
||||
context.font = "11px system-ui, sans-serif";
|
||||
context.textBaseline = "top";
|
||||
context.fillStyle = "#94a3b8";
|
||||
context.textAlign = "left";
|
||||
context.fillText("원지반", PAD, 6);
|
||||
context.fillStyle = "#f97316";
|
||||
context.textAlign = "right";
|
||||
context.fillText("기본 계획 횡단", canvas.width - PAD, 6);
|
||||
context.fillStyle = "#94a3b8";
|
||||
context.textAlign = "center";
|
||||
context.textBaseline = "bottom";
|
||||
context.fillText(
|
||||
`좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` +
|
||||
` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`,
|
||||
canvas.width / 2,
|
||||
canvas.height - 4,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_CurveBar.ts
|
||||
* 곡선 조작 패널의 **배선** — 어느 꺾임점을 만질지 정하고, 칸에서 들어온 값을 편집값에
|
||||
* 옮겨 적는다. 패널을 그리고 자리를 잡는 일은 `_Label` 몫이다.
|
||||
*
|
||||
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과
|
||||
* 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다.
|
||||
*
|
||||
* **R 과 곡선 길이는 한 쌍**(L = R·Δ) — 어느 쪽으로 들어와도 **반지름 한 값**으로 바꿔
|
||||
* 들고 간다. 두 벌로 두면 교각이 바뀔 때 서로 어긋난다(`_Edits.ts` 설명 참고).
|
||||
* ========================================================================== */
|
||||
|
||||
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import type { CurveLock } from "./B05_Profile_UI_RouteEdit_Edits";
|
||||
import {
|
||||
centerDirectionOf,
|
||||
createCurveLabel,
|
||||
deflectionRad,
|
||||
type CurveLabel,
|
||||
} from "./B05_Profile_UI_RouteEdit_Label";
|
||||
|
||||
/** 패널이 만지는 편집값 한 벌 — 모달이 쥔 배열을 그대로 건네받는다. */
|
||||
export interface CurveBarState {
|
||||
picked: number;
|
||||
planned: Vertex[];
|
||||
nodeInfo: EditedNode[];
|
||||
curveInfo: EditedCurve[];
|
||||
curveOn: boolean[];
|
||||
curveRadius: Array<number | null>;
|
||||
curveLock: CurveLock[];
|
||||
curveArc: Array<number | null>;
|
||||
/** 못 넘는 하한(m). 0이면 제한 없음(계획서 0-9 ④). */
|
||||
limitRadiusM: number;
|
||||
limitArcM: number;
|
||||
}
|
||||
|
||||
export interface CurveBarParams {
|
||||
canvas: HTMLCanvasElement;
|
||||
state: () => CurveBarState;
|
||||
toScreen: (vertex: Vertex) => [number, number];
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */
|
||||
applyEdit: (message: string) => void;
|
||||
}
|
||||
|
||||
export interface CurveBar {
|
||||
label: CurveLabel;
|
||||
/** 고른 자리에 맞춰 패널을 옮겨 그린다. */
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
const label = createCurveLabel({
|
||||
onRadius: (value) => {
|
||||
const { picked, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveRadius[picked] = value;
|
||||
// 반지름만 바꾼 것이라 노드 자리는 그대로지만, 그려진 선은 낡았다.
|
||||
params.applyEdit(value === null ? "반지름을 자동으로 되돌렸습니다." : "반지름을 바꿨습니다.");
|
||||
},
|
||||
onArcLength: (value) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius } = params.state();
|
||||
if (picked < 0) return;
|
||||
// 곡선 길이 L 과 반지름 R 은 L = R·Δ 로 묶여 있다(Δ = 교각, 앞뒤 직선이 정함).
|
||||
// 그래서 길이를 받으면 반지름으로 바꿔 **한 값만** 들고 간다 — 두 벌로 두면 어긋난다.
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
curveArc[picked] = value;
|
||||
curveRadius[picked] = value !== null && deflection > 1e-9 ? value / deflection : null;
|
||||
params.applyEdit(
|
||||
value === null ? "반지름을 자동으로 되돌렸습니다." : "곡선 길이를 바꿨습니다.",
|
||||
);
|
||||
},
|
||||
onLock: (lock) => {
|
||||
const { picked, nodeInfo, curveArc, curveRadius, curveLock } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveLock[picked] = lock;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const shown = curveRadius[picked] ?? nodeInfo[picked]?.radius_m ?? null;
|
||||
// 길이를 붙들려면 지금 길이를 적어 둬야 한다 — 뒤에 교각이 바뀌면 이 값으로 R 을 다시 잡는다.
|
||||
if (lock === "arc") {
|
||||
curveArc[picked] = shown !== null && deflection > 1e-9 ? shown * deflection : null;
|
||||
}
|
||||
// R 을 붙들 때 칸이 비어 있으면 지금 그려진 R 을 적어 둔다(자동 상태를 그대로 못 박음).
|
||||
if (lock === "radius" && curveRadius[picked] === null) curveRadius[picked] = shown;
|
||||
params.applyEdit(
|
||||
lock === "radius"
|
||||
? "반지름을 고정했습니다."
|
||||
: lock === "arc"
|
||||
? "곡선 길이를 고정했습니다."
|
||||
: "고정을 풀었습니다.",
|
||||
);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
const { picked, curveOn } = params.state();
|
||||
if (picked < 0) return;
|
||||
curveOn[picked] = on;
|
||||
params.applyEdit(on ? "곡선을 넣었습니다." : "곡선을 지웠습니다.");
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
function sync(): void {
|
||||
const {
|
||||
picked,
|
||||
planned,
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
} = params.state();
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
label.hide();
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const deflection = deflectionRad(nodeInfo[picked]?.inner_angle_deg);
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = params.toScreen(planned[picked]);
|
||||
label.show({
|
||||
seat: picked,
|
||||
// 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다.
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
params.toScreen(pickedCurve.start),
|
||||
params.toScreen(pickedCurve.end),
|
||||
)
|
||||
: null,
|
||||
curveOn: curveOn[picked] !== false,
|
||||
radiusShown: shown,
|
||||
arcLengthShown: shown === null || deflection <= 1e-9 ? null : shown * deflection,
|
||||
lock: curveLock[picked] ?? null,
|
||||
innerAngleDeg: nodeInfo[picked]?.inner_angle_deg ?? null,
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
});
|
||||
}
|
||||
|
||||
return { label, sync };
|
||||
}
|
||||
@@ -228,6 +228,51 @@ export function routePointAtScreen(
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 클릭에 가장 가까운 **규칙 측점**의 누가거리(m). 그만큼 안에 없으면 null(계획서 0-9 ⑧).
|
||||
*
|
||||
* 눈금을 그리는 `drawStationTicks` 와 **같은 자리**를 짚는다 — 선분 길이를 누적해 측점 간격
|
||||
* 마다 한 점씩 보간한다. 눈금이 보이는 자리를 눌렀는데 안 잡히면 안 되기 때문이다.
|
||||
*/
|
||||
export function stationAtScreen(
|
||||
line: Array<[number, number]>,
|
||||
toScreen: ScreenOf,
|
||||
intervalM: number,
|
||||
px: number,
|
||||
py: number,
|
||||
maxPx: number,
|
||||
): number | null {
|
||||
if (line.length < 2 || !(intervalM > 0)) return null;
|
||||
const cumulative: number[] = [0];
|
||||
for (let index = 1; index < line.length; index += 1) {
|
||||
cumulative.push(
|
||||
cumulative[index - 1] +
|
||||
Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]),
|
||||
);
|
||||
}
|
||||
const total = cumulative[cumulative.length - 1];
|
||||
let best: number | null = null;
|
||||
let bestDistance = maxPx;
|
||||
let cursor = 1;
|
||||
for (let chainage = 0; chainage <= total; chainage += intervalM) {
|
||||
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
|
||||
const back = line[cursor - 1];
|
||||
const front = line[cursor];
|
||||
const segment = cumulative[cursor] - cumulative[cursor - 1] || 1;
|
||||
const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment));
|
||||
const [x, y] = toScreen([
|
||||
back[0] + (front[0] - back[0]) * ratio,
|
||||
back[1] + (front[1] - back[1]) * ratio,
|
||||
]);
|
||||
const distance = Math.hypot(x - px, y - py);
|
||||
if (distance < bestDistance) {
|
||||
bestDistance = distance;
|
||||
best = chainage;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 등고선을 보일 화면 사각형 — 노선 경계에 `bandM` 를 두른 것. 노선이 없으면 null.
|
||||
*
|
||||
* **매 프레임 다시 잰다** — 창 크기·배율·이동이 바뀌어도 띠가 노선을 따라간다. 띠 자체는
|
||||
|
||||
@@ -241,3 +241,53 @@
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ────────────────────────────────
|
||||
곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden`
|
||||
이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */
|
||||
.b05-routeedit__cross {
|
||||
position: fixed;
|
||||
z-index: calc(var(--z-modal, 1000) + 2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8, 8px);
|
||||
width: 452px;
|
||||
padding: var(--spacing-8);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-8, 6px);
|
||||
background: var(--color-surface-raised);
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / 40%);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-8, 8px);
|
||||
cursor: move;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-close {
|
||||
padding: 0 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4, 4px);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.b05-routeedit__cross-foot {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user