Merge remote-tracking branch 'origin/dev' into sub_laptop_6
This commit is contained in:
@@ -138,17 +138,18 @@ export const KEY_ACTIONS: readonly KeyAction[] = [
|
||||
A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]),
|
||||
A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]),
|
||||
A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]),
|
||||
A("plan.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]),
|
||||
A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]),
|
||||
A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F),
|
||||
A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F),
|
||||
A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F),
|
||||
A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F),
|
||||
A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F),
|
||||
A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F),
|
||||
A("plan.select", "plan", ["누름 · 다시 누름"], ["노드 → 선 → 구역 → 전체 고름", "Pick node → segment → region → all"], F),
|
||||
A("plan.vertex", "plan", ["고른 선 끌기", "노드 끌기"], ["꼭짓점 넣고 끌기 · 노드 옮김(지점 같이)", "Insert · drag vertex (point follows)"], F),
|
||||
A("plan.unvertex", "plan", ["노드 · 마커 오른쪽 누름"], ["메뉴 [노드 지움] · [마커 지움]", "Menu: delete node · marker"], F),
|
||||
A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F),
|
||||
|
||||
A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F),
|
||||
A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F),
|
||||
A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 · 지점 놓기 · 노드 지움 · 연도 구역 나눔", "Map menu — draw · place point · delete node · split year"], F),
|
||||
A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이 · 노드 12 px 안 = 붙음)", "Move point (with its node · snaps within 12 px)"], F),
|
||||
|
||||
A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F),
|
||||
A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F),
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface RouteEditNavigationParams {
|
||||
leftPan?: boolean;
|
||||
/** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */
|
||||
claimed?: () => boolean;
|
||||
/** 배율 하한 — 안 주면 0.5(B05 그대로). 예정노선 지도만 한반도까지 빼려고 낮은 값을 줌(600-3). */
|
||||
minScale?: () => number;
|
||||
/** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */
|
||||
rotateWheel?: (sign: 1 | -1) => void;
|
||||
draw: () => void;
|
||||
@@ -81,7 +83,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
|
||||
MAX_SCALE_FALLBACK,
|
||||
);
|
||||
const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR;
|
||||
const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor));
|
||||
const scale = Math.min(
|
||||
maxScale,
|
||||
Math.max(params.minScale?.() ?? MIN_SCALE, view.scale * factor),
|
||||
);
|
||||
const ratio = scale / view.scale;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
|
||||
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
|
||||
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
|
||||
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
|
||||
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { createGroupProject } from "../A00_Common/group_workflow";
|
||||
import { navigateTo } from "../A00_Common/router";
|
||||
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
|
||||
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
|
||||
|
||||
const KOREA_CENTER: [number, number] = [127.8, 36.3];
|
||||
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
|
||||
@@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): {
|
||||
}
|
||||
|
||||
draw();
|
||||
// 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래
|
||||
const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat));
|
||||
const field = find.search.parentElement!;
|
||||
field.style.flex = "1 1 12rem";
|
||||
const head = document.createElement("div");
|
||||
head.className = "ui-general-block__actions";
|
||||
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root);
|
||||
head.style.flexWrap = "wrap";
|
||||
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field);
|
||||
const root = document.createElement("div");
|
||||
root.dataset.d01Site = "";
|
||||
root.append(head, map.root, where);
|
||||
root.append(head, find.root, map.root, where);
|
||||
return { root, value: () => site };
|
||||
}
|
||||
|
||||
|
||||
@@ -30,23 +30,24 @@ export interface PlanBaseView {
|
||||
site: { lon: number; lat: number; address: string | null } | null;
|
||||
}
|
||||
|
||||
/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) ·
|
||||
* 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */
|
||||
export interface PlanJob {
|
||||
running: boolean;
|
||||
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;
|
||||
}
|
||||
@@ -62,6 +63,8 @@ export interface TerrainExtent {
|
||||
covered: boolean;
|
||||
sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>;
|
||||
cells: number;
|
||||
/** 받기 칸 상한(넘으면 받기 413). */
|
||||
max_cells: number;
|
||||
}
|
||||
|
||||
/** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */
|
||||
@@ -110,12 +113,12 @@ async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
|
||||
const base = (projectId: string): string => `/projects/${projectId}/plan`;
|
||||
|
||||
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */
|
||||
/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */
|
||||
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));
|
||||
@@ -127,15 +130,16 @@ export const fetchTerrainExtent = (
|
||||
return request(`${base(projectId)}/terrain/extent?${query}`);
|
||||
};
|
||||
|
||||
/** 받기 — 칸 상한 넘음 = 오류(413 글). */
|
||||
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 }> =>
|
||||
@@ -175,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
|
||||
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
|
||||
}
|
||||
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
|
||||
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
|
||||
export interface PlanRaster {
|
||||
box_m: [number, number, number, number];
|
||||
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
|
||||
box?: Array<[number, number]>;
|
||||
image: string;
|
||||
bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>;
|
||||
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
|
||||
counts?: number[];
|
||||
}
|
||||
|
||||
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
|
||||
@@ -246,18 +251,17 @@ export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
|
||||
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
|
||||
request(`${base(projectId)}/layers/bands`);
|
||||
|
||||
/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */
|
||||
/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 ·
|
||||
* 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */
|
||||
export interface PlanForestRoads {
|
||||
count: number;
|
||||
length_m: number;
|
||||
total: number;
|
||||
crs: string[];
|
||||
bounds: [number, number, number, number] | null;
|
||||
distance_m: number | null;
|
||||
source: { files: string[]; bytes: number; uploaded_at: string } | null;
|
||||
collection: GeoJsonCollection;
|
||||
/** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */
|
||||
bounds?: [number, number, number, number] | null;
|
||||
/** 기준점 ~ 가장 가까운 임도(m). */
|
||||
distance_m?: number | null;
|
||||
}
|
||||
|
||||
/** 올린 적 없으면 오류(404 글 그대로). */
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443).
|
||||
"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463).
|
||||
|
||||
- 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선)
|
||||
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안
|
||||
선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`)
|
||||
- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위
|
||||
안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리)
|
||||
- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로)
|
||||
- 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) +
|
||||
`임도망도.json`(파일 이름 · 올린 때)
|
||||
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 →
|
||||
파생 저장본(열쇠 = 원본 지문 · 범위)
|
||||
- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 ·
|
||||
잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점)
|
||||
|
||||
계산 자리 = ③ 서버 단독.
|
||||
"""
|
||||
@@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_time import utc_now_text
|
||||
|
||||
Box = tuple[float, float, float, float]
|
||||
XY = tuple[float, float]
|
||||
|
||||
FOLDER = "D03_PlanAnalysis"
|
||||
SOURCE_ZIP = "임도망도.zip"
|
||||
@@ -41,7 +43,17 @@ DBF_ENCODING = "cp949"
|
||||
MAX_BYTES = 100 * 1024 * 1024
|
||||
SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg")
|
||||
ROAD_KIND = "plan_forest_road"
|
||||
ROAD_VERSION = 1
|
||||
ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리
|
||||
RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18)
|
||||
|
||||
|
||||
def clip_box(origin: XY, have: Box | None) -> Box:
|
||||
"""자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형."""
|
||||
x, y = origin
|
||||
box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M)
|
||||
if have is None:
|
||||
return box
|
||||
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
|
||||
|
||||
|
||||
def source_dir(project_root: Path) -> Path:
|
||||
@@ -100,18 +112,22 @@ def _plain(value: Any) -> Any:
|
||||
return value if isinstance(value, (str, int, float, bool)) or value is None else str(value)
|
||||
|
||||
|
||||
def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"""SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 ·
|
||||
좌표계."""
|
||||
def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
|
||||
"""SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 ·
|
||||
잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` ·
|
||||
자르기 전 모든 선)."""
|
||||
from pyproj import Transformer
|
||||
from shapely.geometry import Point, mapping
|
||||
from shapely.geometry import box as rect
|
||||
from shapely.geometry import mapping
|
||||
from shapely.ops import transform
|
||||
|
||||
frame = rect(*box)
|
||||
here = Point(origin) if origin else None
|
||||
to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform
|
||||
features: list[dict[str, Any]] = []
|
||||
total, length, crs = 0, 0.0, []
|
||||
near: float | None = None
|
||||
edges: list[tuple[float, float, float, float]] = []
|
||||
for shapes in read_shapes(zip_path):
|
||||
crs.append(shapes.crs.to_string())
|
||||
shapes = shapes[shapes.geometry.notna()]
|
||||
@@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
for (_, row), geometry in zip(
|
||||
shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry
|
||||
):
|
||||
if here is not None:
|
||||
gap = geometry.distance(here)
|
||||
near = gap if near is None else min(near, gap)
|
||||
part = geometry.intersection(frame)
|
||||
if part.is_empty or part.length <= 0:
|
||||
continue
|
||||
length += part.length
|
||||
props = {str(k): _plain(v) for k, v in row.items()}
|
||||
props["length_m"] = round(part.length, 1)
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": mapping(transform(to_ll, part)),
|
||||
"properties": props,
|
||||
}
|
||||
)
|
||||
lonlat = transform(to_ll, part)
|
||||
edges.append(lonlat.bounds)
|
||||
features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props})
|
||||
if not total:
|
||||
raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.")
|
||||
return {
|
||||
@@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"length_m": round(length, 1),
|
||||
"total": total,
|
||||
"crs": sorted(set(crs)),
|
||||
"bounds": [
|
||||
min(e[0] for e in edges),
|
||||
min(e[1] for e in edges),
|
||||
max(e[2] for e in edges),
|
||||
max(e[3] for e in edges),
|
||||
]
|
||||
if edges
|
||||
else None,
|
||||
"distance_m": None if near is None else round(near, 1),
|
||||
"collection": {"type": "FeatureCollection", "features": features},
|
||||
}
|
||||
|
||||
|
||||
def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"""올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`."""
|
||||
def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]:
|
||||
"""올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면
|
||||
`FileNotFoundError`."""
|
||||
path = source_dir(project_root) / SOURCE_ZIP
|
||||
stamp = file_stamp(path)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError(
|
||||
"올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요."
|
||||
"올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요."
|
||||
)
|
||||
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box])
|
||||
at = [round(v, 1) for v in origin] if origin else None
|
||||
key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at)
|
||||
saved = read_derived(project_root, ROAD_KIND, key)
|
||||
if saved is not None:
|
||||
return saved["value"]
|
||||
value = clip_roads(path, box, epsg)
|
||||
value = clip_roads(path, box, epsg, origin)
|
||||
try:
|
||||
value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8"))
|
||||
except (OSError, ValueError):
|
||||
@@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
|
||||
|
||||
def save_roads(
|
||||
project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int
|
||||
project_root: Path,
|
||||
files: list[tuple[str, bytes]],
|
||||
box: Box,
|
||||
epsg: int,
|
||||
origin: XY | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
|
||||
(옛 것 그대로)."""
|
||||
"""올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError`
|
||||
(옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리)."""
|
||||
data = pack(files)
|
||||
folder = source_dir(project_root)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
@@ -178,11 +208,13 @@ def save_roads(
|
||||
temporary.write(data)
|
||||
trial = Path(temporary.name)
|
||||
try:
|
||||
got = clip_roads(trial, box, epsg)
|
||||
got = clip_roads(trial, box, epsg, origin)
|
||||
if not got["count"]:
|
||||
gap = got["distance_m"]
|
||||
where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else ""
|
||||
raise ValueError(
|
||||
f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — "
|
||||
"사업지 시군구 파일인지 보세요."
|
||||
f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}"
|
||||
f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요."
|
||||
)
|
||||
os.replace(trial, folder / SOURCE_ZIP)
|
||||
finally:
|
||||
@@ -195,4 +227,4 @@ def save_roads(
|
||||
"uploaded_at": utc_now_text(),
|
||||
},
|
||||
)
|
||||
return road_layer(project_root, box, epsg)
|
||||
return road_layer(project_root, box, epsg, origin)
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 ·
|
||||
저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠
|
||||
- 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` ·
|
||||
15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문)
|
||||
기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) ·
|
||||
파생 저장본(열쇠 = 격자 지문)
|
||||
- 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1)
|
||||
- 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) ·
|
||||
받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 ·
|
||||
@@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope"
|
||||
HILLSHADE_KIND = "plan_hillshade"
|
||||
HILLSHADE_VERSION = 2 # 빈 칸 투명
|
||||
RASTER_KINDS = ("slope", "hillshade")
|
||||
SLOPE_VERSION = 1
|
||||
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
|
||||
SLOPE_BANDS = (
|
||||
(15.0, 35.0, (250, 204, 21, 110)),
|
||||
(35.0, 40.0, (249, 115, 22, 140)),
|
||||
(40.0, 90.0, (220, 38, 38, 160)),
|
||||
)
|
||||
SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603)
|
||||
SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상)
|
||||
SLOPE_EMPTY = 255 # 빈 칸
|
||||
MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로
|
||||
|
||||
|
||||
@@ -179,69 +176,64 @@ def _covers(have: Any, box: Box) -> bool:
|
||||
|
||||
|
||||
def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
|
||||
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음."""
|
||||
"""지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만
|
||||
다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음."""
|
||||
path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY
|
||||
key = _read_json(key_path) or {}
|
||||
if path.is_file() and not key.get("failed") and _covers(key.get("box"), box):
|
||||
covered = path.is_file() and _covers(key.get("box"), box)
|
||||
if covered and not key.get("failed"):
|
||||
return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0}
|
||||
parts = tiles(box)
|
||||
retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로
|
||||
old = ((_read_json(path) or {}).get("features") or []) if retry else []
|
||||
frame = key["box"] if retry else [float(v) for v in box]
|
||||
parts = [tuple(t) for t in retry] if retry else tiles(box)
|
||||
with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool:
|
||||
results = list(pool.map(lambda part: fetch_tile(part, epsg), parts))
|
||||
failed = sum(bad for _, bad in results)
|
||||
if failed == len(parts):
|
||||
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패")
|
||||
features = dedup_parcels([f for got, _ in results for f in got])
|
||||
bad = [list(part) for part, (_, n) in zip(parts, results) if n]
|
||||
if len(bad) == len(parts) and not old:
|
||||
raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패")
|
||||
features = dedup_parcels([*old, *(f for got, _ in results for f in got)])
|
||||
processed.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, {"type": "FeatureCollection", "features": features})
|
||||
atomic_write_json(
|
||||
key_path,
|
||||
{
|
||||
"box": [float(v) for v in box],
|
||||
"box": frame,
|
||||
"epsg": int(epsg),
|
||||
"count": len(features),
|
||||
"failed": failed,
|
||||
"failed": len(bad),
|
||||
"failed_tiles": bad,
|
||||
},
|
||||
)
|
||||
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features))
|
||||
return {"path": path, "fetched": True, "count": len(features), "failed": failed}
|
||||
logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features))
|
||||
return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)}
|
||||
|
||||
|
||||
# ── 급경사 ──────────────────────────────────────────────
|
||||
|
||||
|
||||
def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
|
||||
"""격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율."""
|
||||
def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
|
||||
"""격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255)
|
||||
+ 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸)."""
|
||||
from PIL import Image
|
||||
|
||||
from E07_StabArea.E07_StabArea_Engine import slope_rad
|
||||
|
||||
stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX))
|
||||
grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride]
|
||||
deg = np.degrees(slope_rad(grid, cell_m * stride))
|
||||
known = np.isfinite(deg)
|
||||
index = np.zeros(deg.shape, np.uint8)
|
||||
for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1):
|
||||
index[known & (deg >= low) & (deg < high)] = i
|
||||
total = max(1, int(known.sum()))
|
||||
image = Image.fromarray(np.flipud(index), mode="P")
|
||||
palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]]
|
||||
image.putpalette(palette + [0] * (768 - len(palette)))
|
||||
alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS])
|
||||
pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0
|
||||
known = np.isfinite(pct)
|
||||
value = np.full(pct.shape, SLOPE_EMPTY, np.uint8)
|
||||
value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8)
|
||||
image = Image.fromarray(np.flipud(value), mode="L")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG", optimize=True, transparency=alpha)
|
||||
image.save(buffer, format="PNG", optimize=True)
|
||||
counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1)
|
||||
return {
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"),
|
||||
"bands": [
|
||||
{
|
||||
"min_deg": low,
|
||||
"max_deg": high,
|
||||
"color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]),
|
||||
"ratio": round(int((index == i).sum()) / total, 4),
|
||||
}
|
||||
for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1)
|
||||
],
|
||||
"counts": [int(n) for n in counts],
|
||||
}
|
||||
|
||||
|
||||
@@ -263,17 +255,17 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict
|
||||
def slope_layer(
|
||||
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
|
||||
) -> dict[str, Any]:
|
||||
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본."""
|
||||
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본."""
|
||||
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
|
||||
stamp = file_stamp(npz)
|
||||
if stamp is None:
|
||||
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
|
||||
store_kind, version, build = (
|
||||
(SLOPE_KIND, SLOPE_VERSION, slope_bands_png)
|
||||
(SLOPE_KIND, SLOPE_VERSION, slope_values_png)
|
||||
if kind == "slope"
|
||||
else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png)
|
||||
)
|
||||
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX)
|
||||
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX)
|
||||
saved = read_derived(project_root, store_kind, key)
|
||||
if saved is not None:
|
||||
return saved["value"]
|
||||
|
||||
@@ -11,6 +11,10 @@
|
||||
|
||||
범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위
|
||||
(PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐).
|
||||
위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 ·
|
||||
받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고
|
||||
center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어
|
||||
서버가 다시 켜지면 「끊김」(interrupted).
|
||||
노선 정본(손 그은 선)은
|
||||
`D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음).
|
||||
"""
|
||||
@@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil
|
||||
from pydantic import BaseModel
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from common_util.common_util_crs import lonlat_to_tm
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_json import atomic_write_json
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road
|
||||
@@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set()
|
||||
KINDS = ("terrain", "analyze")
|
||||
|
||||
|
||||
class Center(BaseModel):
|
||||
lon: float
|
||||
lat: float
|
||||
|
||||
|
||||
class TerrainBody(BaseModel):
|
||||
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m)."""
|
||||
"""지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) ·
|
||||
위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림)."""
|
||||
|
||||
margin_m: float | None = None
|
||||
extra: list[tuple[float, float]] = []
|
||||
center: Center | None = None
|
||||
reset: bool = False
|
||||
|
||||
|
||||
async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]:
|
||||
"""(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀."""
|
||||
Ends = tuple[Path, int, list[tuple[float, float]], int | None]
|
||||
|
||||
|
||||
async def _plan_ends(
|
||||
project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False
|
||||
) -> Ends:
|
||||
"""(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점
|
||||
둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면
|
||||
넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만."""
|
||||
root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400
|
||||
site = (await network.project_info(project_id))["site"]
|
||||
epsg = network.work_epsg(plan, site)
|
||||
meta = terrain.received_meta(root)
|
||||
if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg):
|
||||
center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데
|
||||
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
|
||||
if center is not None:
|
||||
band = tm_epsg_for_lon(center[0])
|
||||
if epsg is not None and band != epsg:
|
||||
if not (reset or meta.get("epsg") == band):
|
||||
return (*(await _plan_ends(project_id))[:3], band)
|
||||
epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐
|
||||
epsg = epsg or band
|
||||
corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg))
|
||||
return root, epsg, corners if reset else [*ends, *corners], None
|
||||
if epsg is None:
|
||||
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
||||
ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]]
|
||||
if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음
|
||||
ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg))
|
||||
if not ends:
|
||||
raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.")
|
||||
return root, epsg, ends
|
||||
return root, epsg, ends, None
|
||||
|
||||
|
||||
def _query_xy(extra: str | None) -> list[tuple[float, float]]:
|
||||
@@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]:
|
||||
raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error
|
||||
|
||||
|
||||
def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
|
||||
IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False}
|
||||
|
||||
|
||||
def _job_path(root: Path, kind: str) -> Path:
|
||||
return terrain.models_dir_of(root) / f"plan_job_{kind}.json"
|
||||
|
||||
|
||||
def _note_job(path: Path | None, job: dict[str, Any]) -> None:
|
||||
"""진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속)."""
|
||||
if path is None:
|
||||
return
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
atomic_write_json(path, job)
|
||||
except OSError as error:
|
||||
logger.warning("D 진행 기록 실패: %s", error)
|
||||
|
||||
|
||||
def _saved_job(root: Path, kind: str) -> dict[str, Any]:
|
||||
"""기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 ·
|
||||
message 없음)."""
|
||||
try:
|
||||
saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return dict(IDLE)
|
||||
if not (isinstance(saved, dict) and saved.get("running")):
|
||||
return dict(IDLE)
|
||||
percent = min(99, int(saved.get("percent") or 0))
|
||||
return {**IDLE, "percent": percent, "interrupted": True}
|
||||
|
||||
|
||||
def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]:
|
||||
key = (str(project_id), kind)
|
||||
if (_JOBS.get(key) or {}).get("running"):
|
||||
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
|
||||
job = {"running": True, "percent": 0, "message": "시작", "error": None}
|
||||
job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False}
|
||||
_JOBS[key] = job
|
||||
path = _job_path(root, kind) if root is not None else None
|
||||
_note_job(path, job)
|
||||
|
||||
def report(percent: int, message: str) -> None:
|
||||
job.update(percent=int(percent), message=message)
|
||||
_note_job(path, job)
|
||||
|
||||
async def run() -> None:
|
||||
try:
|
||||
@@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]:
|
||||
job["error"] = str(getattr(error, "detail", None) or error)
|
||||
finally:
|
||||
job["running"] = False
|
||||
_note_job(path, job)
|
||||
|
||||
task = asyncio.create_task(run(), name=f"D {kind} {project_id}")
|
||||
_TASKS.add(task)
|
||||
@@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int
|
||||
|
||||
@router.get("/{project_id}/plan/terrain/extent", response_model=None)
|
||||
async def plan_terrain_extent(
|
||||
project_id: UUID, margin_m: float | None = None, extra: str | None = None
|
||||
project_id: UUID,
|
||||
margin_m: float | None = None,
|
||||
extra: str | None = None,
|
||||
lon: float | None = None,
|
||||
lat: float | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위."""
|
||||
root, epsg, ends = await _plan_ends(project_id)
|
||||
"""받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪
|
||||
받은 범위."""
|
||||
center = (lon, lat) if lon is not None and lat is not None else None
|
||||
root, epsg, ends, _ = await _plan_ends(project_id, center)
|
||||
view = await asyncio.to_thread(
|
||||
terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m
|
||||
)
|
||||
@@ -145,34 +218,73 @@ async def plan_terrain_extent(
|
||||
|
||||
@router.post("/{project_id}/plan/terrain", response_model=None)
|
||||
async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]:
|
||||
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`."""
|
||||
"""사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` ·
|
||||
위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413."""
|
||||
body = body or TerrainBody()
|
||||
root, epsg, ends = await _plan_ends(project_id)
|
||||
center = (body.center.lon, body.center.lat) if body.center else None
|
||||
root, epsg, ends, band = await _plan_ends(project_id, center, body.reset)
|
||||
if band is not None:
|
||||
return {
|
||||
"status": "band",
|
||||
"epsg": epsg,
|
||||
"center_epsg": band,
|
||||
"message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?",
|
||||
}
|
||||
have = None if body.reset else terrain.received_box(root, epsg)
|
||||
if center is not None and have is not None:
|
||||
far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have)
|
||||
if far > terrain.FAR_M:
|
||||
return {
|
||||
"status": "far",
|
||||
"distance_m": round(far),
|
||||
"message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — "
|
||||
"옛 받은 범위를 버리고 새로 받을까요?",
|
||||
}
|
||||
points = [*ends, *body.extra]
|
||||
cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have))
|
||||
if cells > terrain.MAX_CELLS:
|
||||
raise HTTPException(
|
||||
status_code=413,
|
||||
detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · "
|
||||
f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)",
|
||||
)
|
||||
|
||||
async def work(report: Any) -> None:
|
||||
got = await asyncio.to_thread(
|
||||
terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report
|
||||
terrain.ensure_plan_terrain,
|
||||
root,
|
||||
points,
|
||||
epsg,
|
||||
body.margin_m,
|
||||
report,
|
||||
reset=have is None,
|
||||
center=center,
|
||||
)
|
||||
await _save_models(project_id, got["models"], epsg)
|
||||
report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매")
|
||||
|
||||
return _start_job(project_id, "terrain", work)
|
||||
return _start_job(project_id, "terrain", work, root)
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/progress", response_model=None)
|
||||
async def plan_progress(project_id: UUID) -> dict[str, Any]:
|
||||
idle = {"running": False, "percent": 0, "message": None, "error": None}
|
||||
return {
|
||||
"status": "success",
|
||||
**{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS},
|
||||
}
|
||||
"""일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100)."""
|
||||
jobs: dict[str, Any] = {}
|
||||
for kind in KINDS:
|
||||
job = _JOBS.get((str(project_id), kind))
|
||||
if job is None:
|
||||
try:
|
||||
job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind)
|
||||
except HTTPException:
|
||||
job = IDLE
|
||||
jobs[kind] = dict(job)
|
||||
return {"status": "success", **jobs}
|
||||
|
||||
|
||||
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
|
||||
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
|
||||
root, epsg, _ = await _plan_ends(project_id)
|
||||
box = terrain.received_box(root)
|
||||
root, epsg, _, _ = await _plan_ends(project_id)
|
||||
box = terrain.received_box(root, epsg)
|
||||
if box is None:
|
||||
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
|
||||
return root, epsg, box
|
||||
@@ -208,7 +320,7 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
|
||||
|
||||
@router.get("/{project_id}/plan/layers/raster", response_model=None)
|
||||
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]:
|
||||
"""급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
|
||||
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
|
||||
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
|
||||
if kind == "landslide":
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
@@ -301,12 +413,30 @@ async def plan_layer_zero_order(
|
||||
return {"status": "success", **value}
|
||||
|
||||
|
||||
async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]:
|
||||
"""(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 ·
|
||||
지점 가운데 차례 · 지형 받기 전에도(595-4)."""
|
||||
root, epsg, ends, _ = await _plan_ends(project_id)
|
||||
center = terrain.received_meta(root).get("center")
|
||||
site = (await network.project_info(project_id))["site"]
|
||||
if center:
|
||||
origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg)
|
||||
elif site:
|
||||
origin = lonlat_to_tm(site["lon"], site["lat"], epsg)
|
||||
else:
|
||||
xs, ys = [p[0] for p in ends], [p[1] for p in ends]
|
||||
origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2)
|
||||
box = forest_road.clip_box(origin, terrain.received_box(root, epsg))
|
||||
return root, epsg, box, origin
|
||||
|
||||
|
||||
@router.get("/{project_id}/plan/layers/forest-roads", response_model=None)
|
||||
async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]:
|
||||
"""올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404."""
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
"""올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점
|
||||
거리) — 올린 적 없으면 404."""
|
||||
root, epsg, box, origin = await _road_frame(project_id)
|
||||
try:
|
||||
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg)
|
||||
value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin)
|
||||
except FileNotFoundError as error:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
return {"status": "success", **value}
|
||||
@@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload(
|
||||
project_id: UUID, files: list[UploadFile] = File(...)
|
||||
) -> dict[str, Any]:
|
||||
"""임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나
|
||||
받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413."""
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413."""
|
||||
root, epsg, box, origin = await _road_frame(project_id)
|
||||
got: list[tuple[str, bytes]] = []
|
||||
total = 0
|
||||
for upload in files:
|
||||
@@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload(
|
||||
if not total:
|
||||
raise HTTPException(status_code=400, detail="빈 파일입니다.")
|
||||
try:
|
||||
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg)
|
||||
value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin)
|
||||
except ValueError as error:
|
||||
raise HTTPException(status_code=400, detail=str(error)) from error
|
||||
return {"status": "success", **value}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2).
|
||||
"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 ·
|
||||
561-2 · 595).
|
||||
|
||||
지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0):
|
||||
|
||||
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음)
|
||||
- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) ·
|
||||
새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 ·
|
||||
칸 상한 `MAX_CELLS`(넘으면 라우터가 거절)
|
||||
- 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음)
|
||||
- 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀)
|
||||
- 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀)
|
||||
- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속)
|
||||
- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`)
|
||||
- DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json`
|
||||
(상자 · 여유 · 좌표계 · 위치 칸 가운데)
|
||||
- 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음)
|
||||
- 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만
|
||||
|
||||
@@ -43,7 +47,6 @@ from config.config_system_terrain import (
|
||||
SHEET_SURFACE_DEFAULT_METHOD,
|
||||
SHEET_SURFACE_MARGIN_M,
|
||||
)
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0
|
||||
MAX_MARGIN_M = 5000.0
|
||||
# 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2)
|
||||
SITE_RADIUS_M = 1000.0
|
||||
# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4)
|
||||
FAR_M = 5000.0
|
||||
MAX_CELLS = 5_000_000
|
||||
STAGE = Path("B04_PreProcess")
|
||||
PLAN_MODELS = "models_plan"
|
||||
TERRAIN_META = "plan_terrain.json"
|
||||
@@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float:
|
||||
return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m)))
|
||||
|
||||
|
||||
def received_box(project_root: Path) -> Box | None:
|
||||
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None."""
|
||||
meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
|
||||
def received_meta(project_root: Path) -> dict[str, Any]:
|
||||
"""받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict."""
|
||||
return _read_json(models_dir_of(project_root) / TERRAIN_META) or {}
|
||||
|
||||
|
||||
def received_box(project_root: Path, epsg: int | None = None) -> Box | None:
|
||||
"""받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None."""
|
||||
meta = received_meta(project_root)
|
||||
if epsg is not None and meta.get("epsg") not in (None, int(epsg)):
|
||||
return None
|
||||
try:
|
||||
x0, y0, x1, y1 = (float(v) for v in meta["box"])
|
||||
except (KeyError, TypeError, ValueError):
|
||||
@@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box:
|
||||
return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3])
|
||||
|
||||
|
||||
def cells_of(box: Box) -> int:
|
||||
"""DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정."""
|
||||
return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2)
|
||||
|
||||
|
||||
def distance_to_box(xy: XY, box: Box) -> float:
|
||||
"""점 ~ 상자 거리(m · 안이면 0)."""
|
||||
dx = max(box[0] - xy[0], 0.0, xy[0] - box[2])
|
||||
dy = max(box[1] - xy[1], 0.0, xy[1] - box[3])
|
||||
return math.hypot(dx, dy)
|
||||
|
||||
|
||||
def covers(have: Box | None, box: Box) -> bool:
|
||||
return (
|
||||
have is not None
|
||||
@@ -135,7 +160,7 @@ def plan_extent(
|
||||
) -> dict[str, Any]:
|
||||
"""받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m)."""
|
||||
margin = clamp_margin(margin_m)
|
||||
have = received_box(project_root)
|
||||
have = received_box(project_root, epsg)
|
||||
box = wanted_box(points, margin, have)
|
||||
store = get_project_map_sheets_dir(project_root)
|
||||
sheets = []
|
||||
@@ -157,8 +182,8 @@ def plan_extent(
|
||||
"have": box_ring(have, epsg) if have else None,
|
||||
"covered": covers(have, box),
|
||||
"sheets": sheets,
|
||||
# DEM 칸 수 어림 — 받기 시간 · 메모리 가늠
|
||||
"cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2),
|
||||
"cells": cells_of(box),
|
||||
"max_cells": MAX_CELLS,
|
||||
}
|
||||
|
||||
|
||||
@@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None:
|
||||
return None
|
||||
|
||||
|
||||
def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]:
|
||||
"""병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
|
||||
def _merge_sheets(
|
||||
processed: Path, store: Path, available: list[str], keep_old: bool = True
|
||||
) -> list[str]:
|
||||
"""병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀."""
|
||||
key_path = processed / "sheet_merge.key.json"
|
||||
old = (_read_json(key_path) or {}).get("sheets") or []
|
||||
old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else []
|
||||
sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)})
|
||||
key = {"sheets": sheets, "parser": PARSER_VERSION}
|
||||
if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))):
|
||||
@@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st
|
||||
return sheets
|
||||
|
||||
|
||||
def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str:
|
||||
"""위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed."""
|
||||
def _satellite(
|
||||
processed: Path,
|
||||
prj: Path,
|
||||
points: list[tuple[float, float]],
|
||||
epsg: int,
|
||||
keep_old: bool = True,
|
||||
) -> str:
|
||||
"""위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done ·
|
||||
failed."""
|
||||
from pyproj import Transformer
|
||||
|
||||
to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True)
|
||||
@@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep
|
||||
xs.append(x)
|
||||
ys.append(y)
|
||||
need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]}
|
||||
meta = _read_json(processed / "vworld_satellite_meta.json")
|
||||
meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None
|
||||
try:
|
||||
old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None
|
||||
except (KeyError, TypeError, ValueError):
|
||||
@@ -234,8 +268,11 @@ def ensure_plan_terrain(
|
||||
epsg: int,
|
||||
margin_m: float | None = None,
|
||||
on_progress: Progress | None = None,
|
||||
reset: bool = False,
|
||||
center: tuple[float, float] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).
|
||||
"""사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) ·
|
||||
`reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만).
|
||||
|
||||
돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록.
|
||||
"""
|
||||
@@ -250,28 +287,24 @@ def ensure_plan_terrain(
|
||||
processed.mkdir(parents=True, exist_ok=True)
|
||||
prj = write_plan_prj(project_root, epsg)
|
||||
margin = clamp_margin(margin_m)
|
||||
box = wanted_box(points, margin, received_box(project_root))
|
||||
have = None if reset else received_box(project_root, epsg)
|
||||
box = wanted_box(points, margin, have)
|
||||
|
||||
report(5, "수치지형도 도엽 고르는 중")
|
||||
report(5, "1/5 수치지형도 도엽 고르는 중")
|
||||
wanted = sheets_for_box(box, epsg)
|
||||
store = get_project_map_sheets_dir(project_root)
|
||||
before = {s for s in wanted if get_sheet_path(store, s) is not None}
|
||||
report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
|
||||
report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중")
|
||||
got = ensure_sheets(store, wanted)
|
||||
if not got["available"]:
|
||||
raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}")
|
||||
report(45, "도엽 레이어 병합 중")
|
||||
merged = _merge_sheets(processed, store, list(got["available"]))
|
||||
report(60, "위성 지도 받는 중")
|
||||
satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg)
|
||||
report(62, "지적도 받는 중")
|
||||
try:
|
||||
cadastral = layers.ensure_cadastral(processed, box, epsg)["count"]
|
||||
except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시)
|
||||
logger.warning("D 지적도 받기 실패: %s", exc)
|
||||
cadastral = None
|
||||
report(45, "3/5 도엽 레이어 병합 중")
|
||||
merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None)
|
||||
report(60, "4/5 위성 지도 받는 중")
|
||||
ring = [(p[1], p[0]) for p in box_ring(box, epsg)]
|
||||
satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None)
|
||||
|
||||
report(65, "사업 지형면 만드는 중")
|
||||
report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음")
|
||||
models_dir = models_dir_of(project_root)
|
||||
models_dir.mkdir(parents=True, exist_ok=True)
|
||||
# B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값)
|
||||
@@ -293,7 +326,11 @@ def ensure_plan_terrain(
|
||||
"사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인"
|
||||
f"(병합 도엽 {len(merged)}매)"
|
||||
)
|
||||
atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin})
|
||||
meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)}
|
||||
kept = None if reset else received_meta(project_root).get("center")
|
||||
if center or kept:
|
||||
meta["center"] = list(center or kept)
|
||||
atomic_write_json(models_dir / TERRAIN_META, meta)
|
||||
report(100, "지형 받기 끝")
|
||||
return {
|
||||
"sheets": {
|
||||
@@ -303,7 +340,6 @@ def ensure_plan_terrain(
|
||||
"merged": merged,
|
||||
},
|
||||
"satellite": satellite,
|
||||
"cadastral": cadastral,
|
||||
"box_m": list(box),
|
||||
"models": models,
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { pointColor, routeColor } from "@ui/ui_template_palette";
|
||||
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
|
||||
import {
|
||||
distanceAlong,
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type HeightAt,
|
||||
} 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,
|
||||
@@ -128,7 +129,7 @@ export function paintNetwork(
|
||||
context.lineJoin = "round";
|
||||
network.lines.forEach((line, index) => {
|
||||
if (line.vertices.length < 2) return;
|
||||
context.strokeStyle = index === active ? colors.active : colors.line;
|
||||
context.strokeStyle = index === active ? colors.active : routeColor(index);
|
||||
context.lineWidth = line.branch ? 2.5 : 3;
|
||||
const path = (vertices: ReadonlyArray<Vertex>): void => {
|
||||
context.beginPath();
|
||||
@@ -181,7 +182,7 @@ export function paintNetwork(
|
||||
}
|
||||
|
||||
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
|
||||
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
|
||||
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
|
||||
return {
|
||||
draw(context, map) {
|
||||
points().forEach((point, i) => {
|
||||
@@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
|
||||
context.fillStyle = "#ffffff";
|
||||
context.fill();
|
||||
});
|
||||
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
|
||||
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,57 +1,67 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Edit.ts
|
||||
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만.
|
||||
* D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만.
|
||||
*
|
||||
* 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위
|
||||
* (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
|
||||
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) ·
|
||||
* 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함.
|
||||
* 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) ·
|
||||
* 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 ·
|
||||
* Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움.
|
||||
* 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 ·
|
||||
* 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침).
|
||||
* 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감).
|
||||
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함).
|
||||
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름).
|
||||
* 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 ·
|
||||
* 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂.
|
||||
* 「같은 해」 = 왼쪽 해 줄로만(`selectYear`).
|
||||
* 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) ·
|
||||
* 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함.
|
||||
* 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 ·
|
||||
* 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 ·
|
||||
* 선 밖으로 끌어냄 = 지움(합침).
|
||||
* 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크.
|
||||
* 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음).
|
||||
* 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로.
|
||||
* 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함).
|
||||
* 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름).
|
||||
* ========================================================================== */
|
||||
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { keyIs } from "../A00_Common/key_registry";
|
||||
import {
|
||||
distanceAlong,
|
||||
lineLength,
|
||||
pointAtDistance,
|
||||
sliceLine,
|
||||
} from "./logic/D03_PlanAnalysis_Draw";
|
||||
import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
insertVertex,
|
||||
moveMark,
|
||||
moveNodesAt,
|
||||
moveVertex,
|
||||
piecesInBox,
|
||||
removeLine,
|
||||
removeMark,
|
||||
removeVertex,
|
||||
reverseLine,
|
||||
samePoint,
|
||||
splitPiece,
|
||||
branchFromEnd,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
type ViewBox,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import {
|
||||
movePointsAt,
|
||||
nodeSegment,
|
||||
removeAll,
|
||||
removeSegments,
|
||||
segmentsInBox,
|
||||
type PlanNode,
|
||||
type PlanSegment,
|
||||
} from "./logic/D03_PlanAnalysis_Select";
|
||||
import {
|
||||
lineAt,
|
||||
paintMark,
|
||||
planLabel,
|
||||
screenGap,
|
||||
SNAP_PX,
|
||||
strokeCased,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select";
|
||||
|
||||
export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select";
|
||||
|
||||
/** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */
|
||||
const DRAG_PX = 4;
|
||||
/** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */
|
||||
const MARK_OFF_PX = 3 * SNAP_PX;
|
||||
|
||||
export type PlanScope = "piece" | "line" | "year";
|
||||
const NEXT_SCOPE: Record<PlanScope, PlanScope> = { piece: "line", line: "year", year: "piece" };
|
||||
|
||||
export interface PlanEditParams {
|
||||
network: () => PlanNetwork;
|
||||
/** 지점(긋기에 붙는 점 · 591-2). */
|
||||
@@ -59,6 +69,9 @@ export interface PlanEditParams {
|
||||
commit: (next: PlanNetwork) => void;
|
||||
/** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */
|
||||
show: (next: PlanNetwork) => void;
|
||||
/** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */
|
||||
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
|
||||
showPoints: (points: Vertex[], next: PlanNetwork) => void;
|
||||
/** 고르기 받는 때(쉴 때 · 재기 아님). */
|
||||
enabled: () => boolean;
|
||||
/** 고치기 됨(산림청). */
|
||||
@@ -67,151 +80,123 @@ export interface PlanEditParams {
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */
|
||||
export interface PlanSelection {
|
||||
pieces: string[];
|
||||
scope: PlanScope;
|
||||
seed: string | null;
|
||||
/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */
|
||||
export interface PlanHit {
|
||||
mark: string | null;
|
||||
node: PlanNode | null;
|
||||
segment: PlanSegment | null;
|
||||
}
|
||||
|
||||
export interface PlanEdit {
|
||||
layer: PlanMapLayer;
|
||||
selected: () => PlanSelection | null;
|
||||
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */
|
||||
/** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */
|
||||
paths: () => Vertex[][];
|
||||
/** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */
|
||||
select: (pieces: string[], mark?: string | null) => void;
|
||||
/** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */
|
||||
/** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */
|
||||
setScope: (scope: PlanScope) => void;
|
||||
/** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */
|
||||
flipLine: () => void;
|
||||
dropLine: () => void;
|
||||
selectNode: (node: PlanNode) => void;
|
||||
selectRegion: (line: string) => void;
|
||||
selectYear: (year: number) => void;
|
||||
selectAll: () => void;
|
||||
/** 고른 것 지움(Delete · 판 [지움]). */
|
||||
dropSelected: () => void;
|
||||
dropNode: (node: PlanNode) => void;
|
||||
dropMark: () => void;
|
||||
/** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */
|
||||
branchLine: () => void;
|
||||
/** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */
|
||||
lineIndex: () => number;
|
||||
dropRegion: (line: string) => void;
|
||||
/** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */
|
||||
dropAll: () => void;
|
||||
/** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */
|
||||
splitAt: (px: number, py: number, map: ScreenMap) => boolean;
|
||||
/** 이 자리에 무엇이 있나(우클릭 메뉴). */
|
||||
hit: (px: number, py: number, map: ScreenMap) => PlanHit;
|
||||
/** 키 — 받았으면 참(Delete · Esc). */
|
||||
key: (event: KeyboardEvent) => boolean;
|
||||
/** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */
|
||||
/** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */
|
||||
sync: () => void;
|
||||
/** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */
|
||||
box: () => (ViewBox & { crossing: boolean }) | null;
|
||||
}
|
||||
|
||||
type Drag =
|
||||
| { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null }
|
||||
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
|
||||
| { kind: "locked"; note: string; told: boolean }
|
||||
/** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */
|
||||
| {
|
||||
kind: "grow";
|
||||
piece: string;
|
||||
kind: "vertex";
|
||||
line: number;
|
||||
vertex: number;
|
||||
/** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */
|
||||
from: Vertex;
|
||||
follow: boolean;
|
||||
before: PlanNetwork;
|
||||
points: Vertex[];
|
||||
last: [Vertex[], PlanNetwork] | null;
|
||||
}
|
||||
| { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null }
|
||||
/** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */
|
||||
| {
|
||||
kind: "press";
|
||||
x: number;
|
||||
y: number;
|
||||
hit: { line: number; segment: number; point: Vertex } | null;
|
||||
node: PlanNode | null;
|
||||
on: { line: number; segment: number; point: Vertex } | null;
|
||||
grow: boolean;
|
||||
}
|
||||
/** 빈 곳 끌기 — 보이는 px 시작 · 지금. */
|
||||
| { kind: "box"; box: ViewBox; shift: boolean };
|
||||
|
||||
export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
let selected: PlanSelection | null = null;
|
||||
const sel = createPlanSelect(params.network, params.onSelect);
|
||||
let drag: Drag | null = null;
|
||||
/** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */
|
||||
let markDrop: { point: Vertex; ok: boolean; text: string } | null = null;
|
||||
|
||||
const pieceIds = (network: PlanNetwork): Set<string> =>
|
||||
new Set(network.pieces.map((piece) => piece.id));
|
||||
const lineIndexOf = (network: PlanNetwork, id: string): number =>
|
||||
network.lines.findIndex((line) => line.id === id);
|
||||
|
||||
/** 씨 구간 → 넓이만큼 구간 묶음. */
|
||||
function expand(seed: string, scope: PlanScope): string[] {
|
||||
const network = params.network();
|
||||
const piece = network.pieces.find((p) => p.id === seed);
|
||||
if (!piece) return [];
|
||||
if (scope === "piece") return [seed];
|
||||
return network.pieces
|
||||
.filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year))
|
||||
.map((p) => p.id);
|
||||
}
|
||||
|
||||
function choose(next: PlanSelection | null): void {
|
||||
selected = next && next.pieces.length ? next : null;
|
||||
params.onSelect();
|
||||
}
|
||||
|
||||
function select(pieces: string[], mark: string | null = null): void {
|
||||
choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark });
|
||||
}
|
||||
|
||||
function setScope(scope: PlanScope): void {
|
||||
const seed = selected?.seed ?? selected?.pieces[0];
|
||||
if (!seed) return;
|
||||
choose({ pieces: expand(seed, scope), scope, seed, mark: null });
|
||||
}
|
||||
|
||||
function lineIndex(): number {
|
||||
const network = params.network();
|
||||
if (!selected) return -1;
|
||||
const lines = new Set(
|
||||
selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line),
|
||||
);
|
||||
if (lines.size !== 1) return -1;
|
||||
const [id] = lines;
|
||||
return network.lines.findIndex((line) => line.id === id);
|
||||
}
|
||||
|
||||
/** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */
|
||||
function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void {
|
||||
const network = params.network();
|
||||
const index = lineIndex();
|
||||
/** 꼴 바꿈 하나 — 까닭 글이면 알림. */
|
||||
function apply(next: PlanNetwork | string): void {
|
||||
if (!params.editable()) return;
|
||||
if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info");
|
||||
const next = step(network, index);
|
||||
if (typeof next === "string") return void showToast(next, "info");
|
||||
params.commit(next);
|
||||
sync();
|
||||
sel.sync();
|
||||
}
|
||||
|
||||
function dropMark(): void {
|
||||
const network = params.network();
|
||||
const piece = network.pieces.find((p) => p.id === selected?.mark);
|
||||
if (!piece || !params.editable()) return;
|
||||
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
|
||||
sync();
|
||||
const piece = network.pieces.find((p) => p.id === sel.get()?.mark);
|
||||
if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3));
|
||||
}
|
||||
|
||||
/** Delete — 마크 · 선 통째 고름 = 그 선. */
|
||||
function dropSelected(): void {
|
||||
if (selected?.mark) return dropMark();
|
||||
function dropNode(node: PlanNode): void {
|
||||
const network = params.network();
|
||||
const index = lineIndex();
|
||||
const whole =
|
||||
index >= 0 &&
|
||||
network.pieces
|
||||
.filter((p) => p.line === network.lines[index].id)
|
||||
.every((p) => selected!.pieces.includes(p.id));
|
||||
if (!whole)
|
||||
return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info");
|
||||
apply((net, i) => removeLine(net, i));
|
||||
const index = lineIndexOf(network, node.line);
|
||||
if (index >= 0) apply(removeVertex(network, index, node.vertex));
|
||||
}
|
||||
|
||||
/** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */
|
||||
function sync(): void {
|
||||
if (!selected) return;
|
||||
const ids = pieceIds(params.network());
|
||||
const pieces = selected.pieces.filter((id) => ids.has(id));
|
||||
selected = pieces.length
|
||||
? {
|
||||
...selected,
|
||||
pieces,
|
||||
seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0],
|
||||
mark: selected.mark && ids.has(selected.mark) ? selected.mark : null,
|
||||
}
|
||||
: null;
|
||||
function dropRegion(line: string): void {
|
||||
const network = params.network();
|
||||
const index = lineIndexOf(network, line);
|
||||
if (index >= 0) apply(removeLine(network, index));
|
||||
}
|
||||
|
||||
const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null =>
|
||||
network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6)
|
||||
?.id ?? null;
|
||||
function dropAll(): void {
|
||||
const network = params.network();
|
||||
if (!network.lines.length) return;
|
||||
apply(removeAll(network));
|
||||
showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info");
|
||||
}
|
||||
|
||||
/** Delete · 판 [지움] — 고름 꼴마다. */
|
||||
function dropSelected(): void {
|
||||
const picked = sel.get();
|
||||
if (!picked) return;
|
||||
if (picked.kind === "mark") return dropMark();
|
||||
if (picked.kind === "node") return dropNode(picked.node!);
|
||||
if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments));
|
||||
if (picked.kind === "line") return dropRegion(picked.line!);
|
||||
if (picked.kind === "all") return dropAll();
|
||||
showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info");
|
||||
}
|
||||
|
||||
/** 화면 px 둘레 마크(조각 시작) — 조각 차례. */
|
||||
function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null {
|
||||
@@ -245,12 +230,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
return best;
|
||||
}
|
||||
|
||||
/** 잠금 까닭 — 지점에 놓인 점. */
|
||||
function lockOf(network: PlanNetwork, line: number, vertex: number): string | null {
|
||||
const point = network.lines[line].vertices[vertex];
|
||||
return params.base().some((p) => samePoint(p, point))
|
||||
? "지점 자리 점은 지점 핀을 끌어 옮깁니다."
|
||||
: null;
|
||||
/** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */
|
||||
function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null {
|
||||
const near = params
|
||||
.base()
|
||||
.filter((p) => !samePoint(p, skip))
|
||||
.map((p) => ({ p, gap: screenGap(map, p, px, py) }))
|
||||
.filter((item) => item.gap <= SNAP_PX)
|
||||
.sort((a, b) => a.gap - b.gap)[0];
|
||||
return near ? near.p : null;
|
||||
}
|
||||
|
||||
function hit(px: number, py: number, map: ScreenMap): PlanHit {
|
||||
const network = params.network();
|
||||
const mark = markAt(network, px, py, map);
|
||||
if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null };
|
||||
const vertex = vertexAt(network, px, py, map);
|
||||
if (vertex) {
|
||||
const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex };
|
||||
return { mark: null, node, segment: nodeSegment(network, node) };
|
||||
}
|
||||
const on = lineAt(network, px, py, map);
|
||||
if (!on) return { mark: null, node: null, segment: null };
|
||||
return {
|
||||
mark: null,
|
||||
node: null,
|
||||
segment: { line: network.lines[on.line].id, index: on.segment },
|
||||
};
|
||||
}
|
||||
|
||||
/** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */
|
||||
@@ -260,66 +266,62 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
map: ScreenMap,
|
||||
): { point: Vertex; next: PlanNetwork | null; piece: string | null } {
|
||||
const network = params.network();
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (!hit) return { point: map.toMetric(px, py), next: null, piece: null };
|
||||
const next = splitPiece(network, network.lines[hit.line].id, hit.atM);
|
||||
const on = lineAt(network, px, py, map);
|
||||
if (!on) return { point: map.toMetric(px, py), next: null, piece: null };
|
||||
const next = splitPiece(network, network.lines[on.line].id, on.atM);
|
||||
const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id));
|
||||
return { point: hit.point, next: added ? next : null, piece: added?.id ?? null };
|
||||
return { point: on.point, next: added ? next : null, piece: added?.id ?? null };
|
||||
}
|
||||
|
||||
/** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */
|
||||
function pressPiece(
|
||||
piece: string,
|
||||
shift: boolean,
|
||||
x: number,
|
||||
y: number,
|
||||
hit: { line: number; segment: number; point: Vertex } | null,
|
||||
): void {
|
||||
if (shift && selected) {
|
||||
const has = selected.pieces.includes(piece);
|
||||
const pieces = has
|
||||
? selected.pieces.filter((id) => id !== piece)
|
||||
: [...selected.pieces, piece];
|
||||
return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null });
|
||||
function splitAt(px: number, py: number, map: ScreenMap): boolean {
|
||||
if (!params.enabled() || !params.editable()) return false;
|
||||
const place = markPlace(px, py, map);
|
||||
if (!place.next || !place.piece) {
|
||||
showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info");
|
||||
return false;
|
||||
}
|
||||
if (selected?.pieces.includes(piece) && !selected.mark) {
|
||||
drag = { kind: "grow", piece, x, y, hit };
|
||||
return;
|
||||
}
|
||||
select([piece]);
|
||||
params.commit(place.next);
|
||||
sel.pieces([place.piece], place.piece);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */
|
||||
const pickedLines = (network: PlanNetwork): Set<string> =>
|
||||
new Set(
|
||||
(selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""),
|
||||
);
|
||||
/** 노드 끌기 시작 — 지점 자리면 지점도 같이. */
|
||||
function startVertex(line: number, vertex: number): Drag {
|
||||
const before = params.network();
|
||||
const from = before.lines[line].vertices[vertex];
|
||||
const points = params.base();
|
||||
const follow = points.some((p) => samePoint(p, from));
|
||||
return { kind: "vertex", line, vertex, from, follow, before, points, last: null };
|
||||
}
|
||||
|
||||
/** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */
|
||||
function pickedLines(network: PlanNetwork): Set<string> {
|
||||
const picked = sel.get();
|
||||
if (!picked) return new Set();
|
||||
if (picked.kind === "all") return new Set(network.lines.map((line) => line.id));
|
||||
const ids = picked.segments.map((seg) => seg.line);
|
||||
for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? "");
|
||||
if (picked.line) ids.push(picked.line);
|
||||
return new Set(ids);
|
||||
}
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw(context, map) {
|
||||
const network = params.network();
|
||||
const colors = planColors();
|
||||
const path = (points: Vertex[]): void => {
|
||||
const picked = sel.get();
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
for (const points of sel.paths()) {
|
||||
context.beginPath();
|
||||
points.forEach((point, i) => {
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
if (i) context.lineTo(sx, sy);
|
||||
else context.moveTo(sx, sy);
|
||||
});
|
||||
context.stroke();
|
||||
};
|
||||
context.lineCap = "round";
|
||||
context.lineJoin = "round";
|
||||
context.strokeStyle = colors.active;
|
||||
context.globalAlpha = 0.8;
|
||||
context.lineWidth = 8;
|
||||
for (const id of selected?.pieces ?? []) {
|
||||
const piece = network.pieces.find((p) => p.id === id);
|
||||
const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices;
|
||||
if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m));
|
||||
strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1)
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
// 꺾임점 손잡이(고른 구간이 든 선)
|
||||
// 꺾임점 손잡이(고른 것이 든 선)
|
||||
const lines = pickedLines(network);
|
||||
context.lineWidth = 2;
|
||||
context.fillStyle = "#ffffff";
|
||||
@@ -331,11 +333,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
context.strokeRect(sx - 4, sy - 4, 8, 8);
|
||||
}
|
||||
}
|
||||
// 고른 마크 테
|
||||
const mark = network.pieces.find((p) => p.id === selected?.mark);
|
||||
// 고른 노드 · 마크 테
|
||||
const node = picked?.kind === "node" ? picked.node : null;
|
||||
const nodePoint = node
|
||||
? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex]
|
||||
: null;
|
||||
const mark = network.pieces.find((p) => p.id === picked?.mark);
|
||||
const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices;
|
||||
if (mark && markLine) {
|
||||
const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m));
|
||||
const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null);
|
||||
if (ring) {
|
||||
const [sx, sy] = map.toScreen(ring);
|
||||
context.beginPath();
|
||||
context.arc(sx, sy, 12, 0, Math.PI * 2);
|
||||
context.lineWidth = 3;
|
||||
@@ -378,48 +385,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
}
|
||||
},
|
||||
press(px, py, map, button, shift) {
|
||||
if (!params.enabled()) return false;
|
||||
if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu)
|
||||
const network = params.network();
|
||||
const editable = params.editable();
|
||||
const mark = markAt(network, px, py, map);
|
||||
const vertex = vertexAt(network, px, py, map);
|
||||
if (button === 2) {
|
||||
if (!editable) return false;
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
params.commit(removeMark(network, piece.line, piece.from_m, 1e-3));
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (!vertex || lockOf(network, vertex.line, vertex.vertex)) return false; // 지점 = 지도 메뉴
|
||||
const next = removeVertex(network, vertex.line, vertex.vertex);
|
||||
if (typeof next === "string") showToast(next, "info");
|
||||
else params.commit(next);
|
||||
sync();
|
||||
return true;
|
||||
}
|
||||
if (mark !== null) {
|
||||
const piece = network.pieces[mark];
|
||||
select([piece.id], piece.id);
|
||||
if (editable) drag = { kind: "mark", index: mark, before: network, last: null };
|
||||
sel.pieces([piece.id], piece.id);
|
||||
if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null };
|
||||
return true;
|
||||
}
|
||||
if (vertex) {
|
||||
const id = network.lines[vertex.line].id;
|
||||
const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1));
|
||||
const piece = pieceAt(network, id, atM);
|
||||
if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null);
|
||||
const lock = lockOf(network, vertex.line, vertex.vertex);
|
||||
if (editable && !shift)
|
||||
drag = lock
|
||||
? { kind: "locked", note: lock, told: false }
|
||||
: { kind: "vertex", ...vertex, before: network, last: null };
|
||||
return true;
|
||||
}
|
||||
const hit = lineAt(network, px, py, map);
|
||||
if (hit) {
|
||||
const piece = pieceAt(network, network.lines[hit.line].id, hit.atM);
|
||||
if (piece) pressPiece(piece, shift, px, py, hit);
|
||||
const at = hit(px, py, map);
|
||||
if (at.segment) {
|
||||
if (shift) return (sel.segments([at.segment], true), true);
|
||||
const grow = sel.holds({
|
||||
node: at.node ?? undefined,
|
||||
segment: at.node ? undefined : at.segment,
|
||||
});
|
||||
if (!grow) {
|
||||
if (at.node) sel.node(at.node);
|
||||
else sel.segments([at.segment]);
|
||||
}
|
||||
const on = at.node ? null : lineAt(network, px, py, map);
|
||||
drag = { kind: "press", x: px, y: py, node: at.node, on, grow };
|
||||
return true;
|
||||
}
|
||||
const [vx, vy] = map.rerotate(px, py);
|
||||
@@ -432,31 +419,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
[drag.box.x1, drag.box.y1] = map.rerotate(px, py);
|
||||
return;
|
||||
}
|
||||
if (drag.kind === "grow") {
|
||||
if (drag.kind === "press") {
|
||||
if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return;
|
||||
const hit = drag.hit;
|
||||
if (!params.editable() || !hit) return void (drag = null);
|
||||
params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point));
|
||||
const before = params.network();
|
||||
drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null };
|
||||
}
|
||||
if (drag.kind === "locked") {
|
||||
if (!drag.told) showToast(drag.note, "info");
|
||||
drag.told = true;
|
||||
return;
|
||||
const { node, on, grow } = drag;
|
||||
const network = params.network();
|
||||
if (!params.editable()) return void (drag = null);
|
||||
if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex);
|
||||
else if (grow && on) {
|
||||
params.commit(insertVertex(network, on.line, on.segment, on.point));
|
||||
drag = startVertex(on.line, on.segment + 1);
|
||||
} else return void (drag = null);
|
||||
}
|
||||
const cursor = map.toMetric(px, py);
|
||||
if (drag.kind === "vertex") {
|
||||
drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor);
|
||||
} else {
|
||||
const piece = drag.before.pieces[drag.index];
|
||||
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
|
||||
const atM = distanceAlong(vertices, cursor);
|
||||
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
|
||||
drag.last = off
|
||||
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
|
||||
: moveMark(drag.before, drag.index, atM);
|
||||
const to = pointNear(px, py, map, drag.from) ?? cursor;
|
||||
drag.last = drag.follow
|
||||
? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)]
|
||||
: [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)];
|
||||
params.showPoints(...drag.last);
|
||||
return;
|
||||
}
|
||||
if (drag.kind !== "mark") return;
|
||||
const piece = drag.before.pieces[drag.index];
|
||||
const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices;
|
||||
const atM = distanceAlong(vertices, cursor);
|
||||
const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX;
|
||||
drag.last = off
|
||||
? removeMark(drag.before, piece.line, piece.from_m, 1e-3)
|
||||
: moveMark(drag.before, drag.index, atM);
|
||||
params.show(drag.last);
|
||||
},
|
||||
dragOver(px, py, map) {
|
||||
@@ -476,11 +466,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
drop(px, py, map) {
|
||||
markDrop = null;
|
||||
if (!params.enabled() || !params.editable()) return false;
|
||||
const place = markPlace(px, py, map);
|
||||
if (!place.next || !place.piece)
|
||||
return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true);
|
||||
params.commit(place.next);
|
||||
select([place.piece], place.piece);
|
||||
splitAt(px, py, map);
|
||||
return true;
|
||||
},
|
||||
leave() {
|
||||
@@ -489,56 +475,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
|
||||
release(map) {
|
||||
const done = drag;
|
||||
drag = null;
|
||||
if (!done || done.kind === "locked") return;
|
||||
if (done.kind === "grow") {
|
||||
if (selected) {
|
||||
const scope = NEXT_SCOPE[selected.scope];
|
||||
choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null });
|
||||
}
|
||||
if (!done) return;
|
||||
if (done.kind === "press") {
|
||||
if (done.grow) sel.grow();
|
||||
return;
|
||||
}
|
||||
if (done.kind === "box") {
|
||||
const { box, shift } = done;
|
||||
if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) {
|
||||
if (!shift) choose(null);
|
||||
if (!shift) sel.clear();
|
||||
return;
|
||||
}
|
||||
const network = params.network();
|
||||
const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point));
|
||||
const ids = piecesInBox(network, toView, box, box.x1 < box.x0);
|
||||
select(shift && selected ? [...selected.pieces, ...ids] : ids);
|
||||
const found = segmentsInBox(network, toView, box, box.x1 < box.x0);
|
||||
const now = sel.get();
|
||||
sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found);
|
||||
return;
|
||||
}
|
||||
if (!done.last) return;
|
||||
params.show(done.before);
|
||||
params.commit(done.last);
|
||||
sync();
|
||||
if (done.kind === "vertex") {
|
||||
params.showPoints(done.points, done.before);
|
||||
params.commitPoints(...done.last);
|
||||
} else {
|
||||
params.show(done.before);
|
||||
params.commit(done.last);
|
||||
}
|
||||
sel.sync();
|
||||
},
|
||||
};
|
||||
|
||||
/** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */
|
||||
function setScope(scope: PlanScope): void {
|
||||
const network = params.network();
|
||||
const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]);
|
||||
if (!seed) return;
|
||||
if (scope === "line") return sel.region(seed.line);
|
||||
if (scope === "piece") return sel.pieces([seed.id]);
|
||||
const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id);
|
||||
sel.pieces([seed.id, ...same], null, "year");
|
||||
}
|
||||
|
||||
return {
|
||||
layer,
|
||||
selected: () => selected,
|
||||
select,
|
||||
selected: sel.get,
|
||||
paths: sel.paths,
|
||||
select: (pieces, mark = null) => sel.pieces(pieces, mark),
|
||||
setScope,
|
||||
flipLine: () => apply((network, index) => reverseLine(network, index)),
|
||||
dropLine: () => apply((network, index) => removeLine(network, index)),
|
||||
selectNode: sel.node,
|
||||
selectRegion: sel.region,
|
||||
selectYear(year) {
|
||||
const ids = params
|
||||
.network()
|
||||
.pieces.filter((p) => p.year === year)
|
||||
.map((p) => p.id);
|
||||
sel.pieces(ids, null, "year");
|
||||
},
|
||||
selectAll: sel.all,
|
||||
dropSelected,
|
||||
dropNode,
|
||||
dropMark,
|
||||
branchLine: () => apply((network, index) => branchFromEnd(network, index)),
|
||||
lineIndex,
|
||||
dropRegion,
|
||||
dropAll,
|
||||
splitAt,
|
||||
hit,
|
||||
key(event) {
|
||||
if (!selected || !params.enabled()) return false;
|
||||
if (!sel.get() || !params.enabled()) return false;
|
||||
if (keyIs(event, "plan.delete") && params.editable()) {
|
||||
dropSelected();
|
||||
return true;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
choose(null);
|
||||
sel.clear();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
sync,
|
||||
sync: sel.sync,
|
||||
box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
|
||||
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
|
||||
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
|
||||
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
|
||||
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
|
||||
{ key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false },
|
||||
],
|
||||
[
|
||||
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_LayerLegend.ts
|
||||
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
|
||||
* 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈).
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
|
||||
* ========================================================================== */
|
||||
|
||||
@@ -23,6 +24,12 @@ import {
|
||||
type LineLayerKey,
|
||||
type PlanLayerKey,
|
||||
} from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import {
|
||||
SLOPE_MAX_PCT,
|
||||
bandRatios,
|
||||
pctToDeg,
|
||||
type SlopeBand,
|
||||
} from "./logic/D03_PlanAnalysis_SlopeBands";
|
||||
|
||||
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
|
||||
export interface PreparedLines {
|
||||
@@ -39,7 +46,15 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
road: { all: PreparedLayer; small: PreparedLayer };
|
||||
/** 올린 임도망도(584-3). */
|
||||
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
|
||||
slope: { raster: PlanRaster; image: HTMLImageElement };
|
||||
/** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */
|
||||
slope: {
|
||||
raster: PlanRaster;
|
||||
image: HTMLImageElement;
|
||||
values: ImageData;
|
||||
bands: SlopeBand[];
|
||||
url: string;
|
||||
paintMs: number;
|
||||
};
|
||||
hillshade: { raster: PlanRaster; image: HTMLImageElement };
|
||||
landslide: { raster: PlanRaster; image: HTMLImageElement };
|
||||
/** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */
|
||||
@@ -55,6 +70,19 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
|
||||
|
||||
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
|
||||
|
||||
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
|
||||
function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] {
|
||||
const ratios = bandRatios(raster.counts ?? [], bands);
|
||||
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
|
||||
return bands.map((band, i) => {
|
||||
const open = band.high >= SLOPE_MAX_PCT;
|
||||
const range = open
|
||||
? `${band.low} %↑(${deg(band.low)}°↑)`
|
||||
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
|
||||
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
|
||||
});
|
||||
}
|
||||
|
||||
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
|
||||
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
|
||||
const color = "var(--draw-map-landslide-zone)";
|
||||
@@ -86,13 +114,7 @@ export function legendRows(
|
||||
data: Partial<Prepared>,
|
||||
): LegendItem[][] {
|
||||
const rows: LegendItem[][] = [];
|
||||
if (on.slope && data.slope)
|
||||
rows.push(
|
||||
data.slope.raster.bands.map((band) => ({
|
||||
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
|
||||
color: band.color,
|
||||
})),
|
||||
);
|
||||
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
|
||||
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
|
||||
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
|
||||
if (on.zones && data.zones) {
|
||||
@@ -211,6 +233,17 @@ export const layerCounts = (data: Partial<Prepared>): Record<string, number> =>
|
||||
band_road_m2: data.bands.info.road.area_m2,
|
||||
}),
|
||||
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
|
||||
...(data.slope && {
|
||||
slope_px: data.slope.image.naturalWidth,
|
||||
slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0),
|
||||
slope_paint_ms: data.slope.paintMs,
|
||||
...Object.fromEntries(
|
||||
bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [
|
||||
`slope_band_${i}`,
|
||||
r,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
|
||||
...Object.fromEntries(
|
||||
LINE_KEYS.flatMap((key) => {
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
||||
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
|
||||
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
|
||||
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
|
||||
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
||||
@@ -72,6 +73,14 @@ import {
|
||||
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
||||
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
|
||||
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
|
||||
import {
|
||||
openSlopeBands,
|
||||
paintSlope,
|
||||
readSlopeBands,
|
||||
slopeGear,
|
||||
slopeValues,
|
||||
writeSlopeBands,
|
||||
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
@@ -200,9 +209,39 @@ export function createPlanLayers(opts: {
|
||||
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
|
||||
const rows = meta ? legendRows(on, data) : [];
|
||||
legend.replaceChildren(...rows.map((row) => createLegend(row)));
|
||||
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
|
||||
legend.hidden = !rows.length;
|
||||
}
|
||||
|
||||
/** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */
|
||||
let slopeBands = readSlopeBands();
|
||||
let slopeTurn = 0;
|
||||
const gear = slopeGear(() =>
|
||||
openSlopeBands(slopeBands, (bands) => {
|
||||
slopeBands = bands;
|
||||
writeSlopeBands(bands);
|
||||
if (data.slope) data.slope.bands = bands;
|
||||
sync();
|
||||
void repaintSlope();
|
||||
}),
|
||||
);
|
||||
async function repaintSlope(): Promise<void> {
|
||||
const got = data.slope;
|
||||
if (!got) return;
|
||||
const turn = ++slopeTurn;
|
||||
got.paintMs = -1; // 칠하는 중(검증 창구)
|
||||
const started = performance.now();
|
||||
const painted = await paintSlope(got.values, got.bands);
|
||||
if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url);
|
||||
const old = got.url;
|
||||
Object.assign(got, { image: painted.image, url: painted.url });
|
||||
got.paintMs = Math.round(performance.now() - started);
|
||||
gl.setData("slope", glImage(got.raster, painted.url));
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
sync();
|
||||
map.redrawBase();
|
||||
}
|
||||
|
||||
sync();
|
||||
useGl(glOn);
|
||||
|
||||
@@ -257,9 +296,13 @@ export function createPlanLayers(opts: {
|
||||
);
|
||||
return info;
|
||||
});
|
||||
// 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음)
|
||||
pending.set(
|
||||
"forestRoadFile",
|
||||
job.then(() => undefined),
|
||||
job.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
try {
|
||||
return await job;
|
||||
@@ -353,7 +396,22 @@ export function createPlanLayers(opts: {
|
||||
} as GeoJsonCollection);
|
||||
if (info.missing.length)
|
||||
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
|
||||
} else if (key === "slope" || key === "hillshade" || key === "landslide") {
|
||||
} else if (key === "slope") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
const values = slopeValues(await loadImage(raster.image));
|
||||
const bands = slopeBands;
|
||||
const started = performance.now();
|
||||
const painted = await paintSlope(values, bands);
|
||||
if (data.slope?.url) URL.revokeObjectURL(data.slope.url);
|
||||
data.slope = { raster, values, bands, ...painted, paintMs: 0 };
|
||||
data.slope.paintMs = Math.round(performance.now() - started);
|
||||
gl.setData("slope", glImage(raster, painted.url));
|
||||
// 칠하는 동안 범위가 바뀜 = 새 범위로 다시
|
||||
if (bands !== slopeBands) {
|
||||
data.slope.bands = slopeBands;
|
||||
void repaintSlope();
|
||||
}
|
||||
} else if (key === "hillshade" || key === "landslide") {
|
||||
const raster = await fetchPlanRaster(projectId, key);
|
||||
const image = await loadImage(raster.image);
|
||||
data[key] = { raster, image };
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림.
|
||||
* ========================================================================== */
|
||||
|
||||
import { perfTime } from "@ui/ui_template_gl_map";
|
||||
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
import { routeColor, themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
@@ -60,6 +61,9 @@ export function planColors(): {
|
||||
};
|
||||
}
|
||||
|
||||
/** 줌아웃 한도(600-3) — 화면 폭 약 600 km(한반도 · 위성 z6). 휠 하한 배율 = 이 폭이 화면에 드는 값(B05 는 0.5 그대로). */
|
||||
export const MAP_MAX_VIEW_WIDTH_M = 600_000;
|
||||
|
||||
/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */
|
||||
const FAKE_MARGIN_M = 600;
|
||||
|
||||
@@ -400,9 +404,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
function flush(): void {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (baseDirty) glCamera();
|
||||
if (baseDirty) paint(baseContext, true);
|
||||
if (topDirty) paint(context, false);
|
||||
perfTime("plan:flush", () => {
|
||||
if (baseDirty) perfTime("plan:glCamera", glCamera);
|
||||
if (baseDirty) perfTime("plan:paintBase", () => paint(baseContext, true));
|
||||
if (topDirty) perfTime("plan:paintTop", () => paint(context, false));
|
||||
});
|
||||
baseDirty = topDirty = false;
|
||||
}
|
||||
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
|
||||
@@ -449,10 +455,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
for (const layer of layers) layer.hover?.(px, py, map);
|
||||
drawTop();
|
||||
perfTime("plan:pointermove", () => {
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
for (const layer of layers) layer.hover?.(px, py, map);
|
||||
drawTop();
|
||||
});
|
||||
});
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
for (const layer of layers) layer.leave?.();
|
||||
@@ -496,6 +504,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
getMeta: () => meta,
|
||||
unrotateDelta: rotation.unrotateDelta,
|
||||
rotateWheel: rotation.wheel,
|
||||
minScale: () =>
|
||||
meta && view.mapRect.width > 0
|
||||
? Math.min(
|
||||
0.5,
|
||||
(view.width * meta.width_meters) / (view.mapRect.width * MAP_MAX_VIEW_WIDTH_M),
|
||||
)
|
||||
: 0.5,
|
||||
draw,
|
||||
});
|
||||
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Menu.ts
|
||||
* D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목.
|
||||
* 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 ·
|
||||
* 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) ·
|
||||
* 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음).
|
||||
* 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔.
|
||||
* 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points";
|
||||
|
||||
export interface PlanMenuParams {
|
||||
network: () => PlanNetwork;
|
||||
points: () => Vertex[];
|
||||
marks: PlanPoints;
|
||||
edit: PlanEdit;
|
||||
draw: PlanDraw;
|
||||
editable: () => boolean;
|
||||
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
|
||||
respan: (line: string, from: number, to: number) => boolean;
|
||||
}
|
||||
|
||||
export interface PlanMenu {
|
||||
layer: PlanMapLayer;
|
||||
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
|
||||
mount: (viewport: HTMLElement) => void;
|
||||
}
|
||||
|
||||
export function createPlanMenu(params: PlanMenuParams): PlanMenu {
|
||||
const { marks, edit, draw } = params;
|
||||
const menu = createMapContextMenu("d03-plan");
|
||||
let menuAt: [number, number] = [0, 0];
|
||||
|
||||
const layer: PlanMapLayer = {
|
||||
draw: () => undefined,
|
||||
press(px, py, map, button) {
|
||||
if (button !== 2 || !params.editable()) return false;
|
||||
const items: MapContextMenuItem[] = [];
|
||||
const pin = marks.pinAt(px, py, map);
|
||||
const at = pin === null ? edit.hit(px, py, map) : null;
|
||||
const start = (x: number, y: number): void => {
|
||||
const label = {
|
||||
tip: "이어 긋기",
|
||||
point: "지점에서 긋기 시작",
|
||||
line: "갈래 시작",
|
||||
empty: "여기서 긋기 시작",
|
||||
}[draw.aim(x, y, map)];
|
||||
items.push([label, () => draw.startAt(x, y, map)]);
|
||||
};
|
||||
if (draw.mode() === "draw" && draw.stroke())
|
||||
items.push(["긋기 끝", () => draw.setMode("idle")]);
|
||||
else if (pin !== null) {
|
||||
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
|
||||
items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
|
||||
items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]);
|
||||
} else if (at?.mark) {
|
||||
const mark = at.mark;
|
||||
items.push([
|
||||
"마커 지움",
|
||||
() => {
|
||||
edit.select([mark], mark);
|
||||
edit.dropMark();
|
||||
},
|
||||
]);
|
||||
} else if (at?.node) {
|
||||
const node = at.node;
|
||||
const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex];
|
||||
const [sx, sy] = map.toScreen(point);
|
||||
start(sx, sy);
|
||||
items.push("-", ["노드 지움", () => edit.dropNode(node)]);
|
||||
if (!marks.full() && !params.points().some((p) => samePoint(p, point)))
|
||||
items.push(["지점으로", () => marks.place(sx, sy, map)]);
|
||||
} else if (at?.segment) {
|
||||
const seg = at.segment;
|
||||
start(px, py);
|
||||
items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]);
|
||||
items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]);
|
||||
} else start(px, py);
|
||||
if (pin === null && !at?.node && !at?.mark && !marks.full())
|
||||
items.push("-", ["지점 놓기", () => marks.place(px, py, map)]);
|
||||
menu.open(menuAt[0], menuAt[1], items);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
layer,
|
||||
mount(viewport) {
|
||||
viewport.append(menu.element);
|
||||
viewport.addEventListener(
|
||||
"contextmenu",
|
||||
(event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -19,10 +19,8 @@
|
||||
import "./D03_PlanAnalysis_UI_Style.css";
|
||||
import { ROUTES } from "@config/config_frontend";
|
||||
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import {
|
||||
createButton,
|
||||
hideLoadingOverlay,
|
||||
@@ -42,9 +40,12 @@ import {
|
||||
fetchTerrainExtent,
|
||||
savePlanNetwork,
|
||||
startTerrain,
|
||||
type PlaceHit,
|
||||
type PlanJob,
|
||||
type PlanNetworkView,
|
||||
type PlanSurface,
|
||||
type TerrainAim,
|
||||
type TerrainStart,
|
||||
} from "./D03_PlanAnalysis_Api";
|
||||
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
|
||||
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
@@ -56,9 +57,11 @@ import {
|
||||
type PlanMapLayer,
|
||||
} from "./D03_PlanAnalysis_UI_Map";
|
||||
import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure";
|
||||
import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
|
||||
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
|
||||
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
|
||||
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
|
||||
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
|
||||
import {
|
||||
emptyNetwork,
|
||||
fromServer,
|
||||
@@ -75,16 +78,26 @@ const POLL_MS = 1000;
|
||||
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
|
||||
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
|
||||
const SITE_RADIUS_M = 1000;
|
||||
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
|
||||
const COVER_MAX_M = 10000;
|
||||
/** 되돌리기 쌓는 수. */
|
||||
const UNDO_MAX = 100;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */
|
||||
const DRAFT_VERSION = 4;
|
||||
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
|
||||
const DRAFT_VERSION = 5;
|
||||
|
||||
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */
|
||||
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
|
||||
interface Site {
|
||||
lon: number;
|
||||
lat: number;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
|
||||
interface Snap {
|
||||
network: PlanNetwork;
|
||||
points: Vertex[];
|
||||
pointNames: string[];
|
||||
site: Site | null;
|
||||
}
|
||||
|
||||
interface Draft extends Snap {
|
||||
@@ -100,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let editable = false;
|
||||
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
|
||||
let pointNames: string[] = []; // 지점 이름(596 · 597-2)
|
||||
let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2)
|
||||
let savedSite: Site | null = null;
|
||||
let epsg = 0;
|
||||
let yearStart = new Date().getFullYear(); // 새 선 처음 해
|
||||
let network: PlanNetwork = emptyNetwork();
|
||||
let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음
|
||||
let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음
|
||||
let undoStack: Snap[] = [];
|
||||
let redoStack: Snap[] = [];
|
||||
let surface: PlanSurface | null = null;
|
||||
@@ -113,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
|
||||
let busy = false;
|
||||
let failed: string | null = null;
|
||||
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
|
||||
let measuring = false;
|
||||
let lastSaveMs: number | null = null;
|
||||
|
||||
@@ -127,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void =>
|
||||
step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로
|
||||
const edit = createPlanEdit({
|
||||
network: () => network,
|
||||
base: () => points,
|
||||
@@ -135,6 +153,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
network = normalizePieces(next, yearStart);
|
||||
map.redraw();
|
||||
},
|
||||
commitPoints: stepPoints,
|
||||
showPoints: (nextPoints, next) => {
|
||||
[points, network] = [nextPoints, next];
|
||||
map.redraw();
|
||||
},
|
||||
enabled: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
onSelect: () => refresh(),
|
||||
@@ -145,17 +168,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
editable: () => editable,
|
||||
yearStart: () => yearStart,
|
||||
commit,
|
||||
heightAt: () => z,
|
||||
epsg: () => epsg,
|
||||
points: () => points,
|
||||
addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]),
|
||||
commitPoints: stepPoints,
|
||||
respan: draw.respan,
|
||||
});
|
||||
const pieces = createPlanPieces({ network: () => network, edit });
|
||||
const measure = createPlanMeasure(() => z);
|
||||
const pieces = createPlanPieces({
|
||||
network: () => network,
|
||||
edit,
|
||||
editable: () => editable,
|
||||
yearOptions: props.yearOptions,
|
||||
commit,
|
||||
});
|
||||
const measure = createPlanMeasure(
|
||||
() => z,
|
||||
() => network,
|
||||
);
|
||||
const marks = createPlanPoints({
|
||||
points: () => points,
|
||||
network: () => network,
|
||||
names: () => pointNames,
|
||||
epsg: () => epsg,
|
||||
draw,
|
||||
commit: (nextPoints, nextNames, next) =>
|
||||
step({ network: next, points: nextPoints, pointNames: nextNames }),
|
||||
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
|
||||
center: () => viewMid,
|
||||
show: (nextPoints, next) => {
|
||||
[points, network] = [nextPoints, next];
|
||||
@@ -164,6 +200,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
idle: () => draw.mode() === "idle" && !measuring,
|
||||
editable: () => editable,
|
||||
});
|
||||
const menu = createPlanMenu({
|
||||
network: () => network,
|
||||
points: () => points,
|
||||
marks,
|
||||
edit,
|
||||
draw,
|
||||
editable: () => editable,
|
||||
respan: draw.respan,
|
||||
});
|
||||
// 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4)
|
||||
const viewWatch: PlanMapLayer = {
|
||||
draw(context, screen) {
|
||||
@@ -191,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
|
||||
const terrainText = createText("", "muted");
|
||||
terrainText.classList.add("d03-plan__hint");
|
||||
const retryButton = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
const siteBox = createPlanSite({
|
||||
onPick: (hit) => void pickSite(hit),
|
||||
onRetry: () => {
|
||||
[failed, lastTried, ready] = [null, "", true];
|
||||
void ensureCovered();
|
||||
void ensureCovered(true);
|
||||
},
|
||||
onUpload: (files) => void uploadRoads(files),
|
||||
});
|
||||
const terrainCard = createPanelCard("지형", false);
|
||||
const roadButton = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
|
||||
void backdrop.uploadRoads(files);
|
||||
}),
|
||||
});
|
||||
roadButton.title =
|
||||
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
terrainCard.append(terrainText, retryButton, roadButton);
|
||||
const siteCard = createPanelCard("위치", false);
|
||||
siteCard.append(...siteBox.parts);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
const drawCard = createPanelCard("그리기", false);
|
||||
@@ -232,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
actions.append(saveButton, resetButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "ui-sidebar-panel d03-plan__panel";
|
||||
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions);
|
||||
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
|
||||
attachCollapsible(panel);
|
||||
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
|
||||
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
|
||||
@@ -245,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
|
||||
|
||||
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices);
|
||||
const snap = (): Snap => ({ network, points, pointNames });
|
||||
const snap = (): Snap => ({ network, points, pointNames, site });
|
||||
/** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */
|
||||
const moved = (): TerrainAim["center"] =>
|
||||
site && JSON.stringify(site) !== JSON.stringify(savedSite)
|
||||
? { lon: site.lon, lat: site.lat }
|
||||
: null;
|
||||
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
|
||||
const around = (at: Site | null): Vertex[] => {
|
||||
const tm = tmParams(epsg);
|
||||
if (!at || !tm) return [];
|
||||
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
|
||||
return [
|
||||
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
|
||||
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
|
||||
];
|
||||
};
|
||||
const fitTo = (box: Vertex[]): void => {
|
||||
if (!box.length) return;
|
||||
map.update({ fit: box });
|
||||
map.fit();
|
||||
};
|
||||
const dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
|
||||
const keepDraft = (): void =>
|
||||
dirty()
|
||||
@@ -256,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
function step(next: Snap): void {
|
||||
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
|
||||
redoStack = [];
|
||||
[network, points, pointNames] = [
|
||||
[network, points, pointNames, site] = [
|
||||
normalizePieces(next.network, yearStart),
|
||||
next.points,
|
||||
next.pointNames,
|
||||
next.site,
|
||||
];
|
||||
keepDraft();
|
||||
refresh();
|
||||
@@ -267,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
|
||||
function commit(next: PlanNetwork): void {
|
||||
step({ network: next, points, pointNames });
|
||||
step({ network: next, points, pointNames, site });
|
||||
}
|
||||
|
||||
function undo(): void {
|
||||
const last = undoStack.pop();
|
||||
if (!last) return;
|
||||
redoStack.push(snap());
|
||||
({ network, points, pointNames } = last);
|
||||
({ network, points, pointNames, site } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -283,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
const next = redoStack.pop();
|
||||
if (!next) return;
|
||||
undoStack.push(snap());
|
||||
({ network, points, pointNames } = next);
|
||||
({ network, points, pointNames, site } = next);
|
||||
keepDraft();
|
||||
refresh();
|
||||
}
|
||||
@@ -297,7 +349,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
refresh();
|
||||
}
|
||||
|
||||
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */
|
||||
/** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */
|
||||
const layerList = (): PlanMapLayer[] => [
|
||||
...backdrop.layers,
|
||||
draw.layer,
|
||||
@@ -306,20 +358,23 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
...(measuring ? [measure.layer] : []),
|
||||
viewWatch,
|
||||
marks.pins,
|
||||
menu.layer,
|
||||
];
|
||||
|
||||
/** 점들이 받은 범위 안인가. */
|
||||
const inside = (list: Vertex[]): boolean =>
|
||||
!!have &&
|
||||
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
|
||||
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
|
||||
|
||||
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
|
||||
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
|
||||
async function ensureCovered(): Promise<void> {
|
||||
async function ensureCovered(again = false): Promise<void> {
|
||||
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
|
||||
const extra = [...points, ...allVertices()];
|
||||
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const [lo, hi] = around(site);
|
||||
const near = ([x, y]: Vertex): boolean =>
|
||||
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
|
||||
const extra = [...points, ...allVertices()].filter(near);
|
||||
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
|
||||
const key = JSON.stringify([have, extra]);
|
||||
if (key === lastTried) return;
|
||||
lastTried = key;
|
||||
@@ -340,34 +395,88 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
props.render();
|
||||
pieces.render();
|
||||
marks.render();
|
||||
retryButton.hidden = !editable || busy || !failed;
|
||||
roadButton.hidden = !editable;
|
||||
if (!busy)
|
||||
terrainText.textContent = failed
|
||||
? `지형 받기 실패 — ${failed}`
|
||||
: !surface
|
||||
? editable
|
||||
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
|
||||
: "받은 지형 없음"
|
||||
: outside()
|
||||
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
|
||||
: "받은 지형 안";
|
||||
siteBox.render({
|
||||
address: site?.address ?? null,
|
||||
note: failed ? `지형 받기 실패 — ${failed}` : note,
|
||||
retry: !busy && !!failed,
|
||||
editable,
|
||||
});
|
||||
saveButton.hidden = resetButton.hidden = !editable;
|
||||
saveButton.disabled = busy || !dirty();
|
||||
}
|
||||
|
||||
async function download(extra: Vertex[]): Promise<void> {
|
||||
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
|
||||
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
|
||||
async function download(extra: Vertex[]): Promise<boolean> {
|
||||
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
|
||||
const where: TerrainAim = { center: moved() };
|
||||
try {
|
||||
await startTerrain(projectId, null, extra);
|
||||
let answer: TerrainStart = await startTerrain(projectId, null, extra, where);
|
||||
if (answer.status === "far" || answer.status === "band") {
|
||||
const band = answer.status === "band";
|
||||
const ok = await askConfirm({
|
||||
title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김",
|
||||
message: band ? `${answer.message} 그은 선 · 지점을 지웁니다.` : answer.message,
|
||||
okLabel: "새로 받기",
|
||||
danger: band,
|
||||
});
|
||||
if (!ok) {
|
||||
busy = false;
|
||||
refresh();
|
||||
return false;
|
||||
}
|
||||
if (answer.status === "band") clearForBand(answer.center_epsg);
|
||||
answer = await startTerrain(projectId, null, [], { ...where, reset: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// 이미 도는 일(다른 창)이면 그것을 지켜봄
|
||||
if (!/이미 돌고/.test(errorText(error))) {
|
||||
busy = false;
|
||||
return void showToast(errorText(error), "error");
|
||||
refresh();
|
||||
showToast(errorText(error), "error");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
await follow();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */
|
||||
function clearForBand(next: number): void {
|
||||
epsg = next;
|
||||
[network, points, pointNames] = [emptyNetwork(), [], []];
|
||||
[undoStack, redoStack, have] = [[], [], null];
|
||||
map.update({ epsg, fit: around(site) }); // 맞춤 상자도 새 띠 좌표로
|
||||
map.fit();
|
||||
keepDraft();
|
||||
}
|
||||
|
||||
/** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */
|
||||
async function pickSite(hit: PlaceHit): Promise<void> {
|
||||
if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info");
|
||||
epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙)
|
||||
[note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번
|
||||
step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } });
|
||||
ready = true;
|
||||
fitTo(around(site));
|
||||
if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가
|
||||
undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로
|
||||
redoStack.pop();
|
||||
fitTo(around(site));
|
||||
}
|
||||
|
||||
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
|
||||
async function uploadRoads(files: File[]): Promise<void> {
|
||||
const info = await backdrop.uploadRoads(files);
|
||||
const tm = tmParams(epsg);
|
||||
if (!info) return;
|
||||
const roads: Vertex[] =
|
||||
tm && info.bounds
|
||||
? [
|
||||
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
|
||||
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
|
||||
]
|
||||
: [];
|
||||
fitTo([...around(site), ...roads]);
|
||||
}
|
||||
|
||||
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
||||
@@ -429,14 +538,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
? fromServer(server.lines, server.marks, server.pieces, yearStart)
|
||||
: emptyNetwork();
|
||||
points = view.base.points.map((p): Vertex => [p.x, p.y]);
|
||||
pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""`
|
||||
pointNames = view.base.points.map((p) => p.name ?? "");
|
||||
site = savedSite = view.base.site;
|
||||
if (view.base.epsg) epsg = view.base.epsg;
|
||||
savedJson = JSON.stringify(snap());
|
||||
}
|
||||
|
||||
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
|
||||
const { lines, pieces } = network;
|
||||
return { epsg, points, lines, marks: marksOf(network), pieces };
|
||||
const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces };
|
||||
return moved() && site ? { ...body, site } : body;
|
||||
}
|
||||
|
||||
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
|
||||
@@ -473,7 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
draw.setMode("idle");
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const body = { epsg, points, lines: [], marks: [], pieces: [] };
|
||||
const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
|
||||
takeServer(await savePlanNetwork(projectId, body));
|
||||
clearState("d03-plan", projectId);
|
||||
undoStack = [];
|
||||
@@ -506,6 +617,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
base: () => points,
|
||||
have: () => have,
|
||||
busy: () => busy,
|
||||
site: () => site,
|
||||
pickSite,
|
||||
lineNames: () => lineNames(network.lines),
|
||||
screenOf: map.screenOf,
|
||||
screenOfLonLat: map.screenOfLonLat,
|
||||
@@ -546,32 +659,24 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
map.setLayers(layerList());
|
||||
marks.mount(map.root);
|
||||
menu.mount(map.root);
|
||||
|
||||
try {
|
||||
editable = !!(await fetchSessionUser())?.is_forest_service;
|
||||
draw.setEditable(editable);
|
||||
const view = await fetchPlanNetwork(projectId);
|
||||
const { epsg: workEpsg, site } = view.base;
|
||||
if (!workEpsg) {
|
||||
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요.";
|
||||
return;
|
||||
if (!view.base.epsg) {
|
||||
note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
|
||||
ready = true;
|
||||
return refresh();
|
||||
}
|
||||
epsg = workEpsg;
|
||||
epsg = view.base.epsg;
|
||||
takeServer(view);
|
||||
const draft = readState<Draft>("d03-plan", projectId);
|
||||
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
|
||||
({ network, points, pointNames } = draft);
|
||||
const tm = tmParams(epsg);
|
||||
const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null;
|
||||
const around: Vertex[] = siteXy
|
||||
? [
|
||||
[siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M],
|
||||
[siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M],
|
||||
]
|
||||
: [];
|
||||
({ network, points, pointNames, site } = draft);
|
||||
const drawn = [...points, ...allVertices()];
|
||||
map.update({ fit: drawn.length ? drawn : around, epsg });
|
||||
map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
@@ -582,12 +687,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
await follow();
|
||||
} else if (editable && !job.error) {
|
||||
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
const extent = await fetchTerrainExtent(projectId, null, [], moved());
|
||||
ready = true;
|
||||
if (!extent.covered) await download([]);
|
||||
else await ensureCovered();
|
||||
}
|
||||
} catch (error) {
|
||||
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
note = `불러오지 못했습니다 — ${errorText(error)}`;
|
||||
refresh();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Pieces.ts
|
||||
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간).
|
||||
* 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기.
|
||||
* D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래).
|
||||
* 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) ·
|
||||
* 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { askConfirm } from "@ui/ui_template_modal";
|
||||
import { routeColor } from "@ui/ui_template_palette";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { lineLength } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
lineNames,
|
||||
nameError,
|
||||
setLineYear,
|
||||
setPiecesYear,
|
||||
type PlanNetwork,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit";
|
||||
import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
@@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`;
|
||||
export interface PlanPiecesParams {
|
||||
network: () => PlanNetwork;
|
||||
edit: PlanEdit;
|
||||
editable: () => boolean;
|
||||
/** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */
|
||||
yearOptions: () => Array<{ value: string; text: string }>;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
}
|
||||
|
||||
export interface PlanPieces {
|
||||
@@ -25,79 +42,190 @@ export interface PlanPieces {
|
||||
render: () => void;
|
||||
}
|
||||
|
||||
/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */
|
||||
const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => {
|
||||
row.tabIndex = 0;
|
||||
row.setAttribute("role", "button");
|
||||
row.addEventListener("click", onPick);
|
||||
row.addEventListener("keydown", (event) => event.key === "Enter" && onPick());
|
||||
return row;
|
||||
};
|
||||
|
||||
export function createPlanPieces(params: PlanPiecesParams): PlanPieces {
|
||||
const { edit } = params;
|
||||
const list = document.createElement("div");
|
||||
list.className = "d03-plan__pieces";
|
||||
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
|
||||
let syncs: Array<() => void> = [];
|
||||
|
||||
/** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */
|
||||
const isControl = (event: Event): boolean =>
|
||||
!!(event.target as Element).closest("input, select, button, .ui-list-row__trailing");
|
||||
|
||||
/** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */
|
||||
const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => {
|
||||
row.tabIndex = 0;
|
||||
row.addEventListener("click", (event) => isControl(event) || onPick());
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target === row) onPick();
|
||||
});
|
||||
return row;
|
||||
};
|
||||
|
||||
/** 연도 고름 칸 — 값이 섞이면 「여러 해」. */
|
||||
function yearSelect(
|
||||
years: () => number[],
|
||||
label: string,
|
||||
pick: (year: number) => void,
|
||||
): { root: HTMLElement; sync: () => void } {
|
||||
const field = createSelectField({
|
||||
options: [],
|
||||
size: 24,
|
||||
ariaLabel: label,
|
||||
disabled: !params.editable(),
|
||||
onChange: (value) => value && pick(Number(value)),
|
||||
});
|
||||
const sync = (): void => {
|
||||
const set = new Set(years());
|
||||
const options = params.yearOptions();
|
||||
field.setOptions(
|
||||
set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
|
||||
set.size > 1 ? "" : String([...set][0]),
|
||||
);
|
||||
};
|
||||
sync();
|
||||
return { root: field.root, sync };
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const network = params.network();
|
||||
const names = lineNames(network.lines);
|
||||
const picked = new Set(edit.selected()?.pieces ?? []);
|
||||
const scope = edit.selected()?.scope;
|
||||
const editable = params.editable();
|
||||
const key = JSON.stringify([
|
||||
network.lines.map((line) => line.id),
|
||||
network.pieces.map((piece) => [piece.id, piece.line]),
|
||||
[...picked],
|
||||
scope,
|
||||
editable,
|
||||
params.yearOptions().length,
|
||||
]);
|
||||
if (key === shown) {
|
||||
syncs.forEach((sync) => sync());
|
||||
return;
|
||||
}
|
||||
shown = key;
|
||||
syncs = [];
|
||||
const rows: HTMLElement[] = [];
|
||||
|
||||
const byYear = new Map<number, typeof network.pieces>();
|
||||
for (const piece of network.pieces)
|
||||
byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]);
|
||||
for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__piece d03-plan__piece-year";
|
||||
row.dataset.year = String(year);
|
||||
row.classList.toggle(
|
||||
"is-selected",
|
||||
scope === "year" && pieces.every((p) => picked.has(p.id)),
|
||||
);
|
||||
const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0);
|
||||
row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted"));
|
||||
rows.push(
|
||||
pickRow(row, () => {
|
||||
const row = createListRow({
|
||||
label: `${year}년`,
|
||||
sub: `구간 ${pieces.length} · ${meters(length)}`,
|
||||
size: 28,
|
||||
active: scope === "year" && pieces.every((p) => picked.has(p.id)),
|
||||
onClick: () => {
|
||||
edit.select([pieces[0].id]);
|
||||
edit.setScope("year");
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
row.dataset.year = String(year);
|
||||
rows.push(row);
|
||||
}
|
||||
|
||||
network.lines.forEach((line, index) => {
|
||||
const pieces = network.pieces.filter((piece) => piece.line === line.id);
|
||||
if (!pieces.length) return;
|
||||
const head = document.createElement("div");
|
||||
head.className = "d03-plan__piece d03-plan__piece-head";
|
||||
head.dataset.line = line.id;
|
||||
head.classList.toggle(
|
||||
"is-selected",
|
||||
scope === "line" && pieces.every((p) => picked.has(p.id)),
|
||||
const lineNow = (): (typeof network.lines)[number] =>
|
||||
params.network().lines.find((entry) => entry.id === line.id) ?? line;
|
||||
const piecesNow = (): typeof pieces =>
|
||||
params.network().pieces.filter((piece) => piece.line === line.id);
|
||||
|
||||
const nameField = createInputField({ size: 24, placeholder: names[index] });
|
||||
nameField.input.value = line.name ?? "";
|
||||
nameField.input.setAttribute("aria-label", "구역 이름");
|
||||
nameField.input.dataset.role = "line-name";
|
||||
nameField.input.disabled = !editable;
|
||||
nameField.input.addEventListener("input", () =>
|
||||
nameField.setError(nameError(nameField.input.value) ?? undefined),
|
||||
);
|
||||
head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted"));
|
||||
nameField.input.addEventListener("change", () => {
|
||||
const error = nameError(nameField.input.value);
|
||||
nameField.setError(error ?? undefined);
|
||||
const text = nameField.input.value.trim();
|
||||
if (!error && text !== (lineNow().name ?? ""))
|
||||
params.commit(setLineName(params.network(), line.id, text));
|
||||
});
|
||||
|
||||
const year = yearSelect(
|
||||
() => piecesNow().map((piece) => piece.year),
|
||||
"구역 연도",
|
||||
(value) => params.commit(setLineYear(params.network(), line.id, value)),
|
||||
);
|
||||
year.root.dataset.role = "line-year";
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: "구역 지움",
|
||||
size: 24,
|
||||
onClick: async () => {
|
||||
const ok = await askConfirm({
|
||||
message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`,
|
||||
okLabel: "지움",
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
edit.dropRegion(line.id);
|
||||
},
|
||||
});
|
||||
drop.dataset.role = "line-drop";
|
||||
drop.hidden = !editable;
|
||||
const tail = document.createElement("span");
|
||||
tail.className = "d03-plan__line-tail";
|
||||
tail.append(year.root, drop);
|
||||
|
||||
const head = createListRow({
|
||||
label: "",
|
||||
sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`,
|
||||
stripe: routeColor(index),
|
||||
size: 28,
|
||||
trailing: tail,
|
||||
active: scope === "line" && pieces.every((p) => picked.has(p.id)),
|
||||
});
|
||||
head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root);
|
||||
head.dataset.line = line.id;
|
||||
rows.push(
|
||||
pickRow(head, () => {
|
||||
pickable(head, () => {
|
||||
edit.select([pieces[0].id]);
|
||||
edit.setScope("line");
|
||||
}),
|
||||
);
|
||||
syncs.push(() => {
|
||||
year.sync();
|
||||
nameField.input.placeholder = lineNames(params.network().lines)[index] ?? "";
|
||||
if (document.activeElement !== nameField.input)
|
||||
nameField.input.value = lineNow().name ?? "";
|
||||
const sub = head.querySelector(".ui-list-row__sub");
|
||||
if (sub)
|
||||
sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`;
|
||||
});
|
||||
|
||||
for (const piece of pieces) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__piece";
|
||||
row.classList.toggle("is-selected", picked.has(piece.id));
|
||||
row.dataset.piece = piece.id;
|
||||
row.append(
|
||||
createText(
|
||||
`${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`,
|
||||
"muted",
|
||||
),
|
||||
createText(`${piece.year}년`, "muted"),
|
||||
const pieceNow = (): (typeof pieces)[number] =>
|
||||
params.network().pieces.find((entry) => entry.id === piece.id) ?? piece;
|
||||
const pieceYear = yearSelect(
|
||||
() => [pieceNow().year],
|
||||
"구간 연도",
|
||||
(value) => params.commit(setPiecesYear(params.network(), [piece.id], value)),
|
||||
);
|
||||
rows.push(pickRow(row, () => edit.select([piece.id])));
|
||||
const row = createListRow({
|
||||
label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`,
|
||||
sub: meters(piece.to_m - piece.from_m),
|
||||
size: 28,
|
||||
trailing: pieceYear.root,
|
||||
active: picked.has(piece.id),
|
||||
});
|
||||
row.classList.add("d03-plan__piece-row");
|
||||
row.dataset.piece = piece.id;
|
||||
rows.push(pickable(row, () => edit.select([piece.id])));
|
||||
syncs.push(pieceYear.sync);
|
||||
}
|
||||
});
|
||||
if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted"));
|
||||
list.replaceChildren(...rows);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,27 +1,39 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Points.ts
|
||||
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
|
||||
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
||||
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
|
||||
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
|
||||
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
|
||||
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
|
||||
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
|
||||
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
|
||||
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
|
||||
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
|
||||
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
|
||||
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createInputField } from "@ui/ui_template_input";
|
||||
import { createListRow } from "@ui/ui_template_list_row";
|
||||
import { pointColor } from "@ui/ui_template_palette";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
moveNodesAt,
|
||||
nameError,
|
||||
samePoint,
|
||||
type PlanNetwork,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
addPoint,
|
||||
MAX_POINTS,
|
||||
movePointTo,
|
||||
parseLonLat,
|
||||
pointLabel,
|
||||
renamePoint,
|
||||
type PointsState,
|
||||
} from "./logic/D03_PlanAnalysis_PointEdit";
|
||||
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
||||
import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw";
|
||||
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
|
||||
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
|
||||
|
||||
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
||||
const MAX_POINTS = 40;
|
||||
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
|
||||
const PIN_HEAD_PX = 18;
|
||||
|
||||
@@ -31,7 +43,6 @@ export interface PlanPointsParams {
|
||||
names: () => string[];
|
||||
network: () => PlanNetwork;
|
||||
epsg: () => number;
|
||||
draw: PlanDraw;
|
||||
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
|
||||
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
|
||||
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
|
||||
@@ -49,19 +60,33 @@ export interface PlanPoints {
|
||||
input: PlanMapLayer;
|
||||
pins: PlanMapLayer;
|
||||
render: () => void;
|
||||
/** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */
|
||||
mount: (viewport: HTMLElement) => void;
|
||||
/** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */
|
||||
pinAt: (px: number, py: number, map: ScreenMap) => number | null;
|
||||
place: (px: number, py: number, map: ScreenMap) => void;
|
||||
remove: (i: number) => void;
|
||||
name: (i: number) => string;
|
||||
full: () => boolean;
|
||||
}
|
||||
|
||||
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
const list = document.createElement("div");
|
||||
list.className = "d03-plan__points";
|
||||
const hint = createText("", "muted");
|
||||
hint.classList.add("d03-plan__hint");
|
||||
const addButton = createButton({
|
||||
label: "추가",
|
||||
iconStart: "plus",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: addAtCenter,
|
||||
});
|
||||
addButton.dataset.role = "points-add";
|
||||
const head = document.createElement("div");
|
||||
head.className = "d03-plan__points-head";
|
||||
head.append(addButton);
|
||||
const panel = document.createElement("div");
|
||||
panel.append(list, hint);
|
||||
const menu = createMapContextMenu("d03-plan");
|
||||
let menuAt: [number, number] = [0, 0];
|
||||
panel.append(head, list);
|
||||
let openIndex: number | null = null; // 펼친 줄(하나만)
|
||||
let syncEditor: (() => void) | null = null;
|
||||
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
|
||||
let drag: {
|
||||
index: number;
|
||||
points: Vertex[];
|
||||
@@ -69,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
last: [Vertex[], PlanNetwork] | null;
|
||||
} | null = null;
|
||||
|
||||
const name = (i: number): string => `지점 ${i + 1}`;
|
||||
const name = (i: number): string => pointLabel(params.names(), i);
|
||||
const state = (): PointsState => ({
|
||||
points: params.points(),
|
||||
names: params.names(),
|
||||
network: params.network(),
|
||||
});
|
||||
const commitState = (next: PointsState): void =>
|
||||
params.commit(next.points, next.names, next.network);
|
||||
|
||||
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
|
||||
function pinAt(px: number, py: number, map: ScreenMap): number | null {
|
||||
@@ -88,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
}
|
||||
|
||||
function remove(i: number): void {
|
||||
openIndex = null;
|
||||
params.commit(
|
||||
params.points().filter((_, k) => k !== i),
|
||||
params.names().filter((_, k) => k !== i),
|
||||
@@ -95,61 +128,40 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
);
|
||||
}
|
||||
|
||||
/** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */
|
||||
function place(px: number, py: number, map: ScreenMap): void {
|
||||
if (params.points().length >= MAX_POINTS) return;
|
||||
/** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */
|
||||
function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null {
|
||||
const node = params
|
||||
.network()
|
||||
.lines.flatMap((line) => line.vertices)
|
||||
.filter((point) => !skip || !samePoint(point, skip))
|
||||
.map((point) => ({ point, gap: screenGap(map, point, px, py) }))
|
||||
.filter((item) => item.gap <= SNAP_PX)
|
||||
.sort((a, b) => a.gap - b.gap)[0];
|
||||
const at = node?.point ?? map.toMetric(px, py);
|
||||
return node ? node.point : null;
|
||||
}
|
||||
|
||||
/** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */
|
||||
function place(px: number, py: number, map: ScreenMap): void {
|
||||
if (params.points().length >= MAX_POINTS) return;
|
||||
const at = nodeNear(px, py, map) ?? map.toMetric(px, py);
|
||||
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
|
||||
}
|
||||
|
||||
function openMenu(px: number, py: number, map: ScreenMap): boolean {
|
||||
if (!params.editable()) return false;
|
||||
const draw = params.draw;
|
||||
const pin = pinAt(px, py, map);
|
||||
const items: MapContextMenuItem[] = [];
|
||||
if (draw.mode() === "draw" && draw.stroke())
|
||||
items.push(["긋기 끝", () => draw.setMode("idle")]);
|
||||
else if (pin !== null) {
|
||||
const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서
|
||||
items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]);
|
||||
items.push([`${name(pin)} 지우기`, () => remove(pin)]);
|
||||
} else {
|
||||
const label = {
|
||||
tip: "이어 긋기",
|
||||
point: "지점에서 긋기 시작",
|
||||
line: "갈래 시작",
|
||||
empty: "여기서 긋기 시작",
|
||||
}[draw.aim(px, py, map)];
|
||||
items.push([label, () => draw.startAt(px, py, map)]);
|
||||
}
|
||||
if (pin === null && params.points().length < MAX_POINTS)
|
||||
items.push(["지점 놓기", () => place(px, py, map)]);
|
||||
menu.open(menuAt[0], menuAt[1], items);
|
||||
return true;
|
||||
}
|
||||
|
||||
const pins = pointLayer(params.points);
|
||||
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
|
||||
const pins = pointLayer(params.points, params.names);
|
||||
|
||||
const input: PlanMapLayer = {
|
||||
draw: () => undefined,
|
||||
press(px, py, map, button) {
|
||||
if (button !== 0 || !params.editable() || !params.idle()) return false;
|
||||
const index = pinAt(px, py, map);
|
||||
if (index === null) return false;
|
||||
if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이)
|
||||
drag = { index, points: params.points(), network: params.network(), last: null };
|
||||
return true;
|
||||
},
|
||||
hover(px, py, map) {
|
||||
if (!drag) return;
|
||||
const to = map.toMetric(px, py);
|
||||
const from = drag.points[drag.index];
|
||||
const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py);
|
||||
const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p));
|
||||
drag.last = [points, moveNodesAt(drag.network, from, to)];
|
||||
params.show(...drag.last);
|
||||
@@ -163,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
},
|
||||
};
|
||||
|
||||
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
|
||||
function addAtCenter(): void {
|
||||
const at = params.center();
|
||||
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
|
||||
openIndex = params.points().length;
|
||||
commitState(addPoint(state(), at));
|
||||
}
|
||||
|
||||
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
|
||||
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
|
||||
const tm = tmParams(params.epsg());
|
||||
const editable = params.editable();
|
||||
const field = (label: string, role: string, placeholder?: string) => {
|
||||
const f = createInputField({ label, size: 24, placeholder });
|
||||
f.input.dataset.role = role;
|
||||
f.input.disabled = !editable || (role !== "point-name" && !tm);
|
||||
return f;
|
||||
};
|
||||
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
|
||||
const lonField = field("경도", "point-lon");
|
||||
const latField = field("위도", "point-lat");
|
||||
const lonLatNow = (): [number, number] | null => {
|
||||
const p = params.points()[i];
|
||||
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
|
||||
};
|
||||
const sync = (): void => {
|
||||
const set = (f: ReturnType<typeof field>, text: string): void => {
|
||||
if (document.activeElement !== f.input) f.input.value = text;
|
||||
};
|
||||
set(nameField, params.names()[i] ?? "");
|
||||
const ll = lonLatNow();
|
||||
set(lonField, ll ? ll[0].toFixed(6) : "");
|
||||
set(latField, ll ? ll[1].toFixed(6) : "");
|
||||
};
|
||||
nameField.input.addEventListener("input", () =>
|
||||
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
|
||||
);
|
||||
nameField.input.addEventListener("change", () => {
|
||||
const text = nameField.input.value.trim();
|
||||
const error = nameError(text);
|
||||
nameField.setError(error ?? undefined);
|
||||
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
|
||||
});
|
||||
const moved = (): void => {
|
||||
const parsed = parseLonLat(lonField.input.value, latField.input.value);
|
||||
const bad = typeof parsed === "string";
|
||||
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
|
||||
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
|
||||
if (bad || !tm) return;
|
||||
const now = lonLatNow();
|
||||
if (
|
||||
now &&
|
||||
now[0].toFixed(6) === parsed[0].toFixed(6) &&
|
||||
now[1].toFixed(6) === parsed[1].toFixed(6)
|
||||
)
|
||||
return;
|
||||
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
|
||||
};
|
||||
lonField.input.addEventListener("change", moved);
|
||||
latField.input.addEventListener("change", moved);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__point-edit";
|
||||
root.append(nameField.root, lonField.root, latField.root);
|
||||
sync();
|
||||
return { root, sync };
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const tm = tmParams(params.epsg());
|
||||
const editable = params.editable();
|
||||
const points = params.points();
|
||||
if (openIndex !== null && openIndex >= points.length) openIndex = null;
|
||||
addButton.hidden = !editable;
|
||||
addButton.disabled = points.length >= MAX_POINTS || !params.center();
|
||||
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
|
||||
const lonLatOf = (i: number): string | undefined => {
|
||||
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
|
||||
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
|
||||
};
|
||||
if (key === shown) {
|
||||
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
|
||||
points.forEach((_, i) => {
|
||||
const row = list.children[i]?.querySelector(".ui-list-row");
|
||||
const label = row?.querySelector(".ui-list-row__label");
|
||||
const sub = row?.querySelector(".ui-list-row__sub");
|
||||
if (label) label.textContent = name(i);
|
||||
if (sub) sub.textContent = lonLatOf(i) ?? "";
|
||||
});
|
||||
syncEditor?.();
|
||||
return;
|
||||
}
|
||||
shown = key;
|
||||
syncEditor = null;
|
||||
list.replaceChildren(
|
||||
...params.points().map((point, i) => {
|
||||
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
|
||||
...points.map((_, i) => {
|
||||
const drop = createIconButton({
|
||||
icon: "trash",
|
||||
title: `${name(i)} 지우기`,
|
||||
@@ -176,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
onClick: () => remove(i),
|
||||
});
|
||||
drop.hidden = !editable;
|
||||
return createListRow({
|
||||
const row = createListRow({
|
||||
label: name(i),
|
||||
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
|
||||
sub: lonLatOf(i),
|
||||
stripe: pointColor(i),
|
||||
size: 28,
|
||||
trailing: drop,
|
||||
active: i === openIndex,
|
||||
});
|
||||
row.dataset.point = String(i);
|
||||
row.tabIndex = 0;
|
||||
const toggle = (): void => {
|
||||
openIndex = openIndex === i ? null : i;
|
||||
render();
|
||||
};
|
||||
row.addEventListener("click", (event) => {
|
||||
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
|
||||
});
|
||||
row.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" && event.target === row) toggle();
|
||||
});
|
||||
const item = document.createElement("div");
|
||||
item.append(row);
|
||||
if (i === openIndex) {
|
||||
const editor = editorOf(i);
|
||||
syncEditor = editor.sync;
|
||||
item.append(editor.root);
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
);
|
||||
hint.textContent = !editable
|
||||
? params.points().length
|
||||
? "보기만 — 지점은 산림청이 놓습니다."
|
||||
: "지점 없음"
|
||||
: params.points().length
|
||||
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
|
||||
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -199,16 +314,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
|
||||
input,
|
||||
pins,
|
||||
render,
|
||||
mount(viewport) {
|
||||
viewport.append(menu.element);
|
||||
viewport.addEventListener(
|
||||
"contextmenu",
|
||||
(event) => {
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
menuAt = [event.clientX - rect.left, event.clientY - rect.top];
|
||||
},
|
||||
true,
|
||||
);
|
||||
},
|
||||
pinAt,
|
||||
place,
|
||||
remove,
|
||||
name,
|
||||
full: () => params.points().length >= MAX_POINTS,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,55 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Props.ts
|
||||
* D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴.
|
||||
* D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈.
|
||||
*
|
||||
* 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 —
|
||||
* 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 →
|
||||
* 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) ·
|
||||
* [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만.
|
||||
* 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정]
|
||||
* (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음.
|
||||
* 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂.
|
||||
* 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김).
|
||||
* 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘).
|
||||
* 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움].
|
||||
* 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨).
|
||||
* 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움].
|
||||
* 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만.
|
||||
* 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createButton, showToast } from "@ui/ui_template_elements";
|
||||
import { createFloatPanel } from "@ui/ui_template_float_panel";
|
||||
import { createSelectField } from "@ui/ui_template_input";
|
||||
import { createSegment } from "@ui/ui_template_segment";
|
||||
import { createKv, type KvRow } from "@ui/ui_template_kv";
|
||||
import { stationLabel } from "../common_util/common_util_station";
|
||||
import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
lineNames,
|
||||
samePoint,
|
||||
setPiecesYear,
|
||||
type PlanNetwork,
|
||||
type PlanPiece,
|
||||
type Vertex,
|
||||
} from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
placeNode,
|
||||
segmentPath,
|
||||
segmentPieces,
|
||||
segmentRange,
|
||||
} from "./logic/D03_PlanAnalysis_Select";
|
||||
import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span";
|
||||
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
|
||||
import {
|
||||
createDrawKindFields,
|
||||
DRAW_DEFAULT_LENGTH_M,
|
||||
DRAW_DEFAULT_SLOPE_PCT,
|
||||
type DrawKindValue,
|
||||
} from "./D03_PlanAnalysis_UI_DrawPanel";
|
||||
import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit";
|
||||
import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
|
||||
import { selectionLength } from "./D03_PlanAnalysis_UI_Select";
|
||||
|
||||
/** 조각 연도 고를 수 — 사업연도 시작부터. */
|
||||
const YEAR_SPAN = 10;
|
||||
|
||||
const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1);
|
||||
const meters = (m: number): string => `${m.toFixed(1)} m`;
|
||||
const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`);
|
||||
|
||||
export interface PlanPropsParams {
|
||||
network: () => PlanNetwork;
|
||||
@@ -32,6 +57,15 @@ export interface PlanPropsParams {
|
||||
editable: () => boolean;
|
||||
yearStart: () => number;
|
||||
commit: (next: PlanNetwork) => void;
|
||||
heightAt: () => HeightAt;
|
||||
epsg: () => number;
|
||||
/** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */
|
||||
points: () => Vertex[];
|
||||
addPoint: (point: Vertex) => void;
|
||||
/** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */
|
||||
commitPoints: (points: Vertex[], next: PlanNetwork) => void;
|
||||
/** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */
|
||||
respan: (line: string, from: number, to: number) => boolean;
|
||||
}
|
||||
|
||||
export interface PlanProps {
|
||||
@@ -46,7 +80,7 @@ export interface PlanProps {
|
||||
export function createPlanProps(params: PlanPropsParams): PlanProps {
|
||||
const { edit } = params;
|
||||
const float = createFloatPanel({
|
||||
title: "고른 구간",
|
||||
title: "고름",
|
||||
draggable: true,
|
||||
onClose: () => edit.select([]),
|
||||
});
|
||||
@@ -61,104 +95,237 @@ export function createPlanProps(params: PlanPropsParams): PlanProps {
|
||||
return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` }));
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
const picked = edit.selected();
|
||||
float.root.hidden = !picked;
|
||||
if (!picked) return float.body.replaceChildren();
|
||||
const network = params.network();
|
||||
const names = lineNames(network.lines);
|
||||
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
|
||||
const lineIds = new Set(pieces.map((piece) => piece.line));
|
||||
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
|
||||
const title = float.root.querySelector(".ui-float-panel__title");
|
||||
if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`;
|
||||
const editable = params.editable();
|
||||
|
||||
const scope = createSegment({
|
||||
options: [
|
||||
{ value: "piece", label: "구간" },
|
||||
{ value: "line", label: "선" },
|
||||
{ value: "year", label: "같은 해" },
|
||||
],
|
||||
value: picked.scope,
|
||||
size: 24,
|
||||
ariaLabel: "고름 넓이",
|
||||
onChange: (value) => edit.setScope(value as PlanScope),
|
||||
});
|
||||
scope.root.dataset.role = "props-scope";
|
||||
const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => {
|
||||
const b = createButton({ label, variant: "ghost", size: 24, onClick });
|
||||
b.dataset.role = role;
|
||||
return b;
|
||||
};
|
||||
|
||||
/** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */
|
||||
function yearField(pieces: PlanPiece[]): HTMLElement {
|
||||
const years = new Set(pieces.map((piece) => piece.year));
|
||||
const options = yearOptions();
|
||||
const ids = pieces.map((piece) => piece.id);
|
||||
const year = createSelectField({
|
||||
label: "연도",
|
||||
options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options,
|
||||
value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""),
|
||||
size: 24,
|
||||
ariaLabel: "고른 구간 연도",
|
||||
disabled: !editable,
|
||||
disabled: !params.editable(),
|
||||
onChange: (value) =>
|
||||
value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))),
|
||||
value && params.commit(setPiecesYear(params.network(), ids, Number(value))),
|
||||
});
|
||||
year.select.dataset.role = "props-year";
|
||||
return year.root;
|
||||
}
|
||||
|
||||
/** 구간 줄 — 누름 = 그 구간만. */
|
||||
function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement {
|
||||
const names = lineNames(network.lines);
|
||||
const rows = document.createElement("div");
|
||||
rows.className = "d03-plan__props-rows";
|
||||
for (const piece of pieces) {
|
||||
const index = network.lines.findIndex((line) => line.id === piece.line);
|
||||
const row = createButton({
|
||||
label:
|
||||
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
|
||||
const row = button(
|
||||
`${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` +
|
||||
`${meters(piece.to_m - piece.from_m)} · ${piece.year}년`,
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: () => edit.select([piece.id]),
|
||||
});
|
||||
"props-piece",
|
||||
() => edit.select([piece.id]),
|
||||
);
|
||||
row.dataset.piece = piece.id;
|
||||
rows.append(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "d03-plan__row";
|
||||
const index = edit.lineIndex();
|
||||
if (editable && index >= 0) {
|
||||
const flip = createButton({
|
||||
label: "방향 바꾸기",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.flipLine,
|
||||
});
|
||||
flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다.";
|
||||
flip.disabled = !!network.lines[index].branch;
|
||||
flip.dataset.role = "props-flip";
|
||||
const drop = createButton({
|
||||
label: "선 지움",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.dropLine,
|
||||
});
|
||||
drop.dataset.role = "props-drop";
|
||||
const branch = createButton({
|
||||
label: "갈래 시점 지정",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.branchLine,
|
||||
});
|
||||
branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다.";
|
||||
branch.hidden = !!network.lines[index].branch;
|
||||
branch.dataset.role = "props-branch";
|
||||
actions.append(flip, branch, drop);
|
||||
/** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */
|
||||
function nodeKind(line: number, vertex: number): HTMLElement {
|
||||
const record = params.network().lines[line].nodes?.[vertex] ?? null;
|
||||
let value: DrawKindValue = {
|
||||
kind: record?.kind ?? "free",
|
||||
lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M,
|
||||
slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT,
|
||||
};
|
||||
const apply = (): void => {
|
||||
const network = params.network();
|
||||
const next = nodeRecord(value.kind, value.lengthM, value.slopePct);
|
||||
const out = placeNode(network, params.points(), line, vertex, next, params.heightAt());
|
||||
if (typeof out === "string") return void showToast(out, "info");
|
||||
if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines))
|
||||
params.commitPoints(out.points, out.network);
|
||||
};
|
||||
const fields = createDrawKindFields({
|
||||
value,
|
||||
onChange: (next) => {
|
||||
const kindChanged = next.kind !== value.kind;
|
||||
value = next;
|
||||
if (kindChanged) apply();
|
||||
},
|
||||
});
|
||||
fields.root.addEventListener("change", apply);
|
||||
fields.root.dataset.role = "props-node-kind";
|
||||
fields.setDisabled(!params.editable());
|
||||
return fields.root;
|
||||
}
|
||||
|
||||
/** 고름 꼴마다 — 머리 글 · 몸 · 단추. */
|
||||
function body(
|
||||
picked: PlanSelection,
|
||||
network: PlanNetwork,
|
||||
): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } {
|
||||
const names = lineNames(network.lines);
|
||||
const nameOf = (id: string | null): string =>
|
||||
names[network.lines.findIndex((line) => line.id === id)] ?? "";
|
||||
const z = params.heightAt();
|
||||
const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id));
|
||||
const length = selectionLength(edit.paths());
|
||||
|
||||
if (picked.kind === "node") {
|
||||
const { line, vertex } = picked.node!;
|
||||
const vertices = network.lines.find((l) => l.id === line)!.vertices;
|
||||
const point = vertices[vertex];
|
||||
const tm = tmParams(params.epsg());
|
||||
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
|
||||
const height = z(point[0], point[1]);
|
||||
const rows: KvRow[] = [
|
||||
{
|
||||
label: "경위도",
|
||||
value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null,
|
||||
},
|
||||
{ label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null },
|
||||
{ label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) },
|
||||
];
|
||||
if (vertex > 0) {
|
||||
const [a, b] = [vertices[vertex - 1], vertices[vertex]];
|
||||
rows.push(
|
||||
{ label: "앞 선 길이", value: meters(lineLength([a, b])) },
|
||||
{ label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) },
|
||||
);
|
||||
}
|
||||
const entry = network.lines.find((l) => l.id === line)!;
|
||||
if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" });
|
||||
const parts: HTMLElement[] = [createKv(rows)];
|
||||
if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex));
|
||||
const toPoint = button("지점으로", "props-to-point", () =>
|
||||
params.addPoint([point[0], point[1]]),
|
||||
);
|
||||
toPoint.hidden = params.points().some((p) => samePoint(p, point));
|
||||
return {
|
||||
title: `${nameOf(line)} · 노드 ${vertex + 1}`,
|
||||
parts,
|
||||
actions: [
|
||||
button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)),
|
||||
toPoint,
|
||||
],
|
||||
};
|
||||
}
|
||||
if (editable && picked.mark) {
|
||||
const dropMark = createButton({
|
||||
label: "마크 지움",
|
||||
variant: "ghost",
|
||||
size: 24,
|
||||
onClick: edit.dropMark,
|
||||
});
|
||||
dropMark.dataset.role = "props-drop-mark";
|
||||
actions.append(dropMark);
|
||||
|
||||
if (picked.kind === "segment") {
|
||||
const single = picked.segments.length === 1 ? picked.segments[0] : null;
|
||||
const years = [
|
||||
...new Set(
|
||||
segmentPieces(network, picked.segments).map(
|
||||
(id) => network.pieces.find((p) => p.id === id)?.year,
|
||||
),
|
||||
),
|
||||
].sort();
|
||||
const rows: KvRow[] = [{ label: "길이", value: meters(length) }];
|
||||
if (single) {
|
||||
const [from, to] = segmentRange(network, single)!;
|
||||
const [a, b] = segmentPath(network, single)!;
|
||||
rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` });
|
||||
rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) });
|
||||
}
|
||||
rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") });
|
||||
const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)];
|
||||
if (single)
|
||||
actions.unshift(
|
||||
button("다시 긋기", "props-respan", () =>
|
||||
params.respan(single.line, single.index, single.index + 1),
|
||||
),
|
||||
);
|
||||
return {
|
||||
title: single
|
||||
? `${nameOf(single.line)} · 선 ${single.index + 1}`
|
||||
: `선 ${picked.segments.length}개`,
|
||||
parts: [createKv(rows)],
|
||||
actions,
|
||||
};
|
||||
}
|
||||
float.body.replaceChildren(scope.root, year.root, rows, actions);
|
||||
|
||||
if (picked.kind === "line") {
|
||||
const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? [];
|
||||
return {
|
||||
title: nameOf(picked.line),
|
||||
parts: [
|
||||
createKv([
|
||||
{ label: "길이", value: meters(length) },
|
||||
{ label: "노드", value: `${vertices.length}개` },
|
||||
]),
|
||||
yearField(pieces),
|
||||
pieceRows(network, pieces),
|
||||
],
|
||||
actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))],
|
||||
};
|
||||
}
|
||||
|
||||
if (picked.kind === "all") {
|
||||
const byYear = new Map<number, number>();
|
||||
for (const p of network.pieces)
|
||||
byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m);
|
||||
return {
|
||||
title: "전체",
|
||||
parts: [
|
||||
createKv([
|
||||
{ label: "길이", value: meters(length) },
|
||||
{ label: "구역", value: `${network.lines.length}개` },
|
||||
...[...byYear]
|
||||
.sort(([a], [b]) => a - b)
|
||||
.map(([year, m]) => ({ label: `${year}년`, value: meters(m) })),
|
||||
]),
|
||||
],
|
||||
actions: [button("전체 지움", "props-drop-all", edit.dropAll)],
|
||||
};
|
||||
}
|
||||
|
||||
// 연도 구간 · 마커
|
||||
const lineIds = new Set(pieces.map((piece) => piece.line));
|
||||
const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0);
|
||||
const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)];
|
||||
const actions: HTMLButtonElement[] = [];
|
||||
const mark = network.pieces.find((p) => p.id === picked.mark);
|
||||
if (mark) {
|
||||
const before = network.pieces.find(
|
||||
(p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3,
|
||||
);
|
||||
parts.unshift(
|
||||
createKv([
|
||||
{ label: "측점", value: station(mark.from_m) },
|
||||
{ label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` },
|
||||
]),
|
||||
);
|
||||
actions.push(button("마커 지움", "props-drop-mark", edit.dropMark));
|
||||
}
|
||||
return {
|
||||
title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`,
|
||||
parts,
|
||||
actions,
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(): void {
|
||||
const picked = edit.selected();
|
||||
float.root.hidden = !picked;
|
||||
if (!picked) return float.body.replaceChildren();
|
||||
const { title, parts, actions } = body(picked, params.network());
|
||||
const head = float.root.querySelector(".ui-float-panel__title");
|
||||
if (head) head.textContent = title;
|
||||
float.root.dataset.kind = picked.kind;
|
||||
const row = document.createElement("div");
|
||||
row.className = "d03-plan__row";
|
||||
if (params.editable()) row.append(...actions);
|
||||
float.body.replaceChildren(...parts, row);
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Select.ts
|
||||
* D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커.
|
||||
* 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴.
|
||||
* 「같은 해」 = 왼쪽 목록 해 줄로만(`year`).
|
||||
* 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌.
|
||||
* ========================================================================== */
|
||||
|
||||
import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw";
|
||||
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
import {
|
||||
lineSegments,
|
||||
nodeSegment,
|
||||
sameSegment,
|
||||
segmentPath,
|
||||
segmentPieces,
|
||||
type PlanNode,
|
||||
type PlanSegment,
|
||||
} from "./logic/D03_PlanAnalysis_Select";
|
||||
|
||||
export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark";
|
||||
/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */
|
||||
export type PlanScope = "piece" | "line" | "year";
|
||||
|
||||
export interface PlanSelection {
|
||||
kind: PlanSelectKind;
|
||||
/** 노드 고름 · 노드 씨. */
|
||||
node: PlanNode | null;
|
||||
/** 토막 고름(Shift · 끌어 고르기 = 여럿). */
|
||||
segments: PlanSegment[];
|
||||
/** 구역 고름 · 씨가 든 구역. */
|
||||
line: string | null;
|
||||
/** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */
|
||||
pieces: string[];
|
||||
/** 고른 마커(그 마커가 여는 구간 id). */
|
||||
mark: string | null;
|
||||
scope: PlanScope;
|
||||
/** 넓힘 씨 — 처음 누른 노드 · 토막. */
|
||||
seed: { node: PlanNode } | { segment: PlanSegment } | null;
|
||||
}
|
||||
|
||||
const NEXT: Partial<Record<PlanSelectKind, PlanSelectKind>> = {
|
||||
node: "segment",
|
||||
segment: "line",
|
||||
line: "all",
|
||||
};
|
||||
|
||||
export interface PlanSelect {
|
||||
get: () => PlanSelection | null;
|
||||
clear: () => void;
|
||||
node: (node: PlanNode) => void;
|
||||
/** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */
|
||||
segments: (segments: PlanSegment[], toggle?: boolean) => void;
|
||||
region: (line: string) => void;
|
||||
all: () => void;
|
||||
/** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */
|
||||
pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void;
|
||||
/** 넓힘 한 단(씨 기준). */
|
||||
grow: () => void;
|
||||
/** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */
|
||||
holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean;
|
||||
/** 밝힐 꺾은선. */
|
||||
paths: () => Vertex[][];
|
||||
sync: () => void;
|
||||
}
|
||||
|
||||
export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect {
|
||||
let selected: PlanSelection | null = null;
|
||||
|
||||
function piecesOf(next: Omit<PlanSelection, "pieces" | "scope">): string[] {
|
||||
const net = network();
|
||||
if (next.kind === "segment") return segmentPieces(net, next.segments);
|
||||
if (next.kind === "line")
|
||||
return net.pieces.filter((p) => p.line === next.line).map((p) => p.id);
|
||||
if (next.kind === "all") return net.pieces.map((p) => p.id);
|
||||
return [];
|
||||
}
|
||||
|
||||
function choose(
|
||||
next: Omit<PlanSelection, "pieces" | "scope"> & Partial<PlanSelection>,
|
||||
quiet = false,
|
||||
): void {
|
||||
selected = {
|
||||
...next,
|
||||
pieces: next.pieces ?? piecesOf(next),
|
||||
scope: next.scope ?? (next.kind === "line" ? "line" : "piece"),
|
||||
};
|
||||
if (!quiet) onChange();
|
||||
}
|
||||
|
||||
const empty = { node: null, segments: [], line: null, mark: null, seed: null };
|
||||
|
||||
function node(at: PlanNode): void {
|
||||
choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } });
|
||||
}
|
||||
|
||||
function segments(list: PlanSegment[], toggle = false): void {
|
||||
let next = list;
|
||||
if (toggle && selected?.kind === "segment") {
|
||||
const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t));
|
||||
next = [
|
||||
...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))),
|
||||
...list.filter((s) => !has(s)),
|
||||
];
|
||||
}
|
||||
if (!next.length) return clear();
|
||||
choose({
|
||||
...empty,
|
||||
kind: "segment",
|
||||
segments: next,
|
||||
line: next[0].line,
|
||||
seed: { segment: next[0] },
|
||||
});
|
||||
}
|
||||
|
||||
function clear(): void {
|
||||
selected = null;
|
||||
onChange();
|
||||
}
|
||||
|
||||
/** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */
|
||||
function grow(): void {
|
||||
if (!selected?.seed) return;
|
||||
const seed = selected.seed;
|
||||
const next = NEXT[selected.kind];
|
||||
const line = "node" in seed ? seed.node.line : seed.segment.line;
|
||||
if (next === "segment") {
|
||||
const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment;
|
||||
if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed });
|
||||
} else if (next === "line") choose({ ...empty, kind: "line", line, seed });
|
||||
else if (next === "all") choose({ ...empty, kind: "all", line, seed });
|
||||
else if ("node" in seed) node(seed.node);
|
||||
else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed });
|
||||
}
|
||||
|
||||
function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean {
|
||||
const s = selected;
|
||||
if (!s || s.kind === "pieces" || s.kind === "mark") return false;
|
||||
if (s.kind === "all") return true;
|
||||
const line = hit.node?.line ?? hit.segment?.line;
|
||||
if (s.kind === "line") return line === s.line;
|
||||
if (s.kind === "node")
|
||||
return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex;
|
||||
if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!));
|
||||
return s.segments.some(
|
||||
(t) =>
|
||||
t.line === hit.node!.line &&
|
||||
(t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex),
|
||||
);
|
||||
}
|
||||
|
||||
function paths(): Vertex[][] {
|
||||
const s = selected;
|
||||
const net = network();
|
||||
if (!s || s.kind === "node") return [];
|
||||
if (s.kind === "segment")
|
||||
return s.segments
|
||||
.map((seg) => segmentPath(net, seg))
|
||||
.filter((p): p is [Vertex, Vertex] => !!p);
|
||||
if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices);
|
||||
if (s.kind === "all") return net.lines.map((l) => l.vertices);
|
||||
return s.pieces.flatMap((id) => {
|
||||
const piece = net.pieces.find((p) => p.id === id);
|
||||
const v = net.lines.find((l) => l.id === piece?.line)?.vertices;
|
||||
return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */
|
||||
function sync(): void {
|
||||
const s = selected;
|
||||
if (!s) return;
|
||||
const net = network();
|
||||
const vertexCount = (line: string | null): number =>
|
||||
net.lines.find((l) => l.id === line)?.vertices.length ?? 0;
|
||||
const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line);
|
||||
const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line);
|
||||
if (s.kind === "pieces" || s.kind === "mark") {
|
||||
const ids = new Set(net.pieces.map((p) => p.id));
|
||||
const pieces = s.pieces.filter((id) => ids.has(id));
|
||||
selected = pieces.length
|
||||
? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null }
|
||||
: null;
|
||||
if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" };
|
||||
return;
|
||||
}
|
||||
const seed =
|
||||
s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null;
|
||||
const segments = s.segments.filter(okSeg);
|
||||
const alive = {
|
||||
all: net.lines.length > 0,
|
||||
line: vertexCount(s.line) > 0,
|
||||
node: !!s.node && okNode(s.node),
|
||||
segment: segments.length > 0,
|
||||
}[s.kind];
|
||||
if (alive) choose({ ...s, segments, seed, pieces: undefined }, true);
|
||||
else selected = null;
|
||||
}
|
||||
|
||||
return {
|
||||
get: () => selected,
|
||||
clear,
|
||||
node,
|
||||
segments,
|
||||
region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }),
|
||||
all: () => choose({ ...empty, kind: "all", seed: null }),
|
||||
pieces(ids, mark = null, scope = "piece") {
|
||||
const list = [...new Set(ids)];
|
||||
if (!list.length) return clear();
|
||||
const net = network();
|
||||
choose({
|
||||
...empty,
|
||||
kind: mark ? "mark" : "pieces",
|
||||
line: net.pieces.find((p) => p.id === list[0])?.line ?? null,
|
||||
pieces: list,
|
||||
mark,
|
||||
scope,
|
||||
});
|
||||
},
|
||||
grow,
|
||||
holds,
|
||||
paths,
|
||||
sync,
|
||||
};
|
||||
|
||||
/** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */
|
||||
function lineSeed(line: string): { segment: PlanSegment } | null {
|
||||
const first = lineSegments(network(), line)[0];
|
||||
return first ? { segment: first } : null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */
|
||||
export function selectionLength(paths: Vertex[][]): number {
|
||||
return paths.reduce((sum, path) => sum + lineLength(path), 0);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_Site.ts
|
||||
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
|
||||
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
|
||||
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기].
|
||||
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
|
||||
* ========================================================================== */
|
||||
|
||||
import { pickFiles } from "@ui/ui_template_drop";
|
||||
import { createButton } from "@ui/ui_template_elements";
|
||||
import { createFindList, type FindList, type FindRow } from "@ui/ui_template_find_list";
|
||||
import { t } from "@ui/ui_template_locale";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api";
|
||||
|
||||
/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */
|
||||
const MIN_QUERY = 2;
|
||||
|
||||
export interface PlanSiteParams {
|
||||
/** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */
|
||||
onPick: (hit: PlaceHit) => void;
|
||||
onRetry: () => void;
|
||||
onUpload: (files: File[]) => void;
|
||||
}
|
||||
|
||||
export interface PlanSiteState {
|
||||
address: string | null;
|
||||
/** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */
|
||||
note: string;
|
||||
retry: boolean;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
export interface PlanSite {
|
||||
/** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */
|
||||
parts: HTMLElement[];
|
||||
render: (state: PlanSiteState) => void;
|
||||
}
|
||||
|
||||
/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 ·
|
||||
* 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */
|
||||
export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList {
|
||||
const find = createFindList({ placeholder: t("DE_Reg_Find") });
|
||||
const list = find.root.querySelector<HTMLElement>(".ui-find__list")!;
|
||||
let asked = 0; // 옛 찾기 답 버림
|
||||
// 빈 알림 · 빈 목록은 숨김(칸 사이 빈 틈 없앰)
|
||||
const say = (text: string): void => {
|
||||
find.hint.textContent = text;
|
||||
find.hint.hidden = !text;
|
||||
};
|
||||
const show = (rows: FindRow[]): void => {
|
||||
find.setRows(rows);
|
||||
list.hidden = !rows.length;
|
||||
};
|
||||
say("");
|
||||
show([]);
|
||||
|
||||
async function run(): Promise<void> {
|
||||
const query = find.search.value.trim();
|
||||
show([]);
|
||||
if (query.length < MIN_QUERY) return say(t("DE_Reg_FindShort"));
|
||||
const at = ++asked;
|
||||
say(t("DE_Reg_FindBusy"));
|
||||
try {
|
||||
const hits = await searchPlaces(query);
|
||||
if (at !== asked) return;
|
||||
say(hits.length ? "" : t("DE_Reg_FindNone"));
|
||||
show(
|
||||
hits.map((hit, i) => ({
|
||||
key: String(i),
|
||||
tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"),
|
||||
name: hit.title,
|
||||
sub: hit.address !== hit.title ? hit.address : undefined,
|
||||
onPick: () => {
|
||||
show([]);
|
||||
find.search.value = hit.title;
|
||||
onPick(hit);
|
||||
},
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
if (at === asked)
|
||||
say(`${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`);
|
||||
}
|
||||
}
|
||||
// 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음
|
||||
find.search.addEventListener("input", () => show([]));
|
||||
find.search.addEventListener("keydown", (event) => {
|
||||
if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return;
|
||||
event.preventDefault();
|
||||
void run();
|
||||
});
|
||||
return find;
|
||||
}
|
||||
|
||||
export function createPlanSite(params: PlanSiteParams): PlanSite {
|
||||
const address = createText("", "muted", 14);
|
||||
const note = createText("", "error");
|
||||
const find = createPlaceFind(params.onPick);
|
||||
|
||||
const retry = createButton({
|
||||
label: "다시 받기",
|
||||
variant: "filled",
|
||||
size: 28,
|
||||
onClick: params.onRetry,
|
||||
});
|
||||
const upload = createButton({
|
||||
label: "임도망 올리기",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () =>
|
||||
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload),
|
||||
});
|
||||
upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
|
||||
return {
|
||||
parts: [address, find.root, note, retry, upload],
|
||||
render(state) {
|
||||
address.textContent = state.address ?? "";
|
||||
address.hidden = !state.address;
|
||||
note.textContent = state.note;
|
||||
note.hidden = !state.note;
|
||||
find.root.hidden = upload.hidden = !state.editable;
|
||||
retry.hidden = !state.editable || !state.retry;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_UI_SlopeBands.ts
|
||||
* D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
|
||||
* (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음).
|
||||
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
|
||||
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
|
||||
* 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createButton, createIconButton } from "@ui/ui_template_button";
|
||||
import { createInputField, createSelectField } from "@ui/ui_template_input";
|
||||
import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { createText } from "@ui/ui_template_text";
|
||||
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
|
||||
import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs";
|
||||
import {
|
||||
DEFAULT_SLOPE_BANDS,
|
||||
SLOPE_COLORS,
|
||||
SLOPE_MAX_BANDS,
|
||||
SLOPE_MAX_PCT,
|
||||
bandOf,
|
||||
checkBands,
|
||||
parseBands,
|
||||
pctToDeg,
|
||||
type SlopeBand,
|
||||
} from "./logic/D03_PlanAnalysis_SlopeBands";
|
||||
|
||||
const PREF_KEY = "aislo:pref:d03.slope-bands";
|
||||
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
|
||||
const ALPHA = 150;
|
||||
|
||||
export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY));
|
||||
export const writeSlopeBands = (bands: SlopeBand[]): void =>
|
||||
writeByKey(PREF_KEY, JSON.stringify(bands));
|
||||
|
||||
/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */
|
||||
export function slopeValues(image: HTMLImageElement): ImageData {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true })!;
|
||||
context.drawImage(image, 0, 0);
|
||||
return context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */
|
||||
export async function paintSlope(
|
||||
values: ImageData,
|
||||
bands: SlopeBand[],
|
||||
): Promise<{ url: string; image: HTMLImageElement }> {
|
||||
const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15")));
|
||||
const lut = new Uint32Array(256);
|
||||
const view = new DataView(lut.buffer);
|
||||
for (let v = 0; v < 256; v++) {
|
||||
const i = bandOf(v, bands);
|
||||
if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false);
|
||||
}
|
||||
const out = new ImageData(values.width, values.height);
|
||||
const src = values.data;
|
||||
const dst = new Uint32Array(out.data.buffer);
|
||||
for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]];
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = values.width;
|
||||
canvas.height = values.height;
|
||||
canvas.getContext("2d")!.putImageData(out, 0, 0);
|
||||
const blob = await new Promise<Blob>((done, fail) =>
|
||||
canvas.toBlob(
|
||||
(got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))),
|
||||
"image/png",
|
||||
),
|
||||
);
|
||||
const url = URL.createObjectURL(blob);
|
||||
return { url, image: await loadImage(url) };
|
||||
}
|
||||
|
||||
/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */
|
||||
function hexRgb(color: string): number {
|
||||
const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1];
|
||||
return hex ? parseInt(hex, 16) : 0xfacc15;
|
||||
}
|
||||
|
||||
/** 범례 급경사 줄 ⚙. */
|
||||
export const slopeGear = (onClick: () => void): HTMLButtonElement => {
|
||||
const button = createIconButton({
|
||||
icon: "settings",
|
||||
title: "급경사 범위 바꾸기(기울기 %)",
|
||||
size: 20,
|
||||
onClick,
|
||||
});
|
||||
button.dataset.role = "slope-bands";
|
||||
return button;
|
||||
};
|
||||
|
||||
const degText = (value: string): string => {
|
||||
const pct = Number(value);
|
||||
return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—";
|
||||
};
|
||||
|
||||
/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */
|
||||
export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void {
|
||||
let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = [];
|
||||
const list = document.createElement("div");
|
||||
const error = createText("", "error");
|
||||
error.hidden = true;
|
||||
const addButton = createButton({
|
||||
label: "범위 추가",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
iconStart: "plus",
|
||||
onClick: () => {
|
||||
const last = rows.at(-1)?.read();
|
||||
const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0;
|
||||
const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color;
|
||||
add({ low, high: SLOPE_MAX_PCT, color });
|
||||
},
|
||||
});
|
||||
addButton.dataset.role = "slope-band-add";
|
||||
|
||||
function refresh(): void {
|
||||
addButton.disabled = rows.length >= SLOPE_MAX_BANDS;
|
||||
error.hidden = true;
|
||||
}
|
||||
|
||||
function add(band: SlopeBand): void {
|
||||
const field = (label: string, value: number) => {
|
||||
const got = createInputField({
|
||||
label,
|
||||
type: "number",
|
||||
unit: "%",
|
||||
min: 0,
|
||||
max: SLOPE_MAX_PCT,
|
||||
size: 28,
|
||||
value: Number.isFinite(value) ? String(value) : "",
|
||||
});
|
||||
const deg = createText(degText(got.input.value), "muted");
|
||||
got.input.addEventListener("input", () => {
|
||||
deg.textContent = degText(got.input.value);
|
||||
refresh();
|
||||
});
|
||||
got.root.append(deg);
|
||||
return got;
|
||||
};
|
||||
const low = field("아래", band.low);
|
||||
const high = field("위", band.high);
|
||||
const color = createSelectField({
|
||||
label: "색",
|
||||
size: 28,
|
||||
options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })),
|
||||
value: band.color,
|
||||
});
|
||||
const chip = createLegend([{ label: "", color: `var(${band.color})` }]);
|
||||
color.select.addEventListener("change", () =>
|
||||
chip
|
||||
.querySelector<HTMLElement>(".ui-legend__mark")!
|
||||
.style.setProperty("--legend-color", `var(${color.select.value})`),
|
||||
);
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__row";
|
||||
root.dataset.role = "slope-band";
|
||||
const entry = {
|
||||
root,
|
||||
read: (): SlopeBand => ({
|
||||
low: low.input.value.trim() === "" ? NaN : Number(low.input.value),
|
||||
high: high.input.value.trim() === "" ? NaN : Number(high.input.value),
|
||||
color: color.select.value,
|
||||
}),
|
||||
};
|
||||
const remove = createIconButton({
|
||||
icon: "trash",
|
||||
title: "이 범위 지움",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
rows = rows.filter((row) => row !== entry);
|
||||
root.remove();
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
root.append(low.root, high.root, color.root, chip, remove);
|
||||
rows.push(entry);
|
||||
list.append(root);
|
||||
refresh();
|
||||
}
|
||||
|
||||
function fill(bands: SlopeBand[]): void {
|
||||
rows = [];
|
||||
list.replaceChildren();
|
||||
bands.forEach(add);
|
||||
}
|
||||
|
||||
openModal({
|
||||
title: "급경사 범위(기울기 %)",
|
||||
closeLabel: "닫기",
|
||||
width: "40rem",
|
||||
mount: (body, close, actions) => {
|
||||
fill(current);
|
||||
const reset = createButton({
|
||||
label: "기본값",
|
||||
variant: "ghost",
|
||||
size: 28,
|
||||
onClick: () => fill(DEFAULT_SLOPE_BANDS),
|
||||
});
|
||||
reset.dataset.role = "slope-band-default";
|
||||
const apply = createButton({
|
||||
label: "적용",
|
||||
size: 28,
|
||||
onClick: () => {
|
||||
const bands = rows.map((row) => row.read());
|
||||
const why = checkBands(bands);
|
||||
if (why) {
|
||||
error.lastChild!.textContent = why;
|
||||
error.hidden = false;
|
||||
return;
|
||||
}
|
||||
onApply(bands.sort((a, b) => a.low - b.low));
|
||||
close();
|
||||
},
|
||||
});
|
||||
apply.dataset.role = "slope-band-apply";
|
||||
actions.append(apply);
|
||||
const foot = document.createElement("div");
|
||||
foot.className = "d03-plan__row";
|
||||
foot.append(addButton, reset);
|
||||
body.append(
|
||||
createText(
|
||||
`0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`,
|
||||
"hint",
|
||||
),
|
||||
list,
|
||||
foot,
|
||||
error,
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -106,28 +106,6 @@
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.d03-plan__piece-head {
|
||||
margin: 6px 0 0;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.d03-plan__piece {
|
||||
display: flex;
|
||||
gap: var(--spacing-8);
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.d03-plan__piece > .ui-text {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.d03-plan__piece > .ui-field {
|
||||
flex: 0 0 6.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 지형 받기 범위 미리보기 — 카카오 지도 */
|
||||
.d03-plan__extent > .ui-text {
|
||||
margin: 0 0 6px;
|
||||
@@ -152,12 +130,6 @@
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
/* 580-3 고른 선 · 조각 줄 밝힘 */
|
||||
.d03-plan__piece.is-selected {
|
||||
background: var(--color-accent-soft);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
/* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */
|
||||
.d03-plan__props {
|
||||
top: calc(var(--spacing-8) * 6);
|
||||
@@ -188,12 +160,22 @@
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.d03-plan__piece[role="button"] {
|
||||
cursor: pointer;
|
||||
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
|
||||
.d03-plan__points-head {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: var(--spacing-8);
|
||||
}
|
||||
|
||||
.d03-plan__piece-year {
|
||||
font-weight: 600;
|
||||
.d03-plan__point-edit {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
|
||||
}
|
||||
|
||||
.d03-plan__point-edit > .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
|
||||
@@ -213,3 +195,22 @@
|
||||
.d03-plan__draw-fields .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
|
||||
.d03-plan__line-tail {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.d03-plan__pieces .ui-list-row__label > .ui-field {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.d03-plan__piece-row {
|
||||
padding-left: calc(var(--spacing-8) * 2);
|
||||
}
|
||||
|
||||
.d03-plan__piece-row .ui-list-row__label {
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_PointEdit.ts
|
||||
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
|
||||
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
|
||||
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
|
||||
* ========================================================================== */
|
||||
|
||||
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
|
||||
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
|
||||
|
||||
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
|
||||
export const MAX_POINTS = 40;
|
||||
|
||||
/** 보이는 이름 — 빈 값 = 「지점 N」. */
|
||||
export function pointLabel(names: string[], index: number): string {
|
||||
return names[index]?.trim() || `지점 ${index + 1}`;
|
||||
}
|
||||
|
||||
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
|
||||
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
|
||||
const lon = Number(lonText.trim());
|
||||
const lat = Number(latText.trim());
|
||||
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
|
||||
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
|
||||
return [lon, lat];
|
||||
}
|
||||
|
||||
export interface PointsState {
|
||||
points: Vertex[];
|
||||
names: string[];
|
||||
network: PlanNetwork;
|
||||
}
|
||||
|
||||
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
|
||||
export function addPoint(state: PointsState, at: Vertex): PointsState {
|
||||
if (state.points.length >= MAX_POINTS) return state;
|
||||
return {
|
||||
...state,
|
||||
points: [...state.points, [at[0], at[1]]],
|
||||
names: [...state.names, ""],
|
||||
};
|
||||
}
|
||||
|
||||
/** 이름 바꿈 — 빈 값 허용. */
|
||||
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
|
||||
const names = state.points.map((_, k) => state.names[k] ?? "");
|
||||
names[index] = name.trim();
|
||||
return { ...state, names };
|
||||
}
|
||||
|
||||
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
|
||||
export function movePointTo(
|
||||
state: PointsState,
|
||||
index: number,
|
||||
tm: TmParams,
|
||||
lon: number,
|
||||
lat: number,
|
||||
): PointsState {
|
||||
const to = lonLatToTm(tm, lon, lat);
|
||||
const from = state.points[index];
|
||||
return {
|
||||
...state,
|
||||
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
|
||||
network: moveNodesAt(state.network, from, to),
|
||||
};
|
||||
}
|
||||
|
||||
/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */
|
||||
export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork {
|
||||
const text = name.trim();
|
||||
return {
|
||||
...network,
|
||||
lines: network.lines.map((line) =>
|
||||
line.id === lineId ? { ...line, name: text || null } : line,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_Select.ts
|
||||
* D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체.
|
||||
* 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음.
|
||||
* 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감).
|
||||
* 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기.
|
||||
* 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`).
|
||||
* DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`).
|
||||
* ========================================================================== */
|
||||
|
||||
import {
|
||||
compassPoint,
|
||||
fixedLengthPoint,
|
||||
fixedSlopePoint,
|
||||
lineLength,
|
||||
segmentLength,
|
||||
type HeightAt,
|
||||
} from "./D03_PlanAnalysis_Draw";
|
||||
import {
|
||||
moveNodesAt,
|
||||
moveVertex,
|
||||
piecesInBox,
|
||||
removeLine,
|
||||
samePoint,
|
||||
orderLines,
|
||||
type NodeRecord,
|
||||
type PlanLine,
|
||||
type PlanNetwork,
|
||||
type PlanPiece,
|
||||
type Vertex,
|
||||
type ViewBox,
|
||||
} from "./D03_PlanAnalysis_Plan";
|
||||
|
||||
/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */
|
||||
export interface PlanSegment {
|
||||
line: string;
|
||||
index: number;
|
||||
}
|
||||
|
||||
/** 노드 — 구역 id · 꼭짓점 차례. */
|
||||
export interface PlanNode {
|
||||
line: string;
|
||||
vertex: number;
|
||||
}
|
||||
|
||||
const EPS = 1e-6;
|
||||
const round3 = (value: number): number => Math.round(value * 1000) / 1000;
|
||||
|
||||
/** 꺾임점마다 선 위 거리. */
|
||||
export function chainages(vertices: ReadonlyArray<Vertex>): number[] {
|
||||
const out = [0];
|
||||
for (let i = 1; i < vertices.length; i += 1)
|
||||
out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i]));
|
||||
return out;
|
||||
}
|
||||
|
||||
export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean =>
|
||||
a.line === b.line && a.index === b.index;
|
||||
|
||||
/** 구역 토막 모두. */
|
||||
export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] {
|
||||
const entry = network.lines.find((l) => l.id === line);
|
||||
const count = Math.max(0, (entry?.vertices.length ?? 0) - 1);
|
||||
return Array.from({ length: count }, (_, index) => ({ line, index }));
|
||||
}
|
||||
|
||||
/** 노선망 토막 모두. */
|
||||
export const allSegments = (network: PlanNetwork): PlanSegment[] =>
|
||||
network.lines.flatMap((line) => lineSegments(network, line.id));
|
||||
|
||||
/** 토막 꼭짓점 둘(없으면 null). */
|
||||
export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null {
|
||||
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
|
||||
return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null;
|
||||
}
|
||||
|
||||
/** 토막 선 위 거리 [시작, 끝](m). */
|
||||
export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null {
|
||||
const v = network.lines.find((l) => l.id === seg.line)?.vertices;
|
||||
if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null;
|
||||
const from = lineLength(v.slice(0, seg.index + 1));
|
||||
return [from, from + segmentLength(v[seg.index], v[seg.index + 1])];
|
||||
}
|
||||
|
||||
/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */
|
||||
export function segmentAt(vertices: ReadonlyArray<Vertex>, atM: number): number {
|
||||
const c = chainages(vertices);
|
||||
for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i;
|
||||
return Math.max(0, c.length - 2);
|
||||
}
|
||||
|
||||
/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */
|
||||
export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null {
|
||||
const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0;
|
||||
if (n < 2) return null;
|
||||
return { line: node.line, index: Math.min(node.vertex, n - 2) };
|
||||
}
|
||||
|
||||
/** 토막에 걸친 조각 id(연도 구간). */
|
||||
export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray<PlanSegment>): string[] {
|
||||
const out = new Set<string>();
|
||||
for (const seg of segs) {
|
||||
const range = segmentRange(network, seg);
|
||||
if (!range) continue;
|
||||
for (const piece of network.pieces)
|
||||
if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS)
|
||||
out.add(piece.id);
|
||||
}
|
||||
return [...out];
|
||||
}
|
||||
|
||||
/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */
|
||||
export function segmentsInBox(
|
||||
network: PlanNetwork,
|
||||
toView: (point: Vertex) => Vertex,
|
||||
box: ViewBox,
|
||||
crossing: boolean,
|
||||
): PlanSegment[] {
|
||||
const segs = allSegments(network);
|
||||
const probe: PlanNetwork = {
|
||||
lines: segs.map((seg, i) => ({
|
||||
id: `s${i}`,
|
||||
vertices: segmentPath(network, seg)!,
|
||||
branch: null,
|
||||
})),
|
||||
pieces: segs.map((seg, i) => {
|
||||
const [a, b] = segmentPath(network, seg)!;
|
||||
return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 };
|
||||
}),
|
||||
};
|
||||
return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]);
|
||||
}
|
||||
|
||||
/** 새 구역 id — `line-<n>`(있는 것과 안 겹침). */
|
||||
function nextLineId(lines: ReadonlyArray<PlanLine>): string {
|
||||
const used = new Set(lines.map((line) => line.id));
|
||||
let n = lines.length;
|
||||
while (used.has(`line-${n}`)) n += 1;
|
||||
return `line-${n}`;
|
||||
}
|
||||
|
||||
/** 새 조각 id — `p<n>`(있는 것 중 가장 큰 수 + 1). */
|
||||
function nextPieceId(pieces: ReadonlyArray<PlanPiece>): string {
|
||||
const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0));
|
||||
return `p${Math.max(0, ...used) + 1}`;
|
||||
}
|
||||
|
||||
/** 꼭짓점 [a, b) 만 남긴 선 — 노드 기록(594-1)도 같이 자름 · 뒤쪽 첫 기록 = 없음(앞 노드가 사라짐). */
|
||||
function cutVertices(entry: PlanLine, a: number, b: number): PlanLine {
|
||||
const vertices = entry.vertices.slice(a, b);
|
||||
if (!entry.nodes) return { ...entry, vertices };
|
||||
const nodes = entry.nodes.slice(a, b);
|
||||
if (a > 0 && nodes.length) nodes[0] = null;
|
||||
return { ...entry, vertices, nodes };
|
||||
}
|
||||
|
||||
/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) ·
|
||||
* 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */
|
||||
export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string {
|
||||
const li = network.lines.findIndex((l) => l.id === seg.line);
|
||||
const entry = network.lines[li];
|
||||
const n = (entry?.vertices.length ?? 0) - 1;
|
||||
if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다.";
|
||||
if (n === 1) return removeLine(network, li);
|
||||
const i = seg.index;
|
||||
const keepFront = i > 0;
|
||||
const keepBack = i < n - 1;
|
||||
const children = network.lines.filter((l) => l.branch?.line === entry.id);
|
||||
if (
|
||||
children.some(
|
||||
(l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n),
|
||||
)
|
||||
)
|
||||
return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요.";
|
||||
const backId = keepFront ? nextLineId(network.lines) : entry.id;
|
||||
const c = chainages(entry.vertices);
|
||||
const [cut0, cut1] = [c[i], c[i + 1]];
|
||||
|
||||
const front: PlanPiece[] = [];
|
||||
const back: PlanPiece[] = [];
|
||||
for (const piece of network.pieces.filter((p) => p.line === entry.id)) {
|
||||
if (keepFront && piece.from_m < cut0 - EPS)
|
||||
front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) });
|
||||
if (keepBack && piece.to_m > cut1 + EPS)
|
||||
back.push({
|
||||
...piece,
|
||||
line: backId,
|
||||
from_m: round3(Math.max(0, piece.from_m - cut1)),
|
||||
to_m: round3(piece.to_m - cut1),
|
||||
});
|
||||
}
|
||||
const others = network.pieces.filter((p) => p.line !== entry.id);
|
||||
for (const piece of back)
|
||||
if (front.some((p) => p.id === piece.id))
|
||||
piece.id = nextPieceId([...others, ...front, ...back]);
|
||||
|
||||
const lines: PlanLine[] = [];
|
||||
for (const line of network.lines) {
|
||||
if (line === entry) {
|
||||
if (keepFront) lines.push(cutVertices(entry, 0, i + 1));
|
||||
if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null });
|
||||
continue;
|
||||
}
|
||||
const b = line.branch;
|
||||
if (b?.line === entry.id && b.vertex > i)
|
||||
lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } });
|
||||
else lines.push(line);
|
||||
}
|
||||
return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] };
|
||||
}
|
||||
|
||||
/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */
|
||||
export function removeSegments(
|
||||
network: PlanNetwork,
|
||||
segs: ReadonlyArray<PlanSegment>,
|
||||
): PlanNetwork | string {
|
||||
let next = network;
|
||||
const order = [...segs].sort((a, b) =>
|
||||
a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1,
|
||||
);
|
||||
for (const seg of order) {
|
||||
const out = removeSegment(next, seg);
|
||||
if (typeof out === "string") return out;
|
||||
next = out;
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */
|
||||
export const removeAll = (network: PlanNetwork): PlanNetwork => ({
|
||||
...network,
|
||||
lines: [],
|
||||
pieces: [],
|
||||
});
|
||||
|
||||
/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */
|
||||
export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] {
|
||||
const out: PlanNode[] = [];
|
||||
for (const line of network.lines)
|
||||
line.vertices.forEach((v, vertex) => {
|
||||
if (!(vertex === 0 && line.branch) && samePoint(v, point))
|
||||
out.push({ line: line.id, vertex });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */
|
||||
export function movePointsAt(points: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
|
||||
return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p));
|
||||
}
|
||||
|
||||
/** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) ·
|
||||
* 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */
|
||||
export function placeNode(
|
||||
network: PlanNetwork,
|
||||
points: ReadonlyArray<Vertex>,
|
||||
line: number,
|
||||
vertex: number,
|
||||
record: NodeRecord,
|
||||
heightAt: HeightAt,
|
||||
): { network: PlanNetwork; points: Vertex[] } | string {
|
||||
const entry = network.lines[line];
|
||||
if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다.";
|
||||
const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]];
|
||||
const { kind, length_m: lengthM, slope_pct: slopePct } = record;
|
||||
const to =
|
||||
kind === "free"
|
||||
? here
|
||||
: kind === "length"
|
||||
? fixedLengthPoint(prev, here, lengthM ?? 0)
|
||||
: kind === "slope"
|
||||
? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt)
|
||||
: compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt);
|
||||
if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요).";
|
||||
const bound = points.some((p) => samePoint(p, here));
|
||||
let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to);
|
||||
next = {
|
||||
...next,
|
||||
lines: next.lines.map((l) => {
|
||||
if (l.id !== entry.id) return l;
|
||||
const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null);
|
||||
nodes[vertex] = record;
|
||||
return { ...l, nodes };
|
||||
}),
|
||||
};
|
||||
return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] };
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/* =============================================================================
|
||||
* D03_PlanAnalysis_SlopeBands.ts
|
||||
* D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
|
||||
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
|
||||
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
|
||||
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
|
||||
* 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`.
|
||||
* ========================================================================== */
|
||||
|
||||
export interface SlopeBand {
|
||||
/** 아래 % (포함) */
|
||||
low: number;
|
||||
/** 위 % (뺌 · 250 = 끝까지) */
|
||||
high: number;
|
||||
/** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */
|
||||
color: string;
|
||||
}
|
||||
|
||||
export const SLOPE_MAX_PCT = 250;
|
||||
export const SLOPE_EMPTY = 255;
|
||||
export const SLOPE_MAX_BANDS = 8;
|
||||
|
||||
/** 고를 색(이름 · 그림 색 값). */
|
||||
export const SLOPE_COLORS: Array<{ name: string; color: string }> = [
|
||||
{ name: "노랑", color: "--draw-map-landslide-3" },
|
||||
{ name: "주황", color: "--draw-map-landslide-2" },
|
||||
{ name: "빨강", color: "--draw-map-landslide-1" },
|
||||
{ name: "연두", color: "--draw-map-landslide-4" },
|
||||
{ name: "초록", color: "--draw-map-landslide-5" },
|
||||
{ name: "하늘", color: "--draw-map-stream" },
|
||||
{ name: "파랑", color: "--draw-map-zone-water" },
|
||||
{ name: "보라", color: "--draw-map-zone-heritage" },
|
||||
{ name: "분홍", color: "--draw-map-parcel" },
|
||||
];
|
||||
|
||||
export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [
|
||||
{ low: 26.8, high: 70.0, color: "--draw-map-landslide-3" },
|
||||
{ low: 70.0, high: 83.9, color: "--draw-map-landslide-2" },
|
||||
{ low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" },
|
||||
];
|
||||
|
||||
export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI;
|
||||
|
||||
/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */
|
||||
export function checkBands(bands: SlopeBand[]): string | null {
|
||||
if (!bands.length) return "범위가 하나는 있어야 합니다";
|
||||
if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`;
|
||||
for (const [i, band] of bands.entries()) {
|
||||
const at = `${i + 1}번째 범위`;
|
||||
if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`;
|
||||
if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`;
|
||||
if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`;
|
||||
if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`;
|
||||
}
|
||||
const sorted = [...bands].sort((a, b) => a.low - b.low);
|
||||
for (let i = 1; i < sorted.length; i++)
|
||||
if (sorted[i].low < sorted[i - 1].high)
|
||||
return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */
|
||||
export function parseBands(raw: string | null): SlopeBand[] {
|
||||
try {
|
||||
const got = JSON.parse(raw ?? "null") as unknown;
|
||||
if (Array.isArray(got)) {
|
||||
const bands = got.map((b: Partial<SlopeBand>) => ({
|
||||
low: Number(b?.low),
|
||||
high: Number(b?.high),
|
||||
color: String(b?.color),
|
||||
}));
|
||||
if (!checkBands(bands)) return bands;
|
||||
}
|
||||
} catch {
|
||||
// 깨진 값 = 기본
|
||||
}
|
||||
return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b }));
|
||||
}
|
||||
|
||||
/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */
|
||||
export function bandOf(v: number, bands: SlopeBand[]): number {
|
||||
if (v > SLOPE_MAX_PCT) return -1;
|
||||
const mid = v + 0.5;
|
||||
return bands.findIndex(
|
||||
(b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)),
|
||||
);
|
||||
}
|
||||
|
||||
/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */
|
||||
export function bandRatios(counts: number[], bands: SlopeBand[]): number[] {
|
||||
const total = counts.reduce((sum, n) => sum + n, 0) || 1;
|
||||
const out = bands.map(() => 0);
|
||||
counts.forEach((n, v) => {
|
||||
const i = bandOf(v, bands);
|
||||
if (i >= 0) out[i] += n;
|
||||
});
|
||||
return out.map((n) => n / total);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
|
||||
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음.
|
||||
"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 ·
|
||||
옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 ·
|
||||
받은 범위 좌표계 · 칸 상한 · 거리.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q
|
||||
"""
|
||||
@@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge)
|
||||
monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite)
|
||||
monkeypatch.setattr(terrain, "build_sheet_surface_model", dem)
|
||||
monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1
|
||||
return tmp_path, seen
|
||||
|
||||
|
||||
@@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None:
|
||||
assert len(seen["merge"]) == merges and third["box_m"] == grown
|
||||
|
||||
|
||||
def test_reset_drops_old_box_sheets_satellite(fake) -> None:
|
||||
"""595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 ·
|
||||
다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음."""
|
||||
root, seen = fake
|
||||
terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0))
|
||||
old = set(seen["merge"][-1])
|
||||
far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)]
|
||||
got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0))
|
||||
m = terrain.DEFAULT_MARGIN_M
|
||||
assert got["box_m"] == pytest.approx(
|
||||
[far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m]
|
||||
)
|
||||
assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음)
|
||||
assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침
|
||||
assert "cadastral" not in got
|
||||
meta = terrain.received_meta(root)
|
||||
assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0]
|
||||
assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"]))
|
||||
assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님
|
||||
# 새로 아닌 이어 받기 = 가운데 기록 그대로
|
||||
terrain.ensure_plan_terrain(root, far, EPSG)
|
||||
assert terrain.received_meta(root)["center"] == [128.0, 36.0]
|
||||
|
||||
|
||||
def test_cells_distance_and_limit(fake) -> None:
|
||||
root, _ = fake
|
||||
assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자
|
||||
assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0
|
||||
assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5
|
||||
assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000
|
||||
assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS
|
||||
|
||||
|
||||
def test_extent_no_download_and_covered(fake) -> None:
|
||||
root, seen = fake
|
||||
before = terrain.plan_extent(root, [A, B], EPSG)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이
|
||||
없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌.
|
||||
"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 ·
|
||||
선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠
|
||||
물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김.
|
||||
|
||||
옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) ·
|
||||
노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`.
|
||||
@@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
|
||||
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
|
||||
P0, P2 = (0.0, 0.0), (900.0, 400.0)
|
||||
URL = f"/api/projects/{PID}/plan/terrain"
|
||||
OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°)
|
||||
Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1])
|
||||
|
||||
|
||||
class _Pool:
|
||||
@@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch):
|
||||
test = TestClient(app)
|
||||
test.plan = plan
|
||||
test.info = info
|
||||
test.root = tmp_path
|
||||
return test
|
||||
|
||||
|
||||
@@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
|
||||
saved: list = []
|
||||
works: list = []
|
||||
|
||||
def ensure(root, points, epsg, margin_m, report):
|
||||
def ensure(root, points, epsg, margin_m, report, **kw):
|
||||
calls.append((points, epsg, margin_m))
|
||||
return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]}
|
||||
|
||||
async def save(project_id, models, epsg):
|
||||
saved.append((models, epsg))
|
||||
|
||||
def start(project_id, kind, work):
|
||||
def start(project_id, kind, work, root=None):
|
||||
works.append(work)
|
||||
return {"status": "accepted", "kind": kind}
|
||||
|
||||
@@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None:
|
||||
# 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점
|
||||
post({"points": []})
|
||||
assert calls[-1] == ([P0, P2], 5186, None)
|
||||
|
||||
|
||||
# ── 595 위치 바꿈 · 상한 · 끊김 ──
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def posting(client, monkeypatch):
|
||||
"""POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록."""
|
||||
calls: list = []
|
||||
|
||||
def ensure(root, points, epsg, margin_m, report, reset=False, center=None):
|
||||
calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center})
|
||||
return {"sheets": {"downloaded": []}, "models": []}
|
||||
|
||||
async def save(project_id, models, epsg):
|
||||
return None
|
||||
|
||||
works: list = []
|
||||
|
||||
def start(project_id, kind, work, root=None):
|
||||
works.append(work)
|
||||
return {"status": "accepted", "kind": kind}
|
||||
|
||||
def post(body):
|
||||
res = client.post(URL, json=body)
|
||||
while works: # 일은 응답 뒤 돌림(앱 고리 밖)
|
||||
asyncio.run(works.pop()(lambda percent, message: None))
|
||||
return res
|
||||
|
||||
monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure)
|
||||
monkeypatch.setattr(router_mod, "_save_models", save)
|
||||
monkeypatch.setattr(router_mod, "_start_job", start)
|
||||
client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}]
|
||||
client.calls = calls
|
||||
client.run_post = post
|
||||
return client
|
||||
|
||||
|
||||
def _have(client, box, epsg=5186, center=None):
|
||||
meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg}
|
||||
if center:
|
||||
meta["center"] = list(center)
|
||||
folder = router_mod.terrain.models_dir_of(client.root)
|
||||
folder.mkdir(parents=True, exist_ok=True)
|
||||
(folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8")
|
||||
|
||||
|
||||
def test_center_near_adds_site_ring(posting) -> None:
|
||||
"""가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침."""
|
||||
from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat
|
||||
|
||||
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
|
||||
lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km
|
||||
res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json()
|
||||
assert res["status"] == "accepted"
|
||||
call = posting.calls[-1]
|
||||
x, y = lonlat_to_tm(lon, lat, 5186)
|
||||
r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M
|
||||
assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r))
|
||||
assert call["reset"] is False and call["center"] == (lon, lat)
|
||||
# extent 에도 캐시 가운데 = 둘레가 상자에 들어감
|
||||
got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json()
|
||||
assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M)
|
||||
|
||||
|
||||
def test_center_far_asks_then_reset(posting) -> None:
|
||||
"""5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음)."""
|
||||
from common_util.common_util_crs import tm_to_lonlat
|
||||
|
||||
_have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0))
|
||||
lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km
|
||||
body = {"center": {"lon": lon, "lat": lat}}
|
||||
far = posting.run_post(body).json()
|
||||
assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2)
|
||||
assert "km" in far["message"] and posting.calls == []
|
||||
res = posting.run_post({**body, "reset": True}).json()
|
||||
assert res["status"] == "accepted"
|
||||
call = posting.calls[-1]
|
||||
assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만
|
||||
assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1)
|
||||
|
||||
|
||||
def test_center_other_band_asks_then_moves(posting) -> None:
|
||||
"""다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름."""
|
||||
posting.plan["lines"] = [{"id": "a"}]
|
||||
body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187)
|
||||
ask = posting.run_post(body).json()
|
||||
assert ask == {
|
||||
"status": "band",
|
||||
"epsg": 5186,
|
||||
"center_epsg": 5187,
|
||||
"message": ask["message"],
|
||||
}
|
||||
assert posting.calls == []
|
||||
# 확인 전 extent = 가운데 무시(옛 띠 그대로)
|
||||
assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186
|
||||
posting.run_post({**body, "reset": True})
|
||||
call = posting.calls[-1]
|
||||
assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2
|
||||
# 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레
|
||||
(x0, y0), (x1, y1) = call["points"]
|
||||
box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400]
|
||||
_have(posting, box, epsg=5187, center=(129.1017, 36.8128))
|
||||
got = posting.get(f"{URL}/extent").json()
|
||||
assert got["epsg"] == 5187 and got["covered"] is True
|
||||
|
||||
|
||||
def test_cells_limit_413(posting) -> None:
|
||||
posting.plan["points"].append(
|
||||
{"x": OX + 6000.0, "y": OY + 6000.0}
|
||||
) # 6.7 × 6.7 km = 1,100 만 칸
|
||||
res = posting.run_post({})
|
||||
assert res.status_code == 413 and "상한 500만" in res.json()["detail"]
|
||||
assert posting.calls == []
|
||||
|
||||
|
||||
def test_progress_interrupted_after_restart(client, monkeypatch) -> None:
|
||||
"""도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100."""
|
||||
root = client.root
|
||||
|
||||
async def tile_root(project_id):
|
||||
return root
|
||||
|
||||
monkeypatch.setattr(router_mod, "_tile_root", tile_root)
|
||||
url = f"/api/projects/{PID}/plan/progress"
|
||||
router_mod._JOBS.clear()
|
||||
idle = client.get(url).json()["terrain"]
|
||||
assert idle == {**router_mod.IDLE}
|
||||
|
||||
path = router_mod._job_path(root, "terrain")
|
||||
seen = []
|
||||
|
||||
async def work(report):
|
||||
report(65, "5/5 사업 지형면 만드는 중")
|
||||
seen.append(json.loads(path.read_text("utf-8")))
|
||||
|
||||
async def run():
|
||||
router_mod._start_job(PID, "terrain", work, root)
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
asyncio.run(run())
|
||||
assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록
|
||||
assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록
|
||||
path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐
|
||||
router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔
|
||||
got = client.get(url).json()["terrain"]
|
||||
assert got["running"] is False and got["interrupted"] is True
|
||||
assert got["percent"] == 65 and got["message"] is None and got["error"] is None
|
||||
# 끝난 기록 = 끊김 아님
|
||||
path.write_text(json.dumps({**seen[0], "running": False}), "utf-8")
|
||||
assert client.get(url).json()["terrain"] == {**router_mod.IDLE}
|
||||
|
||||
@@ -17,10 +17,11 @@ def _no_hex(path: str) -> None:
|
||||
|
||||
|
||||
def test_d03_points_parts():
|
||||
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품."""
|
||||
"""591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품(598-2 = `UI_Menu`)."""
|
||||
assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists()
|
||||
ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts")
|
||||
assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts
|
||||
assert "createListRow(" in ts and "createIconButton(" in ts
|
||||
assert "createMapContextMenu(" in _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts")
|
||||
for raw in ('createElement("button")', 'createElement("input")', 'el("input"'):
|
||||
assert raw not in ts
|
||||
|
||||
@@ -35,6 +36,13 @@ def test_d03_parts():
|
||||
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
|
||||
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
|
||||
site = _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
|
||||
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
|
||||
assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts
|
||||
assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
|
||||
for raw in ('createElement("button")', 'createElement("input")'):
|
||||
assert raw not in site, raw
|
||||
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
|
||||
for src in (ts, draw, draw_panel, tools, layers):
|
||||
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
|
||||
@@ -87,10 +95,14 @@ def test_d03_parts():
|
||||
# 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌
|
||||
assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools
|
||||
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
|
||||
assert "createSelectField(" in props and "createFloatPanel(" in props
|
||||
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
|
||||
assert 'createElement("button")' not in props and 'createElement("select")' not in props
|
||||
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
|
||||
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
|
||||
assert (
|
||||
'createElement("select")' not in three and "createSelectField" not in three
|
||||
) # 601 선 고름 걷음
|
||||
assert (
|
||||
"createCheckbox" not in three and "setCorridors(" in three
|
||||
) # 601 노선망 예상 코리도 한 번에
|
||||
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
|
||||
assert dead not in css, dead
|
||||
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
|
||||
|
||||
@@ -113,7 +113,7 @@ def test_de_pages_guard_leave():
|
||||
if name:
|
||||
assert f'discard: () => clearState("{name}", projectId)' in call, path
|
||||
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
|
||||
assert "let savedJson = JSON.stringify({ network, points });" in _src(
|
||||
assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
|
||||
)
|
||||
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
|
||||
겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
|
||||
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
|
||||
겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
|
||||
"""
|
||||
@@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1))
|
||||
first = layers.ensure_cadastral(tmp_path, BOX, EPSG)
|
||||
assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists()
|
||||
assert (
|
||||
len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1
|
||||
)
|
||||
# 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음
|
||||
again = FakeWfs()
|
||||
monkeypatch.setattr(layers, "request_vworld_wfs", again)
|
||||
assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15
|
||||
retry = layers.ensure_cadastral(tmp_path, BOX, EPSG)
|
||||
assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0
|
||||
assert retry["count"] == 15 * 2 + 1
|
||||
assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1
|
||||
# 옛 열쇠(실패 조각 기록 없음) = 통째로
|
||||
key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))
|
||||
key.update(failed=1)
|
||||
key.pop("failed_tiles")
|
||||
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
|
||||
layers.ensure_cadastral(tmp_path, BOX, EPSG)
|
||||
assert again.calls == 1 + 15
|
||||
# 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님)
|
||||
key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])])
|
||||
(tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
|
||||
monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99))
|
||||
kept = layers.ensure_cadastral(tmp_path, BOX, EPSG)
|
||||
assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1
|
||||
|
||||
|
||||
# ── 급경사 ──
|
||||
@@ -149,20 +169,22 @@ def _indices(value) -> np.ndarray:
|
||||
return np.asarray(Image.open(io.BytesIO(raw)))
|
||||
|
||||
|
||||
def test_slope_bands_and_north_up() -> None:
|
||||
def test_slope_values_and_north_up() -> None:
|
||||
"""값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸."""
|
||||
z = _plane(10.0, 37.0)
|
||||
value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0)
|
||||
ratios = [b["ratio"] for b in value["bands"]]
|
||||
assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0]
|
||||
assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만
|
||||
value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0)
|
||||
index = _indices(value)
|
||||
assert value["width"] == value["height"] == 40
|
||||
assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37°
|
||||
steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0)
|
||||
assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4
|
||||
assert value["width"] == value["height"] == 40 and "bands" not in value
|
||||
assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 %
|
||||
assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함
|
||||
assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38
|
||||
assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1
|
||||
cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0)
|
||||
assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸
|
||||
hole = np.ones(z.shape, bool)
|
||||
hole[25:30, 10:20] = False
|
||||
assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all()
|
||||
holed = _indices(layers.slope_values_png(z, hole, 2.0))
|
||||
assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all()
|
||||
|
||||
|
||||
def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
|
||||
@@ -174,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None:
|
||||
y = 450000.0 + np.arange(40) * 2.0
|
||||
np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0])
|
||||
counted = []
|
||||
real = layers.slope_bands_png
|
||||
monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a))
|
||||
real = layers.slope_values_png
|
||||
monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a))
|
||||
first = layers.slope_layer(tmp_path, models, EPSG)
|
||||
assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4
|
||||
assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1
|
||||
|
||||
@@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
|
||||
shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade")
|
||||
slope = layers.slope_layer(tmp_path, models, EPSG, "slope")
|
||||
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
|
||||
assert slope["bands"] and shade["box_m"] == slope["box_m"]
|
||||
assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수
|
||||
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 ·
|
||||
cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
|
||||
"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면
|
||||
5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 +
|
||||
거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 ·
|
||||
도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q
|
||||
@@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
|
||||
([("a.zip", b"not a zip")], "zip 파일을 열지"),
|
||||
([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"),
|
||||
([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"),
|
||||
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"),
|
||||
(_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"),
|
||||
]
|
||||
for upload, text in bad:
|
||||
with pytest.raises(ValueError, match=re.escape(text)):
|
||||
@@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None:
|
||||
assert not list((tmp_path / fr.FOLDER).glob("*.new"))
|
||||
|
||||
|
||||
def test_clip_box_site_5km_and_distance(tmp_path) -> None:
|
||||
"""595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 ·
|
||||
밖뿐 = 거절 + 거리."""
|
||||
site = (231000.0, 450600.0)
|
||||
frame = fr.clip_box(site, BOX)
|
||||
assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음
|
||||
assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪
|
||||
assert fr.clip_box(site, None) == frame
|
||||
got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site)
|
||||
assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐
|
||||
assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴
|
||||
assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500
|
||||
w, s, e, n = got["bounds"]
|
||||
assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4)
|
||||
assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4)
|
||||
far = _shp_files(tmp_path / "far", [LINES[2]])
|
||||
with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"):
|
||||
fr.save_roads(tmp_path, far, frame, EPSG, site)
|
||||
|
||||
|
||||
def test_no_upload_is_not_found(tmp_path) -> None:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
fr.road_layer(tmp_path, BOX, EPSG)
|
||||
@@ -255,7 +276,9 @@ def test_screen_defs() -> None:
|
||||
assert entry and entry.group(2) == "false", key # 처음 값 끔
|
||||
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key
|
||||
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
|
||||
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
|
||||
site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸
|
||||
assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창
|
||||
assert "uploadRoads" in page and "uploadPlanForestRoads(" in page
|
||||
assert "<input" not in page and 'createElement("input")' not in page
|
||||
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
|
||||
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정
|
||||
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`.
|
||||
"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침
|
||||
(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. 갈래 시점 지정은 걷음(요구 463-15 · PLAN 598-3).
|
||||
|
||||
창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 ·
|
||||
돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에.
|
||||
@@ -46,7 +46,7 @@ writeFileSync(process.argv[2], JSON.stringify({
|
||||
...marks(),
|
||||
}));
|
||||
|
||||
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */
|
||||
/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해. */
|
||||
function marks() {
|
||||
const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]);
|
||||
let m = plan.newLine(plan.emptyNetwork(), [0, 0]);
|
||||
@@ -58,17 +58,9 @@ function marks() {
|
||||
// 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해
|
||||
let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y);
|
||||
b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y);
|
||||
// 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래
|
||||
let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y);
|
||||
t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y);
|
||||
const branched = plan.branchFromEnd(t, 1);
|
||||
const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y);
|
||||
return {
|
||||
cut: span(cut), edgeSame: edge === cut, merged: span(merged),
|
||||
branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year),
|
||||
branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]),
|
||||
again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1),
|
||||
lone: plan.branchFromEnd(lone, 1),
|
||||
};
|
||||
}
|
||||
"""
|
||||
@@ -118,14 +110,9 @@ def test_여러_구간_연도(tmp_path: Path) -> None:
|
||||
assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026
|
||||
|
||||
|
||||
def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None:
|
||||
def test_마커_구역_나눔_합침_갈래_해(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로
|
||||
assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음
|
||||
assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음
|
||||
assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해
|
||||
lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]}
|
||||
head, branch = lines[next(k for k in lines if k != "main")]
|
||||
assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점
|
||||
assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다."
|
||||
assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다")
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
|
||||
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.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_PointEdit.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
|
||||
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
|
||||
const Y = 2026;
|
||||
const tm = tmx.tmParams(5187);
|
||||
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
|
||||
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
|
||||
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
|
||||
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
|
||||
const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x');
|
||||
const cleared = pe.setLineName(named, n.lines[0].id, ' ');
|
||||
const added = pe.addPoint(state, [7, 8]);
|
||||
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
|
||||
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
|
||||
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
|
||||
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
|
||||
.map((s) => plan.nameError(s)),
|
||||
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
|
||||
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
|
||||
added: [added.points, added.names, added.network === n],
|
||||
lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)],
|
||||
full: pe.addPoint(full, [1, 1]) === full,
|
||||
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
|
||||
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
|
||||
tmp_path = tmp_path_factory.mktemp("pe")
|
||||
out = tmp_path / "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,
|
||||
) # fmt: skip
|
||||
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_path / "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_name_rule(got: dict) -> None:
|
||||
ok, bad = got["errors"][:2], got["errors"][2:11]
|
||||
assert ok == [None, None] # 빈 이름 · 한글 = 됨
|
||||
assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
|
||||
assert got["errors"][11] is None # 20 자
|
||||
assert "20 자" in got["errors"][12] # 21 자 = 막음
|
||||
|
||||
|
||||
def test_label_blank_falls_back(got: dict) -> None:
|
||||
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
|
||||
|
||||
|
||||
def test_parse_lonlat(got: dict) -> None:
|
||||
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
|
||||
assert ok == [128.5, 36.9]
|
||||
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
|
||||
assert big_lon.startswith("경도") and big_lat.startswith("위도")
|
||||
|
||||
|
||||
def test_add_and_limit(got: dict) -> None:
|
||||
points, names, network_same = got["added"]
|
||||
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
|
||||
assert network_same # 더해도 노선 그대로
|
||||
assert got["full"] # 40 개 = 그대로
|
||||
|
||||
|
||||
def test_rename(got: dict) -> None:
|
||||
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
|
||||
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
|
||||
|
||||
|
||||
def test_move_by_lonlat_drags_node(got: dict) -> None:
|
||||
moved = got["moved"]
|
||||
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
|
||||
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
|
||||
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
|
||||
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
|
||||
|
||||
|
||||
def test_line_name(got: dict) -> None:
|
||||
named, names, cleared, back = got["lineName"]
|
||||
assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선
|
||||
assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로
|
||||
@@ -0,0 +1,150 @@
|
||||
"""598-1 예정노선 고름 층 노드 → 선(토막) → 구역 → 전체 · 토막 지움(요구 463 9 · 12 · 13·16 · 15)
|
||||
— TS `logic/D03_PlanAnalysis_Select.ts`.
|
||||
|
||||
끝 토막 지움 = 끝 점 뺌 · 가운데 토막 지움 = 구역 둘(뒤쪽 = 새 구역 · 갈래 고리 · 조각 따라감) ·
|
||||
사라지는 노드에 갈래 = 까닭 글 · 끌어 고르기 토막 판정 · 지점 ↔ 노드 같은 자리.
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_598_1_plan_select_layers.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Select.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
|
||||
_RUNNER = """
|
||||
import { writeFileSync } from "node:fs";
|
||||
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
|
||||
import * as sel from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js";
|
||||
const Y = 2026;
|
||||
// 본선 (0,0)→(100,0)→(200,0)→(300,0) · 마크 150 → p1 [0,150] p2 [150,300] · 갈래 at 노드 2 (200,0)→(200,50)
|
||||
let n = plan.newLine(plan.emptyNetwork(), [0, 0]);
|
||||
for (const x of [100, 200, 300]) n = plan.appendVertex(n, 0, [x, 0]);
|
||||
n = plan.normalizePieces(plan.splitPiece(plan.normalizePieces(n, Y), "main", 150), Y);
|
||||
n = plan.normalizePieces(plan.setPiecesYear(n, ["p2"], 2028), Y);
|
||||
n = plan.normalizePieces(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]), Y);
|
||||
const show = (net) => typeof net === "string" ? net : ({
|
||||
lines: net.lines.map((l) => [l.id, l.vertices, l.branch]),
|
||||
pieces: plan.normalizePieces(net, Y).pieces.map((p) => [p.id, p.line, p.from_m, p.to_m, p.year]),
|
||||
});
|
||||
const seg = (line, index) => ({ line, index });
|
||||
const view = ([x, y]) => [x, -y];
|
||||
writeFileSync(process.argv[2], JSON.stringify({
|
||||
segs: sel.allSegments(n),
|
||||
range: sel.segmentRange(n, seg("main", 1)),
|
||||
at: [sel.segmentAt(n.lines[0].vertices, 0), sel.segmentAt(n.lines[0].vertices, 100), sel.segmentAt(n.lines[0].vertices, 300)],
|
||||
nodeSeg: [sel.nodeSegment(n, { line: "main", vertex: 1 }), sel.nodeSegment(n, { line: "main", vertex: 3 })],
|
||||
pieces: sel.segmentPieces(n, [seg("main", 1)]).sort(),
|
||||
middle: show(sel.removeSegment(n, seg("main", 0 + 1))),
|
||||
tail: show(sel.removeSegment(n, seg("main", 2))),
|
||||
head: show(sel.removeSegment(n, seg("main", 0))),
|
||||
only: show(sel.removeSegment(n, seg("branch-1", 0))),
|
||||
parentOnly: sel.removeSegments(n, [seg("main", 0), seg("main", 1), seg("main", 2)]),
|
||||
many: show(sel.removeSegments(n, [seg("main", 0), seg("main", 2)])),
|
||||
box: sel.segmentsInBox(n, view, { x0: 90, y0: -10, x1: 210, y1: 10 }, false),
|
||||
cross: sel.segmentsInBox(n, view, { x0: 150, y0: 10, x1: 120, y1: -10 }, true),
|
||||
all: show(sel.removeAll(n)),
|
||||
nodes: sel.nodesAt(n, [200, 0]),
|
||||
moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]),
|
||||
placed: (() => {
|
||||
const out = sel.placeNode(n, [[100, 0], [9, 9]], 0, 1, { kind: "length", length_m: 50, slope_pct: null }, (x) => x * 0.1);
|
||||
return [out.network.lines[0].vertices, out.network.lines[0].nodes, out.points];
|
||||
})(),
|
||||
placeFirst: sel.placeNode(n, [], 0, 0, { kind: "free", length_m: null, slope_pct: null }, () => 0),
|
||||
nodes594: (() => {
|
||||
const r = { kind: "length", length_m: 100, slope_pct: null };
|
||||
const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) };
|
||||
const out = sel.removeSegment(withNodes, seg("main", 1));
|
||||
return out.lines.filter((l) => l.id !== "branch-1").map((l) => [l.id, l.nodes]);
|
||||
})(),
|
||||
}));
|
||||
"""
|
||||
|
||||
|
||||
def _run_ts(tmp_path: Path) -> dict:
|
||||
out = tmp_path / "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,
|
||||
) # fmt: skip
|
||||
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_path / "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_토막_자리(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
assert [s["line"] for s in got["segs"]] == ["main"] * 3 + ["branch-1"]
|
||||
assert got["range"] == [100, 200]
|
||||
assert got["at"] == [0, 1, 2] # 꺾임점 위 = 뒤 토막 · 끝 = 마지막
|
||||
assert got["nodeSeg"] == [{"line": "main", "index": 1}, {"line": "main", "index": 2}]
|
||||
assert got["pieces"] == ["p1", "p2"] # 100~200 토막에 마크 150 = 두 구간
|
||||
|
||||
|
||||
def test_가운데_토막_지움_구역_둘(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
lines = {i: (v, b) for i, v, b in got["middle"]["lines"]}
|
||||
assert lines["main"] == ([[0, 0], [100, 0]], None) # 앞쪽 = 같은 id
|
||||
back = next(k for k in lines if k not in ("main", "branch-1"))
|
||||
assert lines[back] == ([[200, 0], [300, 0]], None) # 뒤쪽 = 새 구역(본선)
|
||||
assert lines["branch-1"][1] == {"line": back, "vertex": 0} # 갈래 고리가 따라감
|
||||
pieces = {(p[1], p[2], p[3]): (p[0], p[4]) for p in got["middle"]["pieces"]}
|
||||
assert pieces[("main", 0, 100)] == ("p1", 2026) # 앞 조각 id 지님
|
||||
assert pieces[(back, 0, 100)][1] == 2028 # 뒤 조각 해 그대로
|
||||
|
||||
|
||||
def test_끝_토막_지움_끝_점_뺌(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
tail = {i: v for i, v, _ in got["tail"]["lines"]}
|
||||
assert tail["main"] == [[0, 0], [100, 0], [200, 0]]
|
||||
head = {i: (v, b) for i, v, b in got["head"]["lines"]}
|
||||
assert head["main"][0] == [[100, 0], [200, 0], [300, 0]]
|
||||
assert head["branch-1"][1] == {"line": "main", "vertex": 1} # 갈래 차례 −1
|
||||
assert [p[2:4] for p in got["head"]["pieces"] if p[1] == "main"] == [[0, 50], [50, 200]]
|
||||
|
||||
|
||||
def test_갈래_붙은_노드_막음_하나뿐_구역_지움(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
only = got["only"]["lines"]
|
||||
assert [i for i, _, _ in only] == ["main"] # 토막 하나뿐 = 구역 지움
|
||||
assert got["parentOnly"] == "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."
|
||||
many = {i: v for i, v, _ in got["many"]["lines"]}
|
||||
assert many["main"] == [[100, 0], [200, 0]] # 뒤부터 지움 = 차례 안 꼬임
|
||||
|
||||
|
||||
def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None:
|
||||
got = _run_ts(tmp_path)
|
||||
assert got["box"] == [{"line": "main", "index": 1}] # 창 = 두 끝 모두 안
|
||||
assert got["cross"] == [{"line": "main", "index": 1}] # 걸침 = 상자와 만남
|
||||
assert got["all"] == {"lines": [], "pieces": []}
|
||||
assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌
|
||||
assert got["moved"] == [[210, 5], [5, 5]]
|
||||
vertices, nodes, points = got["placed"]
|
||||
assert vertices == [[0, 0], [50, 0], [200, 0], [300, 0]] # 앞 노드 기준 50 m · 뒤 노드 그대로
|
||||
assert nodes[1] == {"kind": "length", "length_m": 50, "slope_pct": None} and nodes[0] is None
|
||||
assert points == [[50, 0], [9, 9]] # 그 자리 지점 같이
|
||||
assert got["placeFirst"] == "첫 노드는 방식이 없습니다."
|
||||
rec = {"kind": "length", "length_m": 100, "slope_pct": None}
|
||||
assert got["nodes594"] == [
|
||||
["main", [None, rec]],
|
||||
["line-2", [None, rec]],
|
||||
] # 노드 기록 같이 · 뒤쪽 첫 = 없음
|
||||
@@ -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"
|
||||
@@ -0,0 +1,172 @@
|
||||
"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 +
|
||||
`logic/D03_PlanAnalysis_SlopeBands.ts`.
|
||||
|
||||
1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 ·
|
||||
범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위.
|
||||
TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이).
|
||||
|
||||
./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
|
||||
from E07_StabArea.E07_StabArea_Engine import slope_rad
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts"
|
||||
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
|
||||
CELL = 2.0
|
||||
OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS
|
||||
|
||||
|
||||
def _hills(n: int = 300) -> np.ndarray:
|
||||
"""0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나)."""
|
||||
y, x = np.mgrid[0:n, 0:n] * CELL
|
||||
return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x
|
||||
|
||||
|
||||
_RUNNER = """
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js";
|
||||
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
|
||||
const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS };
|
||||
// 인자 null = 기본 범위
|
||||
for (const [key, [name, ...args]] of Object.entries(cases))
|
||||
out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a)));
|
||||
writeFileSync(process.argv[3], JSON.stringify(out));
|
||||
"""
|
||||
|
||||
C = "--draw-map-landslide-3"
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def hills() -> dict:
|
||||
z = _hills()
|
||||
valid = np.ones(z.shape, bool)
|
||||
valid[100:140, 100:180] = False
|
||||
value = layers.slope_values_png(z, valid, CELL)
|
||||
deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL))
|
||||
known = np.isfinite(deg)
|
||||
old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG]
|
||||
return {"counts": value["counts"], "known": int(known.sum()), "old": old}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict:
|
||||
tmp = tmp_path_factory.mktemp("slope_bands")
|
||||
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")
|
||||
band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731
|
||||
top = [0] * 250 + [7]
|
||||
cases = {
|
||||
"default_ratios": ["bandRatios", hills["counts"], None],
|
||||
"user_ratios": [
|
||||
"bandRatios",
|
||||
hills["counts"],
|
||||
[band(10, 15), band(15, 20), band(20, 250), band(0, 10)],
|
||||
],
|
||||
"top": ["bandRatios", top, [band(83.9, 250)]],
|
||||
"top_closed": ["bandRatios", top, [band(83.9, 249)]],
|
||||
"ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]],
|
||||
"overlap": ["checkBands", [band(10, 20), band(15, 30)]],
|
||||
"empty_value": ["checkBands", [band(None, 20)]],
|
||||
"out_of_range": ["checkBands", [band(10, 260)]],
|
||||
"upside_down": ["checkBands", [band(30, 20)]],
|
||||
"none": ["checkBands", []],
|
||||
"bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]],
|
||||
"parse_broken": ["parseBands", "{oops"],
|
||||
"parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])],
|
||||
"parse_ok": ["parseBands", json.dumps([band(10, 20)])],
|
||||
"deg": ["pctToDeg", 100],
|
||||
}
|
||||
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_칸_수_합은_유효_칸(hills):
|
||||
assert sum(hills["counts"]) == hills["known"]
|
||||
assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1
|
||||
|
||||
|
||||
def test_기본은_지식DB_15_35_40도(r):
|
||||
assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [
|
||||
(26.8, 70.0),
|
||||
(70.0, 83.9),
|
||||
(83.9, 250),
|
||||
]
|
||||
|
||||
|
||||
def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills):
|
||||
"""1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안."""
|
||||
for new, old in zip(r["default_ratios"], hills["old"], strict=True):
|
||||
assert old > 0.02
|
||||
assert new == pytest.approx(old, abs=0.002)
|
||||
|
||||
|
||||
def test_사용자_범위_넷(r):
|
||||
ratios = r["user_ratios"]
|
||||
assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0)
|
||||
|
||||
|
||||
def test_250_퍼센트_이상은_끝_범위(r):
|
||||
assert r["top"] == [1.0]
|
||||
assert r["top_closed"] == [0.0]
|
||||
|
||||
|
||||
def test_범위_막음(r):
|
||||
assert r["ok"] is None
|
||||
assert "겹칩니다" in r["overlap"]
|
||||
assert "비었습니다" in r["empty_value"]
|
||||
assert "0~250" in r["out_of_range"]
|
||||
assert "아래가 위보다" in r["upside_down"]
|
||||
assert "하나는" in r["none"]
|
||||
assert "색" in r["bad_color"]
|
||||
|
||||
|
||||
def test_깨진_취향은_기본(r):
|
||||
assert r["parse_broken"] == r["DEFAULT"]
|
||||
assert r["parse_overlap"] == r["DEFAULT"]
|
||||
assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}]
|
||||
assert r["deg"] == pytest.approx(45.0)
|
||||
@@ -65,6 +65,17 @@ export function satelliteTileSource(projectId: string): SourceSpecification {
|
||||
};
|
||||
}
|
||||
|
||||
/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */
|
||||
export function perfTime<T>(name: string, run: () => T): T {
|
||||
if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run();
|
||||
const start = performance.now();
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
performance.measure(name, { start, end: performance.now() });
|
||||
}
|
||||
}
|
||||
|
||||
export function createGlMap<K extends string>(opts: {
|
||||
/** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */
|
||||
specs: () => GlSpec<K>[];
|
||||
@@ -190,8 +201,8 @@ export function createGlMap<K extends string>(opts: {
|
||||
camera(center, zoom, bearingDeg) {
|
||||
if (!map) return;
|
||||
if (ready && themeKey() !== theme) apply();
|
||||
map.jumpTo({ center, zoom, bearing: bearingDeg });
|
||||
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
|
||||
perfTime("gl:jumpTo", () => map!.jumpTo({ center, zoom, bearing: bearingDeg }));
|
||||
if (ready) perfTime("gl:redraw", () => map!.redraw()); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
|
||||
},
|
||||
resize: () => map?.resize(),
|
||||
};
|
||||
|
||||
@@ -102,6 +102,13 @@ export const ui_locales_de = {
|
||||
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
|
||||
DE_Reg_SiteSource: ["출처", "Source"],
|
||||
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."],
|
||||
DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"],
|
||||
DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"],
|
||||
DE_Reg_FindBusy: ["찾는 중…", "Searching…"],
|
||||
DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"],
|
||||
DE_Reg_FindFail: ["찾지 못함", "Search failed"],
|
||||
DE_Reg_FindAddress: ["주소", "Address"],
|
||||
DE_Reg_FindPlace: ["장소", "Place"],
|
||||
DE_Reg_MapType: ["바탕 지도", "Base map"],
|
||||
DE_Reg_MapNormal: ["일반", "Map"],
|
||||
DE_Reg_MapSatellite: ["위성", "Satellite"],
|
||||
|
||||
Reference in New Issue
Block a user