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>
This commit is contained in:
2026-10-30 15:22:21 +09:00
co-authored by Claude Opus 5.5
25 changed files with 1520 additions and 252 deletions
+9 -2
View File
@@ -5,6 +5,7 @@
* · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1).
* 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 →
* GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음)
* 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점.
* [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로.
* ========================================================================== */
@@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { createGroupProject } from "../A00_Common/group_workflow";
import { navigateTo } from "../A00_Common/router";
import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name";
import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site";
const KOREA_CENTER: [number, number] = [127.8, 36.3];
const DIGITS = 6; // 경위도 소수 자리(약 0.1 m)
@@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): {
}
draw();
// 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래
const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat));
const field = find.search.parentElement!;
field.style.flex = "1 1 12rem";
const head = document.createElement("div");
head.className = "ui-general-block__actions";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root);
head.style.flexWrap = "wrap";
head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field);
const root = document.createElement("div");
root.dataset.d01Site = "";
root.append(head, map.root, where);
root.append(head, find.root, map.root, where);
return { root, value: () => site };
}
+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)
@@ -6,7 +6,7 @@
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor } from "@ui/ui_template_palette";
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -18,6 +18,7 @@ import {
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
import {
planColors,
@@ -128,7 +129,7 @@ export function paintNetwork(
context.lineJoin = "round";
network.lines.forEach((line, index) => {
if (line.vertices.length < 2) return;
context.strokeStyle = index === active ? colors.active : colors.line;
context.strokeStyle = index === active ? colors.active : routeColor(index);
context.lineWidth = line.branch ? 2.5 : 3;
const path = (vertices: ReadonlyArray<Vertex>): void => {
context.beginPath();
@@ -181,7 +182,7 @@ export function paintNetwork(
}
/** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */
export function pointLayer(points: () => Vertex[]): PlanMapLayer {
export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer {
return {
draw(context, map) {
points().forEach((point, i) => {
@@ -206,7 +207,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer {
context.fillStyle = "#ffffff";
context.fill();
});
planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px");
planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px");
});
},
};
+9 -6
View File
@@ -44,7 +44,14 @@ import {
type PlanNode,
type PlanSegment,
} from "./logic/D03_PlanAnalysis_Select";
import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
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";
@@ -305,9 +312,6 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
const picked = sel.get();
context.lineCap = "round";
context.lineJoin = "round";
context.strokeStyle = colors.active;
context.globalAlpha = 0.8;
context.lineWidth = 8;
for (const points of sel.paths()) {
context.beginPath();
points.forEach((point, i) => {
@@ -315,9 +319,8 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit {
if (i) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
strokeCased(context, colors.active, 8); // 밝은 바탕 대비 = 검은 테(600-1)
}
context.globalAlpha = 1;
// 꺾임점 손잡이(고른 것이 든 선)
const lines = pickedLines(network);
context.lineWidth = 2;
@@ -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) => {
+54 -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,38 @@ 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;
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);
@@ -353,7 +391,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 };
+162 -79
View File
@@ -19,10 +19,8 @@
import "./D03_PlanAnalysis_UI_Style.css";
import { ROUTES } from "@config/config_frontend";
import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible";
import { pickFiles } from "@ui/ui_template_drop";
import { askConfirm } from "@ui/ui_template_modal";
import { createProgressCircle } from "@ui/ui_template_progress";
import { createText } from "@ui/ui_template_text";
import {
createButton,
hideLoadingOverlay,
@@ -42,9 +40,12 @@ import {
fetchTerrainExtent,
savePlanNetwork,
startTerrain,
type PlaceHit,
type PlanJob,
type PlanNetworkView,
type PlanSurface,
type TerrainAim,
type TerrainStart,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw";
@@ -60,6 +61,7 @@ import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu";
import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points";
import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces";
import { createPlanProps } from "./D03_PlanAnalysis_UI_Props";
import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import {
emptyNetwork,
fromServer,
@@ -76,16 +78,26 @@ const POLL_MS = 1000;
const INTERRUPTED = "끊김(서버 다시 켜짐 등)";
/** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */
const SITE_RADIUS_M = 1000;
/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */
const COVER_MAX_M = 10000;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 100;
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */
const DRAFT_VERSION = 4;
/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */
const DRAFT_VERSION = 5;
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */
/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */
interface Site {
lon: number;
lat: number;
address: string | null;
}
/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */
interface Snap {
network: PlanNetwork;
points: Vertex[];
pointNames: string[];
site: Site | null;
}
interface Draft extends Snap {
@@ -101,10 +113,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false;
let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2)
let pointNames: string[] = []; // 지점 이름(596 · 597-2)
let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2)
let savedSite: Site | null = null;
let epsg = 0;
let yearStart = new Date().getFullYear(); // 새 선 처음 해
let network: PlanNetwork = emptyNetwork();
let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음
let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음
let undoStack: Snap[] = [];
let redoStack: Snap[] = [];
let surface: PlanSurface | null = null;
@@ -114,6 +128,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음)
let busy = false;
let failed: string | null = null;
let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴)
let measuring = false;
let lastSaveMs: number | null = null;
@@ -128,6 +143,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh();
},
});
const stepPoints = (nextPoints: Vertex[], next: PlanNetwork, nextNames = pointNames): void =>
step({ network: next, points: nextPoints, pointNames: nextNames, site }); // 지점 한 걸음 — 위치 그대로
const edit = createPlanEdit({
network: () => network,
base: () => points,
@@ -136,7 +153,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network = normalizePieces(next, yearStart);
map.redraw();
},
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
commitPoints: stepPoints,
showPoints: (nextPoints, next) => {
[points, network] = [nextPoints, next];
map.redraw();
@@ -154,11 +171,17 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
heightAt: () => z,
epsg: () => epsg,
points: () => points,
addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }),
commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }),
addPoint: (at) => stepPoints([...points, at], network, [...pointNames, ""]),
commitPoints: stepPoints,
respan: draw.respan,
});
const pieces = createPlanPieces({ network: () => network, edit });
const pieces = createPlanPieces({
network: () => network,
edit,
editable: () => editable,
yearOptions: props.yearOptions,
commit,
});
const measure = createPlanMeasure(
() => z,
() => network,
@@ -168,8 +191,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
network: () => network,
names: () => pointNames,
epsg: () => epsg,
commit: (nextPoints, nextNames, next) =>
step({ network: next, points: nextPoints, pointNames: nextNames }),
commit: (nextPoints, nextNames, next) => stepPoints(nextPoints, next, nextNames),
center: () => viewMid,
show: (nextPoints, next) => {
[points, network] = [nextPoints, next];
@@ -214,30 +236,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
main.className = "d03-plan";
main.append(map.root);
const terrainText = createText("", "muted");
terrainText.classList.add("d03-plan__hint");
const retryButton = createButton({
label: "다시 받기",
variant: "filled",
size: 28,
onClick: () => {
const siteBox = createPlanSite({
onPick: (hit) => void pickSite(hit),
onRetry: () => {
[failed, lastTried, ready] = [null, "", true];
void ensureCovered();
void ensureCovered(true);
},
onUpload: (files) => void uploadRoads(files),
});
const terrainCard = createPanelCard("지형", false);
const roadButton = createButton({
label: "임도망 올리기",
variant: "ghost",
size: 28,
onClick: () =>
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => {
void backdrop.uploadRoads(files);
}),
});
roadButton.title =
"임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
terrainCard.append(terrainText, retryButton, roadButton);
const siteCard = createPanelCard("위치", false);
siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel);
const drawCard = createPanelCard("그리기", false);
@@ -255,7 +263,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
actions.append(saveButton, resetButton);
const panel = document.createElement("div");
panel.className = "ui-sidebar-panel d03-plan__panel";
panel.append(terrainCard, pointCard, drawCard, pieceCard, actions);
panel.append(siteCard, pointCard, drawCard, pieceCard, actions);
attachCollapsible(panel);
// 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3)
mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, {
@@ -268,7 +276,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout");
const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices);
const snap = (): Snap => ({ network, points, pointNames });
const snap = (): Snap => ({ network, points, pointNames, site });
/** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */
const moved = (): TerrainAim["center"] =>
site && JSON.stringify(site) !== JSON.stringify(savedSite)
? { lon: site.lon, lat: site.lat }
: null;
/** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */
const around = (at: Site | null): Vertex[] => {
const tm = tmParams(epsg);
if (!at || !tm) return [];
const [x, y] = lonLatToTm(tm, at.lon, at.lat);
return [
[x - SITE_RADIUS_M, y - SITE_RADIUS_M],
[x + SITE_RADIUS_M, y + SITE_RADIUS_M],
];
};
const fitTo = (box: Vertex[]): void => {
if (!box.length) return;
map.update({ fit: box });
map.fit();
};
const dirty = (): boolean => JSON.stringify(snap()) !== savedJson;
const keepDraft = (): void =>
dirty()
@@ -279,10 +307,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
function step(next: Snap): void {
undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()];
redoStack = [];
[network, points, pointNames] = [
[network, points, pointNames, site] = [
normalizePieces(next.network, yearStart),
next.points,
next.pointNames,
next.site,
];
keepDraft();
refresh();
@@ -290,14 +319,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
function commit(next: PlanNetwork): void {
step({ network: next, points, pointNames });
step({ network: next, points, pointNames, site });
}
function undo(): void {
const last = undoStack.pop();
if (!last) return;
redoStack.push(snap());
({ network, points, pointNames } = last);
({ network, points, pointNames, site } = last);
keepDraft();
refresh();
}
@@ -306,7 +335,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const next = redoStack.pop();
if (!next) return;
undoStack.push(snap());
({ network, points, pointNames } = next);
({ network, points, pointNames, site } = next);
keepDraft();
refresh();
}
@@ -336,14 +365,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const inside = (list: Vertex[]): boolean =>
!!have &&
list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]);
const outside = (): boolean => !!have && !inside([...points, ...allVertices()]);
/** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) ·
* 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */
async function ensureCovered(): Promise<void> {
async function ensureCovered(again = false): Promise<void> {
if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return;
const extra = [...points, ...allVertices()];
if (inside(extra)) return; // 받을 때 서버가 여유를 더함
const [lo, hi] = around(site);
const near = ([x, y]: Vertex): boolean =>
!lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M;
const extra = [...points, ...allVertices()].filter(near);
if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함
const key = JSON.stringify([have, extra]);
if (key === lastTried) return;
lastTried = key;
@@ -364,34 +395,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
props.render();
pieces.render();
marks.render();
retryButton.hidden = !editable || busy || !failed;
roadButton.hidden = !editable;
if (!busy)
terrainText.textContent = failed
? `지형 받기 실패 — ${failed}`
: !surface
? editable
? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)."
: "받은 지형 없음"
: outside()
? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다."
: "받은 지형 안";
siteBox.render({
address: site?.address ?? null,
note: failed ? `지형 받기 실패 — ${failed}` : note,
retry: !busy && !!failed,
editable,
});
saveButton.hidden = resetButton.hidden = !editable;
saveButton.disabled = busy || !dirty();
}
async function download(extra: Vertex[]): Promise<void> {
/** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) ·
* 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */
async function download(extra: Vertex[]): Promise<boolean> {
busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음)
const where: TerrainAim = { center: moved() };
try {
await startTerrain(projectId, null, extra);
let answer: TerrainStart = await startTerrain(projectId, null, extra, where);
if (answer.status === "far" || answer.status === "band") {
const band = answer.status === "band";
const ok = await askConfirm({
title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김",
message: band
? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.`
: `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`,
okLabel: "새로 받기",
danger: band,
});
if (!ok) {
busy = false;
refresh();
return false;
}
if (answer.status === "band") clearForBand(answer.center_epsg);
answer = await startTerrain(projectId, null, [], { ...where, reset: true });
}
} catch (error) {
// 이미 도는 일(다른 창)이면 그것을 지켜봄
if (!/이미 돌고/.test(errorText(error))) {
busy = false;
return void showToast(errorText(error), "error");
refresh();
showToast(errorText(error), "error");
return false;
}
}
await follow();
return true;
}
/** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */
function clearForBand(next: number): void {
epsg = next;
[network, points, pointNames] = [emptyNetwork(), [], []];
[undoStack, redoStack, have] = [[], [], null];
map.update({ epsg });
keepDraft();
}
/** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */
async function pickSite(hit: PlaceHit): Promise<void> {
if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info");
epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙)
[note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번
step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } });
ready = true;
fitTo(around(site));
if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가
undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로
redoStack.pop();
fitTo(around(site));
}
/** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */
async function uploadRoads(files: File[]): Promise<void> {
const info = await backdrop.uploadRoads(files);
const tm = tmParams(epsg);
if (!info) return;
const roads: Vertex[] =
tm && info.bounds
? [
lonLatToTm(tm, info.bounds[0], info.bounds[1]),
lonLatToTm(tm, info.bounds[2], info.bounds[3]),
]
: [];
fitTo([...around(site), ...roads]);
}
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
@@ -453,14 +539,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? fromServer(server.lines, server.marks, server.pieces, yearStart)
: emptyNetwork();
points = view.base.points.map((p): Vertex => [p.x, p.y]);
pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""`
pointNames = view.base.points.map((p) => p.name ?? "");
site = savedSite = view.base.site;
if (view.base.epsg) epsg = view.base.epsg;
savedJson = JSON.stringify(snap());
}
function serverBody(): Parameters<typeof savePlanNetwork>[1] {
const { lines, pieces } = network;
return { epsg, points, lines, marks: marksOf(network), pieces };
const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces };
return moved() && site ? { ...body, site } : body;
}
/** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */
@@ -497,7 +585,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
draw.setMode("idle");
showLoadingOverlay();
try {
const body = { epsg, points, lines: [], marks: [], pieces: [] };
const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] };
takeServer(await savePlanNetwork(projectId, body));
clearState("d03-plan", projectId);
undoStack = [];
@@ -530,6 +618,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points,
have: () => have,
busy: () => busy,
site: () => site,
pickSite,
lineNames: () => lineNames(network.lines),
screenOf: map.screenOf,
screenOfLonLat: map.screenOfLonLat,
@@ -576,26 +666,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
editable = !!(await fetchSessionUser())?.is_forest_service;
draw.setEditable(editable);
const view = await fetchPlanNetwork(projectId);
const { epsg: workEpsg, site } = view.base;
if (!workEpsg) {
terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요.";
return;
if (!view.base.epsg) {
note = "기준점이 없습니다 — 주소를 찾아 정하세요.";
ready = true;
return refresh();
}
epsg = workEpsg;
epsg = view.base.epsg;
takeServer(view);
const draft = readState<Draft>("d03-plan", projectId);
if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points))
({ network, points, pointNames } = draft);
const tm = tmParams(epsg);
const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null;
const around: Vertex[] = siteXy
? [
[siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M],
[siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M],
]
: [];
({ network, points, pointNames, site } = draft);
const drawn = [...points, ...allVertices()];
map.update({ fit: drawn.length ? drawn : around, epsg });
map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg });
refresh();
await loadSurface();
refresh();
@@ -606,12 +688,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
await follow();
} else if (editable && !job.error) {
// 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기])
const extent = await fetchTerrainExtent(projectId, null, []);
const extent = await fetchTerrainExtent(projectId, null, [], moved());
ready = true;
if (!extent.covered) await download([]);
else await ensureCovered();
}
} catch (error) {
terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`;
note = `불러오지 못했습니다 — ${errorText(error)}`;
refresh();
}
}
+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);
}
+153 -20
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Points.ts
* D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로.
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`).
* [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음).
* 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) ·
* 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지).
* 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림).
@@ -9,21 +10,30 @@
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createButton } from "@ui/ui_template_elements";
import { createInputField } from "@ui/ui_template_input";
import { createListRow } from "@ui/ui_template_list_row";
import { pointColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import {
moveNodesAt,
nameError,
samePoint,
type PlanNetwork,
type Vertex,
} from "./logic/D03_PlanAnalysis_Plan";
import {
addPoint,
MAX_POINTS,
movePointTo,
parseLonLat,
pointLabel,
renamePoint,
type PointsState,
} from "./logic/D03_PlanAnalysis_PointEdit";
import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm";
import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint";
import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
const MAX_POINTS = 40;
/** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */
const PIN_HEAD_PX = 18;
@@ -61,10 +71,22 @@ export interface PlanPoints {
export function createPlanPoints(params: PlanPointsParams): PlanPoints {
const list = document.createElement("div");
list.className = "d03-plan__points";
const hint = createText("", "muted");
hint.classList.add("d03-plan__hint");
const addButton = createButton({
label: "추가",
iconStart: "plus",
variant: "ghost",
size: 24,
onClick: addAtCenter,
});
addButton.dataset.role = "points-add";
const head = document.createElement("div");
head.className = "d03-plan__points-head";
head.append(addButton);
const panel = document.createElement("div");
panel.append(list, hint);
panel.append(head, list);
let openIndex: number | null = null; // 펼친 줄(하나만)
let syncEditor: (() => void) | null = null;
let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기)
let drag: {
index: number;
points: Vertex[];
@@ -72,7 +94,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
last: [Vertex[], PlanNetwork] | null;
} | null = null;
const name = (i: number): string => `지점 ${i + 1}`;
const name = (i: number): string => pointLabel(params.names(), i);
const state = (): PointsState => ({
points: params.points(),
names: params.names(),
network: params.network(),
});
const commitState = (next: PointsState): void =>
params.commit(next.points, next.names, next.network);
/** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */
function pinAt(px: number, py: number, map: ScreenMap): number | null {
@@ -91,6 +120,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
}
function remove(i: number): void {
openIndex = null;
params.commit(
params.points().filter((_, k) => k !== i),
params.names().filter((_, k) => k !== i),
@@ -117,7 +147,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network());
}
const pins = pointLayer(params.points);
const pins = pointLayer(params.points, params.names);
const input: PlanMapLayer = {
draw: () => undefined,
@@ -145,12 +175,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
},
};
/** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */
function addAtCenter(): void {
const at = params.center();
if (!at || !params.editable() || params.points().length >= MAX_POINTS) return;
openIndex = params.points().length;
commitState(addPoint(state(), at));
}
/** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */
function editorOf(i: number): { root: HTMLElement; sync: () => void } {
const tm = tmParams(params.epsg());
const editable = params.editable();
const field = (label: string, role: string, placeholder?: string) => {
const f = createInputField({ label, size: 24, placeholder });
f.input.dataset.role = role;
f.input.disabled = !editable || (role !== "point-name" && !tm);
return f;
};
const nameField = field("이름", "point-name", `지점 ${i + 1}`);
const lonField = field("경도", "point-lon");
const latField = field("위도", "point-lat");
const lonLatNow = (): [number, number] | null => {
const p = params.points()[i];
return tm && p ? tmToLonLat(tm, p[0], p[1]) : null;
};
const sync = (): void => {
const set = (f: ReturnType<typeof field>, text: string): void => {
if (document.activeElement !== f.input) f.input.value = text;
};
set(nameField, params.names()[i] ?? "");
const ll = lonLatNow();
set(lonField, ll ? ll[0].toFixed(6) : "");
set(latField, ll ? ll[1].toFixed(6) : "");
};
nameField.input.addEventListener("input", () =>
nameField.setError(nameError(nameField.input.value.trim()) ?? undefined),
);
nameField.input.addEventListener("change", () => {
const text = nameField.input.value.trim();
const error = nameError(text);
nameField.setError(error ?? undefined);
if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text));
});
const moved = (): void => {
const parsed = parseLonLat(lonField.input.value, latField.input.value);
const bad = typeof parsed === "string";
lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined);
latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined);
if (bad || !tm) return;
const now = lonLatNow();
if (
now &&
now[0].toFixed(6) === parsed[0].toFixed(6) &&
now[1].toFixed(6) === parsed[1].toFixed(6)
)
return;
commitState(movePointTo(state(), i, tm, parsed[0], parsed[1]));
};
lonField.input.addEventListener("change", moved);
latField.input.addEventListener("change", moved);
const root = document.createElement("div");
root.className = "d03-plan__point-edit";
root.append(nameField.root, lonField.root, latField.root);
sync();
return { root, sync };
}
function render(): void {
const tm = tmParams(params.epsg());
const editable = params.editable();
const points = params.points();
if (openIndex !== null && openIndex >= points.length) openIndex = null;
addButton.hidden = !editable;
addButton.disabled = points.length >= MAX_POINTS || !params.center();
const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`;
const lonLatOf = (i: number): string | undefined => {
const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null;
return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined;
};
if (key === shown) {
// 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움
points.forEach((_, i) => {
const row = list.children[i]?.querySelector(".ui-list-row");
const label = row?.querySelector(".ui-list-row__label");
const sub = row?.querySelector(".ui-list-row__sub");
if (label) label.textContent = name(i);
if (sub) sub.textContent = lonLatOf(i) ?? "";
});
syncEditor?.();
return;
}
shown = key;
syncEditor = null;
list.replaceChildren(
...params.points().map((point, i) => {
const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null;
...points.map((_, i) => {
const drop = createIconButton({
icon: "trash",
title: `${name(i)} 지우기`,
@@ -158,22 +277,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints {
onClick: () => remove(i),
});
drop.hidden = !editable;
return createListRow({
const row = createListRow({
label: name(i),
sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined,
sub: lonLatOf(i),
stripe: pointColor(i),
size: 28,
trailing: drop,
active: i === openIndex,
});
row.dataset.point = String(i);
row.tabIndex = 0;
const toggle = (): void => {
openIndex = openIndex === i ? null : i;
render();
};
row.addEventListener("click", (event) => {
if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle();
});
row.addEventListener("keydown", (event) => {
if (event.key === "Enter" && event.target === row) toggle();
});
const item = document.createElement("div");
item.append(row);
if (i === openIndex) {
const editor = editorOf(i);
syncEditor = editor.sync;
item.append(editor.root);
}
return item;
}),
);
hint.textContent = !editable
? params.points().length
? "보기만 — 지점은 산림청이 놓습니다."
: "지점 없음"
: params.points().length
? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함."
: "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작).";
}
return {
@@ -0,0 +1,114 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Site.ts
* D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기
* (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) ·
* 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기].
* 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸).
* ========================================================================== */
import { pickFiles } from "@ui/ui_template_drop";
import { createButton } from "@ui/ui_template_elements";
import { createFindList, type FindList } from "@ui/ui_template_find_list";
import { t } from "@ui/ui_template_locale";
import { createText } from "@ui/ui_template_text";
import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api";
/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */
const MIN_QUERY = 2;
export interface PlanSiteParams {
/** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */
onPick: (hit: PlaceHit) => void;
onRetry: () => void;
onUpload: (files: File[]) => void;
}
export interface PlanSiteState {
address: string | null;
/** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */
note: string;
retry: boolean;
editable: boolean;
}
export interface PlanSite {
/** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */
parts: HTMLElement[];
render: (state: PlanSiteState) => void;
}
/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 ·
* 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */
export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList {
const find = createFindList({ placeholder: t("DE_Reg_Find") });
let asked = 0; // 옛 찾기 답 버림
async function run(): Promise<void> {
const query = find.search.value.trim();
find.setRows([]);
if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort"));
const at = ++asked;
find.hint.textContent = t("DE_Reg_FindBusy");
try {
const hits = await searchPlaces(query);
if (at !== asked) return;
find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone");
find.setRows(
hits.map((hit, i) => ({
key: String(i),
tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"),
name: hit.title,
sub: hit.address !== hit.title ? hit.address : undefined,
onPick: () => {
find.setRows([]);
find.search.value = hit.title;
onPick(hit);
},
})),
);
} catch (error) {
if (at === asked)
find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`;
}
}
// 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음
find.search.addEventListener("input", () => find.setRows([]));
find.search.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return;
event.preventDefault();
void run();
});
return find;
}
export function createPlanSite(params: PlanSiteParams): PlanSite {
const address = createText("", "muted", 14);
const note = createText("", "error");
const find = createPlaceFind(params.onPick);
const retry = createButton({
label: "다시 받기",
variant: "filled",
size: 28,
onClick: params.onRetry,
});
const upload = createButton({
label: "임도망 올리기",
variant: "ghost",
size: 28,
onClick: () =>
pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload),
});
upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)";
return {
parts: [address, find.root, note, retry, upload],
render(state) {
address.textContent = state.address ?? "";
address.hidden = !state.address;
note.textContent = state.note;
note.hidden = !state.note;
find.root.hidden = upload.hidden = !state.editable;
retry.hidden = !state.editable || !state.retry;
},
};
}
@@ -0,0 +1,236 @@
/* =============================================================================
* D03_PlanAnalysis_UI_SlopeBands.ts
* D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
* (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음).
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
* 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`.
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
import { createInputField, createSelectField } from "@ui/ui_template_input";
import { createLegend } from "@ui/ui_template_flow_legend";
import { openModal } from "@ui/ui_template_modal";
import { themeColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs";
import {
DEFAULT_SLOPE_BANDS,
SLOPE_COLORS,
SLOPE_MAX_BANDS,
SLOPE_MAX_PCT,
bandOf,
checkBands,
parseBands,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
const PREF_KEY = "aislo:pref:d03.slope-bands";
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
const ALPHA = 150;
export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY));
export const writeSlopeBands = (bands: SlopeBand[]): void =>
writeByKey(PREF_KEY, JSON.stringify(bands));
/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */
export function slopeValues(image: HTMLImageElement): ImageData {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true })!;
context.drawImage(image, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height);
}
/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */
export async function paintSlope(
values: ImageData,
bands: SlopeBand[],
): Promise<{ url: string; image: HTMLImageElement }> {
const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15")));
const lut = new Uint32Array(256);
const view = new DataView(lut.buffer);
for (let v = 0; v < 256; v++) {
const i = bandOf(v, bands);
if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false);
}
const out = new ImageData(values.width, values.height);
const src = values.data;
const dst = new Uint32Array(out.data.buffer);
for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]];
const canvas = document.createElement("canvas");
canvas.width = values.width;
canvas.height = values.height;
canvas.getContext("2d")!.putImageData(out, 0, 0);
const blob = await new Promise<Blob>((done, fail) =>
canvas.toBlob(
(got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))),
"image/png",
),
);
const url = URL.createObjectURL(blob);
return { url, image: await loadImage(url) };
}
/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */
function hexRgb(color: string): number {
const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1];
return hex ? parseInt(hex, 16) : 0xfacc15;
}
/** 범례 급경사 줄 ⚙. */
export const slopeGear = (onClick: () => void): HTMLButtonElement => {
const button = createIconButton({
icon: "settings",
title: "급경사 범위 바꾸기(기울기 %)",
size: 20,
onClick,
});
button.dataset.role = "slope-bands";
return button;
};
const degText = (value: string): string => {
const pct = Number(value);
return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—";
};
/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */
export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void {
let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = [];
const list = document.createElement("div");
const error = createText("", "error");
error.hidden = true;
const addButton = createButton({
label: "범위 추가",
variant: "ghost",
size: 28,
iconStart: "plus",
onClick: () => {
const last = rows.at(-1)?.read();
const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0;
const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color;
add({ low, high: SLOPE_MAX_PCT, color });
},
});
addButton.dataset.role = "slope-band-add";
function refresh(): void {
addButton.disabled = rows.length >= SLOPE_MAX_BANDS;
error.hidden = true;
}
function add(band: SlopeBand): void {
const field = (label: string, value: number) => {
const got = createInputField({
label,
type: "number",
unit: "%",
min: 0,
max: SLOPE_MAX_PCT,
size: 28,
value: Number.isFinite(value) ? String(value) : "",
});
const deg = createText(degText(got.input.value), "muted");
got.input.addEventListener("input", () => {
deg.textContent = degText(got.input.value);
refresh();
});
got.root.append(deg);
return got;
};
const low = field("아래", band.low);
const high = field("위", band.high);
const color = createSelectField({
label: "색",
size: 28,
options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })),
value: band.color,
});
const chip = createLegend([{ label: "", color: `var(${band.color})` }]);
color.select.addEventListener("change", () =>
chip
.querySelector<HTMLElement>(".ui-legend__mark")!
.style.setProperty("--legend-color", `var(${color.select.value})`),
);
const root = document.createElement("div");
root.className = "d03-plan__row";
root.dataset.role = "slope-band";
const entry = {
root,
read: (): SlopeBand => ({
low: low.input.value.trim() === "" ? NaN : Number(low.input.value),
high: high.input.value.trim() === "" ? NaN : Number(high.input.value),
color: color.select.value,
}),
};
const remove = createIconButton({
icon: "trash",
title: "이 범위 지움",
size: 28,
onClick: () => {
rows = rows.filter((row) => row !== entry);
root.remove();
refresh();
},
});
root.append(low.root, high.root, color.root, chip, remove);
rows.push(entry);
list.append(root);
refresh();
}
function fill(bands: SlopeBand[]): void {
rows = [];
list.replaceChildren();
bands.forEach(add);
}
openModal({
title: "급경사 범위(기울기 %)",
closeLabel: "닫기",
width: "40rem",
mount: (body, close, actions) => {
fill(current);
const reset = createButton({
label: "기본값",
variant: "ghost",
size: 28,
onClick: () => fill(DEFAULT_SLOPE_BANDS),
});
reset.dataset.role = "slope-band-default";
const apply = createButton({
label: "적용",
size: 28,
onClick: () => {
const bands = rows.map((row) => row.read());
const why = checkBands(bands);
if (why) {
error.lastChild!.textContent = why;
error.hidden = false;
return;
}
onApply(bands.sort((a, b) => a.low - b.low));
close();
},
});
apply.dataset.role = "slope-band-apply";
actions.append(apply);
const foot = document.createElement("div");
foot.className = "d03-plan__row";
foot.append(addButton, reset);
body.append(
createText(
`0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`,
"hint",
),
list,
foot,
error,
);
},
});
}
+33 -32
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,12 +160,22 @@
justify-content: flex-start;
}
.d03-plan__piece[role="button"] {
cursor: pointer;
/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */
.d03-plan__points-head {
display: flex;
justify-content: flex-end;
margin-bottom: var(--spacing-8);
}
.d03-plan__piece-year {
font-weight: 600;
.d03-plan__point-edit {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2);
}
.d03-plan__point-edit > .ui-field {
margin: 0;
}
/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */
@@ -213,3 +195,22 @@
.d03-plan__draw-fields .ui-field {
margin: 0;
}
/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */
.d03-plan__line-tail {
display: flex;
gap: var(--spacing-4);
align-items: center;
}
.d03-plan__pieces .ui-list-row__label > .ui-field {
margin: 0;
}
.d03-plan__piece-row {
padding-left: calc(var(--spacing-8) * 2);
}
.d03-plan__piece-row .ui-list-row__label {
font-size: var(--text-caption);
}
@@ -0,0 +1,77 @@
/* =============================================================================
* D03_PlanAnalysis_PointEdit.ts
* D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/).
* 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임.
* 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`).
* ========================================================================== */
import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan";
import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm";
/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */
export const MAX_POINTS = 40;
/** 보이는 이름 — 빈 값 = 「지점 N」. */
export function pointLabel(names: string[], index: number): string {
return names[index]?.trim() || `지점 ${index + 1}`;
}
/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */
export function parseLonLat(lonText: string, latText: string): [number, number] | string {
const lon = Number(lonText.trim());
const lat = Number(latText.trim());
if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180";
if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90";
return [lon, lat];
}
export interface PointsState {
points: Vertex[];
names: string[];
network: PlanNetwork;
}
/** 지점 더함 — 이름 비움. 상한이면 그대로. */
export function addPoint(state: PointsState, at: Vertex): PointsState {
if (state.points.length >= MAX_POINTS) return state;
return {
...state,
points: [...state.points, [at[0], at[1]]],
names: [...state.names, ""],
};
}
/** 이름 바꿈 — 빈 값 허용. */
export function renamePoint(state: PointsState, index: number, name: string): PointsState {
const names = state.points.map((_, k) => state.names[k] ?? "");
names[index] = name.trim();
return { ...state, names };
}
/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */
export function movePointTo(
state: PointsState,
index: number,
tm: TmParams,
lon: number,
lat: number,
): PointsState {
const to = lonLatToTm(tm, lon, lat);
const from = state.points[index];
return {
...state,
points: state.points.map((p, k): Vertex => (k === index ? to : p)),
network: moveNodesAt(state.network, from, to),
};
}
/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */
export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork {
const text = name.trim();
return {
...network,
lines: network.lines.map((line) =>
line.id === lineId ? { ...line, name: text || null } : line,
),
};
}
@@ -0,0 +1,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);
}
+13 -2
View File
@@ -36,6 +36,13 @@ def test_d03_parts():
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클
site = _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts
assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts")
for raw in ('createElement("button")', 'createElement("input")'):
assert raw not in site, raw
# 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품
for src in (ts, draw, draw_panel, tools, layers):
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
@@ -90,8 +97,12 @@ def test_d03_parts():
props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판
assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props
assert 'createElement("button")' not in props and 'createElement("select")' not in props
assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음
assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에
assert (
'createElement("select")' not in three and "createSelectField" not in three
) # 601 선 고름 걷음
assert (
"createCheckbox" not in three and "setCorridors(" in three
) # 601 노선망 예상 코리도 한 번에
for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"):
assert dead not in css, dead
assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음
+1 -1
View File
@@ -113,7 +113,7 @@ def test_de_pages_guard_leave():
if name:
assert f'discard: () => clearState("{name}", projectId)' in call, path
# D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음
assert "let savedJson = JSON.stringify({ network, points });" in _src(
assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts"
)
e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts")
+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
@@ -276,7 +276,9 @@ def test_screen_defs() -> None:
assert entry and entry.group(2) == "false", key # 처음 값 끔
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸
assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창
assert "uploadRoads" in page and "uploadPlanForestRoads(" in page
assert "<input" not in page and 'createElement("input")' not in page
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
+120
View File
@@ -0,0 +1,120 @@
"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김
(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`.
./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q
"""
from __future__ import annotations
import json
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js";
import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js";
import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js";
const Y = 2026;
const tm = tmx.tmParams(5187);
// 지점 (100,0) 에서 시작하는 선 · 지점 둘
let n = plan.newLine(plan.emptyNetwork(), [100, 0]);
n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y);
const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n };
const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x');
const cleared = pe.setLineName(named, n.lines[0].id, ' ');
const added = pe.addPoint(state, [7, 8]);
const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n };
const [lon, lat] = tmx.tmToLonLat(tm, 100, 0);
const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat);
const back = tmx.tmToLonLat(tm, ...moved.points[0]);
writeFileSync(process.argv[2], JSON.stringify({
errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "a<b", "a>b", "a|b", "x".repeat(20), "x".repeat(21)]
.map((s) => plan.nameError(s)),
labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)],
parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")],
added: [added.points, added.names, added.network === n],
lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)],
full: pe.addPoint(full, [1, 1]) === full,
renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names],
moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names },
}));
"""
@pytest.fixture(scope="module")
def got(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp_path = tmp_path_factory.mktemp("pe")
out = tmp_path / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT),
"--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler",
"--ignoreConfig"],
cwd=str(ROOT), check=True, capture_output=True,
) # fmt: skip
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp_path / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def test_name_rule(got: dict) -> None:
ok, bad = got["errors"][:2], got["errors"][2:11]
assert ok == [None, None] # 빈 이름 · 한글 = 됨
assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉
assert got["errors"][11] is None # 20 자
assert "20 자" in got["errors"][12] # 21 자 = 막음
def test_label_blank_falls_back(got: dict) -> None:
assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"]
def test_parse_lonlat(got: dict) -> None:
ok, no_lon, bad_lat, big_lon, big_lat = got["parse"]
assert ok == [128.5, 36.9]
assert no_lon.startswith("경도") and bad_lat.startswith("위도")
assert big_lon.startswith("경도") and big_lat.startswith("위도")
def test_add_and_limit(got: dict) -> None:
points, names, network_same = got["added"]
assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""]
assert network_same # 더해도 노선 그대로
assert got["full"] # 40 개 = 그대로
def test_rename(got: dict) -> None:
assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌
assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤
def test_move_by_lonlat_drags_node(got: dict) -> None:
moved = got["moved"]
assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복
assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이
assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로
assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"]
def test_line_name(got: dict) -> None:
named, names, cleared, back = got["lineName"]
assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선
assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로
@@ -0,0 +1,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)
+7
View File
@@ -102,6 +102,13 @@ export const ui_locales_de = {
DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"],
DE_Reg_SiteSource: ["출처", "Source"],
DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."],
DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"],
DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"],
DE_Reg_FindBusy: ["찾는 중…", "Searching…"],
DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"],
DE_Reg_FindFail: ["찾지 못함", "Search failed"],
DE_Reg_FindAddress: ["주소", "Address"],
DE_Reg_FindPlace: ["장소", "Place"],
DE_Reg_MapType: ["바탕 지도", "Base map"],
DE_Reg_MapNormal: ["일반", "Map"],
DE_Reg_MapSatellite: ["위성", "Satellite"],