32 Commits
Author SHA1 Message Date
eomsangdonandClaude Sonnet 5.5 5f90f6c0bb fix(D 예정노선): 첫 열림 지점 칸 [추가] 켬 — 지도 가운데가 처음 생길 때 칸 다시 그림 · Page 줄 수 698 → 647 (받기 지켜보기 · 둘레 상자 → UI_Terrain) (PLAN 597 · 요구 463-7)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:27:06 +09:00
eomsangdon 8e8812c56d Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-30 16:23:25 +09:00
eomsangdonandClaude Sonnet 5.5 157688a435 feat(D 예정노선): 지도 노선 선에 검은 테 (PLAN 600-1 · 요구 463-24)
- paintNetwork 선 그리기 = strokeCased(검은 테 + 차례 색 선) · 고름(active) · 흐림(dim) · 갈래 굵기 그대로
- 지킴 시험 551_2 에 paintNetwork 가 strokeCased · routeColor 를 쓰는지 한 줄 추가

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:10:18 +09:00
eomsangdon 4f69a9bd67 Merge remote-tracking branch 'origin/sub_laptop_6' into main_laptop_1 2026-10-30 16:06:13 +09:00
eomsangdonandClaude Opus 5.5 1d2e8f74e9 fix(D 예정노선): 그리기 칸 길이 · 기울기 두 열이 좁은 왼쪽 판에서 넘침 — 칸이 줄어들게 (PLAN 599-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 16:05:28 +09:00
eomsangdon 0a45253e97 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-30 16:01:41 +09:00
eomsangdonandClaude Sonnet 5.5 f8271e7559 feat(D 예정노선): 검은 테 두껍게 · 줌아웃 한도 지킴 시험 (PLAN 600-1 · 600-3 · 요구 463-24)
- strokeCased 테 +4 px · casing 투명도 0.9 — 밝은 회색 바탕(위성 끔 · 음영 켬)에서 미리보기 대비 3 : 1 이상
- 새 시험 test_600_3_zoom_limit — 휠 하한 인자 기본 0.5 · B05 는 인자 안 줌 · 예정노선만 600 km

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 16:00:43 +09:00
eomsangdonandClaude Opus 5.5 bb791d3c67 refactor(D 예정노선): 갈래 시점 지정 branchFromEnd 걷음 — 단추 걷음(598-3) 뒤 · 닿음 = 긋기 끝에서 저절로 갈래 (PLAN 599 · 요구 463-15)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:31:15 +09:00
eomsangdon ef99ba6806 Merge remote-tracking branch 'origin/dev' into sub_laptop_6 2026-10-30 15:30:13 +09:00
eomsangdonandClaude Opus 5.5 bcc6328a89 feat(D 예정노선): 지도 도움말 줄 — 다시 긋기 · 닿음 = 갈래 · 노드 · 선 · 구역 · 전체 고름 · 우클릭 메뉴 · 마커 그리기 칸 (PLAN 599 · 598 이음)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:30:07 +09:00
eomsangdon 8244d3ed96 Merge remote-tracking branch 'origin/sub_laptop_5' into main_laptop_1 2026-10-30 15:29:17 +09:00
eomsangdon 99b47cfe40 Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1 2026-10-30 15:29:16 +09:00
eomsangdon 844e7e8e7c Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-30 15:29:15 +09:00
eomsangdonandClaude Opus 5.5 4415f422f4 Merge remote-tracking branch 'origin/dev' into sub_laptop_5 — 598 합치기 풂(PLAN 598 · 요구 463)
- UI_Points: 우클릭 메뉴 = UI_Menu 그대로 · sub4 597-2 칸 몫([추가] · 펼침 이름 · 경위도 · nameError · 칸 설명 글 걷음) 살림 · 핀 이름 그림(pointLayer names) · dev 쪽 openMenu · mount 걷음(지점 놓기 노드 붙음 = UI_Menu place)
- UI_Pieces: 구역 지움 = edit.dropRegion(line.id)(598 이 dropLine 을 dropRegion 으로 바꿈)
- UI_Page: 지점 한 걸음 stepPoints — 위치(site) 같이 실음(596-2 Snap) · 700 줄
- UI_Edit: 고름 강조 선 = strokeCased 검은 테(600-1 · 밝은 바탕 대비)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:22:21 +09:00
eomsangdonandClaude Opus 5.5 4a1f230b42 chore(D 예정노선): 급경사 다시 칠하는 동안 칠하기 ms = -1 표시(검증 창구 · PLAN 603)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:20:25 +09:00
eomsangdon c6b13098b8 Merge remote-tracking branch 'origin/sub_laptop_7' into main_laptop_1 2026-10-30 15:17:24 +09:00
eomsangdon 911fe0c0f1 Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_5 2026-10-30 15:16:39 +09:00
eomsangdonandClaude Sonnet 5.5 da473b041b feat(D 예정노선): 줌아웃 한도 인자 · 느린 조작 시간 표식 (PLAN 600-3 · 600-4 · 요구 463-27)
- 휠 부품 하한 인자 minScale(안 주면 0.5 = B05 그대로) · 예정노선 지도만 화면 폭 약 600 km(한반도)까지 뺌
- performance 표식 perfTime — 지도 다시 그림 · GL 카메라 · 바탕 · 위 그림 · 마우스 움직임(__aisloPerf 켰을 때만)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 15:15:08 +09:00
eomsangdon ad0eb31214 Merge remote-tracking branch 'origin/sub_laptop_4' into main_laptop_1 2026-10-30 15:14:52 +09:00
eomsangdon fdea248030 Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1 2026-10-30 15:14:51 +09:00
eomsangdonandClaude Opus 5.5 edc20df849 feat(D 예정노선): 급경사 범위 옵션 모달 — 기울기 % 값 그림 · 1 % 칸 수 · 화면 칠하기 · 범례 ⚙ (PLAN 603 · 요구 466~468)
- 서버: 15 · 35 · 40° 단계 PNG 길을 걷고 기울기 % 값 그림 한 장(칸 = % 내림 0~250 · 빈 칸 255) + 1 % 칸 수 · 파생 저장본 판 2(열쇠 = 격자 지문)
- 화면: 고른 범위대로 값 그림을 칠함(서버 왕복 없음) · 범례 비율 = 칸 수로 바로 셈 · 범례 급경사 줄 ⚙ → 모달(아래 % · 위 % · 색 · ° 환산 · 범위 추가 · 지움 · 기본값 · 빈 값 · 겹침 막음)
- 기본 = 지식DB 15 · 35 · 40° 의 % 26.8 · 70.0 · 83.9 · 기억 = 화면 취향 aislo:pref:d03.slope-bands(계정)
- 시험: 569_1 급경사 시험을 값 그림 · 칸 수로 고침, 581_1 은 bands 대신 counts(옛 단계 응답이 없어짐) · 새 603 시험(칸 수 합 = 유효 칸 · 기본 비율 = 옛 15 · 35 · 40° 비율)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:12:27 +09:00
eomsangdon 7427a19246 Merge remote-tracking branch 'origin/dev' into sub_laptop_4 — DrawPaint import 정리 2026-10-30 15:12:19 +09:00
eomsangdonandClaude Opus 5.5 ed7f7fa674 feat(D 예정노선): 노드 판 — 방식 · 값 고침(앞 노드 기준 다시 놓음 · 뒤 노드 그대로 · 어긋남 ⚠) · [지점으로] · [다시 긋기] 이음 (PLAN 598-4 · 요구 463-11 · 464)
- logic/Select placeNode — 지점 자리 노드면 지점 같이 · 첫 노드 = 까닭 글
- UI_Props 노드 판 = 599-1 방식 칸(createDrawKindFields) · 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때
- 판 · 메뉴 [다시 긋기] = draw.respan(599-3)
- 시험 598_1 placeNode 줄

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 15:02:48 +09:00
eomsangdonandClaude Opus 5.5 b44066da61 fix(D 예정노선): 토막 지움이 노드 기록(594-1 nodes)도 같이 자름 — 뒤쪽 첫 기록 = 없음 (PLAN 598-1)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:59:19 +09:00
eomsangdon 6f7b04d25b Merge remote-tracking branch 'origin/dev' into sub_laptop_4 — Api 는 dev 쪽 · DrawPaint import 둘 다 · Page 구역 목록 인자 + 재기 인자 2026-10-30 14:58:58 +09:00
eomsangdon 2e6aa17648 Merge remote-tracking branch 'origin/dev' into sub_laptop_5
충돌 = UI_Page.ts 메뉴 블록 옆 주석(596-4) — 둘 다 살림
2026-10-30 14:58:32 +09:00
eomsangdonandClaude Sonnet 5.5 10402b3ee1 feat(예정노선): 구역 목록 색 띠 · 이름 칸 · 연도 고름 · 지움 · 측점 글 작게 · 지점 이름 규칙은 Plan.nameError (PLAN 597-2 · 597-3)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 14:57:51 +09:00
eomsangdon 15ae6b98dc Merge remote-tracking branch 'origin/sub_laptop_8' into sub_laptop_5 2026-10-30 14:54:36 +09:00
eomsangdon 3e955e2c85 Merge remote-tracking branch 'origin/sub_laptop_2' into sub_laptop_5 2026-10-30 14:52:43 +09:00
eomsangdon 6631604504 Merge remote-tracking branch 'origin/sub_laptop_1' into sub_laptop_5 2026-10-30 14:52:41 +09:00
eomsangdonandClaude Opus 5.5 e68744c3eb fix(D 예정노선): 노드 위 핀 누름 = 노드 고름 · 끌면 지점 같이 (PLAN 598-3)
- 핀이 노드 자리에 있으면 핀 겹이 누름을 안 먹음 — 노드 판 · 넓힘이 가능

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:51:48 +09:00
eomsangdonandClaude Opus 5.5 ff49f2c8a2 feat(D 예정노선): 고름 층 노드 → 선 → 구역 → 전체 · 판 하나 재활용 · 우클릭 메뉴 · 지점 ↔ 노드 붙음 (PLAN 598-1~3 · 요구 463)
- 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리
- 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만)
- UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt)
- UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음
- 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔)
- UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear)
- key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 14:47:39 +09:00
34 changed files with 2467 additions and 716 deletions
+6 -5
View File
@@ -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();
// 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다.
+3 -2
View File
@@ -179,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> =>
+19 -33
View File
@@ -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: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로
@@ -215,39 +212,28 @@ def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]:
# ── 급경사 ──────────────────────────────────────────────
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],
}
@@ -269,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"]
+1 -1
View File
@@ -320,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)
@@ -18,8 +18,8 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
PLAN_STATION_INTERVAL_M,
@@ -43,7 +43,7 @@ export function strokeCased(
dash: number[] = [],
): void {
context.setLineDash(dash);
context.lineWidth = width + 3;
context.lineWidth = width + 4;
context.strokeStyle = planColors().casing;
context.stroke();
context.lineWidth = width;
@@ -129,8 +129,8 @@ export function paintNetwork(
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
const color = index === active ? colors.active : routeColor(index);
const width = line.branch ? 2.5 : 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
vertices.forEach((v, i) => {
@@ -138,7 +138,7 @@ export function paintNetwork(
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
strokeCased(context, color, width); // 밝은 바탕 대비 = 검은 테(600-1 · 463-24)
};
if (dim?.line === index) {
path(line.vertices.slice(0, dim.from + 1));
+274 -261
View File
@@ -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) => {
+55 -1
View File
@@ -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);
@@ -357,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 };
+32 -16
View File
@@ -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";
@@ -55,11 +56,14 @@ export function planColors(): {
active: themeColor("--draw-map-sketch", "#ffe94d"),
warn: themeColor("--draw-map-warn", "#ff5a4d"),
measure: themeColor("--draw-map-measure", "#ffffff"),
casing: "rgba(0, 0, 0, 0.85)",
casing: "rgba(0, 0, 0, 0.9)",
halo: "rgba(0, 0, 0, 0.65)",
};
}
/** 줌아웃 한도(600-3) — 화면 폭 약 600 km(한반도 · 위성 z6). 휠 하한 배율 = 이 폭이 화면에 드는 값(B05 는 0.5 그대로). */
export const MAP_MAX_VIEW_WIDTH_M = 600_000;
/** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */
const FAKE_MARGIN_M = 600;
@@ -202,18 +206,19 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
hint: {
lines: [
"[긋기] 뒤 지점 · 선 끝 · 선 위 누름 = 시작",
"누름 = 점 찍기",
"누름 = 점 찍기 · 다른 구역에 닿으면 그 점의 갈래로 끝",
"Enter = 긋기 끝",
"Esc = 긋기 취소",
"Backspace = 마지막 점 빼기",
"선 누름 = 구간 고름 · 다시 누름 = 선 → 같은 해로 넓힘",
"Shift+누름 = 구간 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김",
"[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝",
"누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘",
"Shift+누름 = 선 더함 · 뺌",
"빈 곳 오른쪽으로 끌기 = 창 안 선 · 왼쪽으로 = 걸친 선까지",
"고른 선 위 끌기 = 꺾임점 더해 옮김 · 노드 끌기 = 옮김",
"그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔",
"꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 꺾임점 · 마크 지움",
"Delete = 고른 선 · 마크 지움",
"마크 끌기 = 옮김 · 선 밖으로 = 지움(앞 구간과 합침)",
"오른쪽 누름 = 메뉴(긋기 · 지점 · 노드 지움 · 연도 구역 나눔)",
"Delete = 고른 노드 · 선 · 구역 · 전체 · 마크 지움",
"가운데 끌기 = 옮기기",
"휠 = 확대 · 축소",
"Ctrl+휠 = 돌리기",
@@ -399,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;
}
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
@@ -448,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?.();
@@ -495,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,
});
@@ -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,
);
},
};
}
+57 -80
View File
@@ -41,7 +41,6 @@ import {
savePlanNetwork,
startTerrain,
type PlaceHit,
type PlanJob,
type PlanNetworkView,
type PlanSurface,
type TerrainAim,
@@ -57,10 +56,19 @@ 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 {
aroundSite,
boxOfLonLat,
errorText,
nearSite,
watchTerrain,
type Site,
} from "./D03_PlanAnalysis_UI_Terrain";
import {
emptyNetwork,
fromServer,
@@ -70,27 +78,12 @@ import {
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
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 = 5;
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
interface Site {
lon: number;
lat: number;
address: string | null;
}
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
interface Snap {
network: PlanNetwork;
@@ -103,10 +96,6 @@ interface Draft extends Snap {
version: number;
}
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = currentProjectId();
let editable = false;
@@ -142,6 +131,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,
@@ -150,6 +141,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(),
@@ -160,8 +156,20 @@ 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,
editable: () => editable,
yearOptions: props.yearOptions,
commit,
});
const pieces = createPlanPieces({ network: () => network, edit });
const measure = createPlanMeasure(
() => z,
() => network,
@@ -171,9 +179,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
names: () => pointNames,
epsg: () => epsg,
draw,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames, site }),
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
center: () => viewMid,
show: (nextPoints, next) => {
[points, network] = [nextPoints, next];
@@ -182,11 +188,22 @@ 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) {
const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight];
const had = !!viewMid;
viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null;
if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬
},
};
const map = createPlanMap({
@@ -254,16 +271,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const moved = (): boolean => !!site && JSON.stringify(site) !== JSON.stringify(savedSite);
/** 받기 · 범위에 늘 지금 기준점(캐시)을 실음 — 받은 범위에서 멀면 서버가 물음(저장된 자리로 되돌아올 때도). */
const center = (): TerrainAim["center"] => (site ? { 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 around = (at: Site | null): Vertex[] => aroundSite(epsg, at);
const fitTo = (box: Vertex[]): void => {
if (!box.length) return;
map.update({ fit: box });
@@ -321,7 +329,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,
@@ -330,6 +338,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
...(measuring ? [measure.layer] : []),
viewWatch,
marks.pins,
menu.layer,
];
/** 점들이 받은 범위 안인가. */
@@ -341,10 +350,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) 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);
const extra = nearSite(epsg, site, [...points, ...allVertices()]);
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
const key = JSON.stringify([have, extra]);
if (key === lastTried) return;
@@ -360,7 +366,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
redo: editable && redoStack.length > 0,
view3d: !!surface && network.lines.some((line) => line.vertices.length > 1),
measure: measuring,
yearMark: editable && network.lines.some((line) => line.vertices.length > 1),
});
map.redraw();
props.render();
@@ -438,16 +443,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
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]);
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
@@ -460,35 +456,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
async function watch(): Promise<PlanJob | null> {
busy = true;
circle.set(null, "지형");
circle.root.hidden = false;
refresh();
try {
for (;;) {
if (!root.isConnected) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) {
const cut = !job.error && (job.interrupted || job.percent < 100);
if (job.error) showToast(job.error, "error");
return cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
busy = false;
circle.root.hidden = true;
refresh();
}
}
const watch = () =>
watchTerrain({
projectId,
alive: () => root.isConnected,
circle,
setBusy: (on) => {
busy = on;
refresh();
},
});
async function loadSurface(): Promise<void> {
try {
@@ -630,7 +607,7 @@ 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;
+173 -45
View File
@@ -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);
}
+36 -60
View File
@@ -3,32 +3,34 @@
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 ·
* 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝.
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 ·
* 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
* 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위).
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu";
import { createListRow } from "@ui/ui_template_list_row";
import { pointColor } from "@ui/ui_template_palette";
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,
pointNameError,
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";
@@ -41,7 +43,6 @@ export interface PlanPointsParams {
names: () => string[];
network: () => PlanNetwork;
epsg: () => number;
draw: PlanDraw;
/** 한 걸음 — 지점 · 이름 · 노선 함께. */
commit: (points: Vertex[], names: string[], network: PlanNetwork) => void;
/** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */
@@ -59,8 +60,12 @@ 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 {
@@ -82,8 +87,6 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
let openIndex: number | null = null; // 펼친 줄(하나만)
let syncEditor: (() => void) | null = null;
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
const menu = createMapContextMenu("d03-plan");
let menuAt: [number, number] = [0, 0];
let drag: {
index: number;
points: Vertex[];
@@ -125,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, params.names);
pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false);
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);
@@ -228,11 +210,11 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined),
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = pointNameError(text);
const error = nameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
@@ -332,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,
};
}
+253 -86
View File
@@ -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,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,
);
},
});
}
+27 -37
View File
@@ -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,14 +160,6 @@
justify-content: flex-start;
}
.d03-plan__piece[role="button"] {
cursor: pointer;
}
.d03-plan__piece-year {
font-weight: 600;
}
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
@@ -223,11 +187,37 @@
.d03-plan__draw-fields {
display: grid;
grid-template-columns: 1fr 1fr;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--spacing-8);
align-items: start;
}
.d03-plan__draw-fields .ui-field {
margin: 0;
min-width: 0;
}
.d03-plan__draw-fields .ui-input-unit,
.d03-plan__draw-fields .ui-input {
width: 100%;
min-width: 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,94 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Terrain.ts
* D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클).
* 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import type { createProgressCircle } from "@ui/ui_template_progress";
import { fetchPlanProgress, type PlanJob } from "./D03_PlanAnalysis_Api";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm";
const POLL_MS = 1000;
/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
export interface Site {
lon: number;
lat: number;
address: string | null;
}
export const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
export const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
export function aroundSite(epsg: number, 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],
];
}
/** 경위도 상자 [경도1, 위도1, 경도2, 위도2] → 작업 좌표 두 점(없음 · 띠 모름 = 빈 목록). */
export function boxOfLonLat(epsg: number, bounds: number[] | null | undefined): Vertex[] {
const tm = tmParams(epsg);
if (!tm || !bounds) return [];
return [lonLatToTm(tm, bounds[0], bounds[1]), lonLatToTm(tm, bounds[2], bounds[3])];
}
/** 기준점 둘레 상자 가운데에서 `COVER_MAX_M` 안인 점만 남김(기준점 없음 = 모두). */
export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[] {
const [lo, hi] = aroundSite(epsg, at);
if (!lo) return list;
return list.filter(
([x, y]) => Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M,
);
}
export interface WatchParams {
projectId: string;
alive: () => boolean;
circle: ReturnType<typeof createProgressCircle>;
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
setBusy: (on: boolean) => void;
}
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
const { projectId, alive, circle, setBusy } = params;
setBusy(true);
circle.set(null, "지형");
circle.root.hidden = false;
try {
for (;;) {
if (!alive()) return null;
const job = (await fetchPlanProgress(projectId)).terrain;
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) {
const cut = !job.error && (job.interrupted || job.percent < 100);
if (job.error) showToast(job.error, "error");
return cut ? { ...job, error: INTERRUPTED } : job;
}
await sleep(POLL_MS);
}
} catch (error) {
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally {
circle.root.hidden = true;
setBusy(false);
}
}
@@ -2,7 +2,7 @@
* D03_PlanAnalysis_Plan.ts
* D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 ·
* 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) ·
* 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
* 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) ·
* 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`).
* DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`).
* [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음.
@@ -659,27 +659,3 @@ export function piecesInBox(
})
.map((piece) => piece.id);
}
/** 갈래 시점 지정(585-3) — 본선 한 끝이 다른 선 꺾임점에 닿았으면 그 점을 시점(No.0)으로 그 선의 갈래로 ·
* 머리가 다른 선 꼬리면 한 선으로 이음(연속 구간 = 측점 차례). 닿은 끝 없음 · 이미 갈래 = 까닭 글. */
export function branchFromEnd(network: PlanNetwork, line: number): PlanNetwork | string {
const entry = network.lines[line];
if (!entry || entry.vertices.length < 2) return "선을 고르세요.";
if (entry.branch) return "이미 갈래입니다 — 갈라지는 점이 시점입니다.";
const own = family(network, entry.id);
const touches = (point: Vertex): boolean =>
network.lines.some(
(other) => !own.has(other.id) && other.vertices.some((v) => samePoint(v, point)),
);
if (touches(entry.vertices[0])) {
const next = attachHead(network, line);
if (next !== network) return next;
}
if (!touches(entry.vertices[entry.vertices.length - 1]))
return "다른 선에 닿은 끝이 없습니다 — 끝을 다른 선 위에 놓고 지정하세요.";
const turned = reverseLine(network, line);
if (typeof turned === "string") return turned;
const index = turned.lines.findIndex((other) => other.id === entry.id);
const next = attachHead(turned, index);
return next === turned ? "다른 선에 닿은 끝이 없습니다." : next;
}
@@ -1,9 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임.
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴.
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
@@ -11,15 +10,6 @@ import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
export const MAX_POINTS = 40;
export const POINT_NAME_MAX = 20;
const BAD_CHARS = /[\\/:*?"<>|]/;
/** 이름 까닭 글 — 쓸 수 있으면 null. */
export function pointNameError(name: string): string | null {
if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`;
if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다';
return null;
}
/** 보이는 이름 — 빈 값 = 「지점 N」. */
export function pointLabel(names: string[], index: number): string {
@@ -74,3 +64,14 @@ export function movePointTo(
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);
}
@@ -183,3 +183,10 @@ def test_map_drawing_colors_fixed_and_readable_on_dark_map():
lum = _lum(blocks["light"][name])
assert (lum + 0.05) / (ground + 0.05) >= 3, name
assert (lum + 0.05) / 0.05 >= 3, name
def test_paint_network_uses_cased_route_lines():
src = (ROOT / "D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts").read_text(encoding="utf-8")
body = src[src.index("export function paintNetwork") : src.index("export function pointLayer")]
assert "strokeCased(context, color, width)" in body
assert "routeColor(index)" in body
+4 -3
View File
@@ -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
@@ -94,7 +95,7 @@ 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
+15 -13
View File
@@ -1,5 +1,5 @@
"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 ·
겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈.
겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈.
./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q
"""
@@ -169,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:
@@ -194,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
+4 -17
View File
@@ -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("다른 선에 닿은 끝이 없습니다")
+11 -2
View File
@@ -28,6 +28,8 @@ const tm = tmx.tmParams(5187);
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);
@@ -35,10 +37,11 @@ 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) => pe.pointNameError(s)),
.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 },
@@ -75,7 +78,7 @@ def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
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 all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
@@ -109,3 +112,9 @@ def test_move_by_lonlat_drags_node(got: dict) -> None:
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]],
] # 노드 기록 같이 · 뒤쪽 첫 = 없음
+31
View File
@@ -0,0 +1,31 @@
"""600-3 줌아웃 한도 인자 지킴 — 휠 부품 하한은 인자가 있을 때만 바뀜(B05 · 추천 지도는 안 줌 = 0.5 그대로).
./venv/Scripts/python.exe -m pytest resources/tester/test_600_3_zoom_limit.py -q
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731
INPUT = read("B05_Profile/B05_Profile_UI_RouteEdit_Input.ts")
def test_하한_기본은_0_5():
assert re.search(r"const MIN_SCALE = 0\.5;", INPUT)
assert "params.minScale?.() ?? MIN_SCALE" in INPUT
def test_B05_는_하한_인자를_안_줌():
for rel in (
"B05_Profile/B05_Profile_UI_RouteEdit.ts",
"B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts",
):
assert "minScale" not in read(rel), rel
def test_예정노선만_한반도_폭을_줌():
plan = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
assert "MAP_MAX_VIEW_WIDTH_M = 600_000" in plan
assert "minScale:" in plan
@@ -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)
+13 -2
View File
@@ -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(),
};