merge(D 예정노선): dev 받기 — 604 영역 루틴(fetchLayer · areaTurn · load(have) · track) 과 605 올린 임도망 묶음(applyRoads · deleteRoad · pickRoad) 함께 살림 · 옛 haveTag · sheetLayer 뺌 (PLAN 605 · 요구 475 · 476)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 21:54:36 +09:00
co-authored by Claude Opus 5.5
9 changed files with 532 additions and 267 deletions
+43 -3
View File
@@ -290,12 +290,51 @@ async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
return root, epsg, box return root, epsg, box
SHEET_CLIP_KIND = "plan_sheet"
SHEET_CLIP_VERSION = 1
def _clip_to_box(root: Path, source: Path, box: terrain.Box, epsg: int) -> Path:
"""받은 범위(경위도 외접 상자)로 자른 사본(475) — 열쇠 = 사본 지문 + 범위 · 실패 = 통째."""
from B04_PreProcess.B04_PreProcess_Engine_SheetClip import clip_collection
from common_util.common_util_derived_store import (
derived_dir,
derived_key,
file_stamp,
prune_derived,
)
try:
bounds = layers._lonlat_bounds(box, epsg)
rect = (bounds["min_lon"], bounds["min_lat"], bounds["max_lon"], bounds["max_lat"])
key = derived_key(
SHEET_CLIP_VERSION, source.name, file_stamp(source), [round(v, 1) for v in box]
)
target = derived_dir(root, SHEET_CLIP_KIND) / f"{key}.json"
if target.is_file():
return target
collection = json.loads(source.read_text(encoding="utf-8"))
text = json.dumps(
clip_collection(collection, rect), ensure_ascii=False, separators=(",", ":")
)
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(f".{uuid4().hex}.tmp")
temporary.write_text(text, encoding="utf-8")
temporary.replace(target)
prune_derived(root, SHEET_CLIP_KIND, keep=2 * len(layers.SHEET_LAYERS))
return target
except Exception as error: # 자르기 실패가 화면을 막으면 안 됨
logger.warning("도엽 겹 받은 범위 자름 실패 (%s): %s", source.name, error)
return source
@router.get("/{project_id}/plan/layers/sheet", response_model=None) @router.get("/{project_id}/plan/layers/sheet", response_model=None)
async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response: async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Response:
"""도엽 겹 GeoJSON(하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄.""" """도엽 겹 GeoJSON(등고선 · 하천중심선 · 도로중심선 …) — 없으면 지난 병합 도엽에서 풀어 냄 ·
받은 범위로 자름(475 · 범위가 늘면 새 사본)."""
from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy
root, _, _ = await _layer_root(project_id) root, epsg, box = await _layer_root(project_id)
try: try:
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key) path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
except KeyError as error: except KeyError as error:
@@ -303,7 +342,8 @@ async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Resp
except FileNotFoundError as error: except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error raise HTTPException(status_code=404, detail=str(error)) from error
display = await asyncio.to_thread(_display_copy, path) display = await asyncio.to_thread(_display_copy, path)
return cached_file_response(request, display, "application/geo+json") clipped = await asyncio.to_thread(_clip_to_box, root, display, box, epsg)
return cached_file_response(request, clipped, "application/geo+json")
@router.get("/{project_id}/plan/layers/cadastral", response_model=None) @router.get("/{project_id}/plan/layers/cadastral", response_model=None)
+227 -228
View File
@@ -10,8 +10,9 @@
* 선 겹(단층 · 지질구조선 · 0차곡) · * 선 겹(단층 · 지질구조선 · 0차곡) ·
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
* 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 · * 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
* 받기 실패 = 그 겹만 꺼짐 + 알림. * 옛 차례 답은 버림(475) · 범위는 서버가 받은 범위로 정함(등고 · 세류 · 도엽 겹 = 서버가 자름) · 지형을 안 받았으면 단추를 막음 ·
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
@@ -27,8 +28,6 @@ import { createLegend } from "@ui/ui_template_flow_legend";
import { createIconButton } from "@ui/ui_template_button"; import { createIconButton } from "@ui/ui_template_button";
import { createMapTools } from "@ui/ui_template_map_tools"; import { createMapTools } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette"; import { themeColor } from "@ui/ui_template_palette";
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { import {
@@ -85,7 +84,6 @@ import {
writeSlopeBands, writeSlopeBands,
} from "./D03_PlanAnalysis_UI_SlopeBands"; } from "./D03_PlanAnalysis_UI_SlopeBands";
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
const PREF_KEY = "aislo:pref:d03.layers"; const PREF_KEY = "aislo:pref:d03.layers";
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ /** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
@@ -95,8 +93,6 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림"; const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */
const CONTOUR_PAD_M = 600;
type DataKey = keyof Prepared; type DataKey = keyof Prepared;
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */ /** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
@@ -108,8 +104,8 @@ export interface PlanLayers {
legend: HTMLElement; legend: HTMLElement;
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
layers: PlanMapLayer[]; layers: PlanMapLayer[];
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */ /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
load: () => Promise<void>; load: (have: [number, number, number, number] | null) => Promise<void>;
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>; uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
/** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */ /** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */
@@ -123,23 +119,26 @@ export interface PlanLayers {
counts: Record<string, number>; counts: Record<string, number>;
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
gl: Record<string, number>; gl: Record<string, number>;
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
area: [number, number, number, number] | null;
turn: number;
}; };
} }
export function createPlanLayers(opts: { export function createPlanLayers(opts: {
projectId: string; projectId: string;
map: PlanMap; map: PlanMap;
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */ /** 받는 일을 진행 서클에 셈(끝나면 뺌). */
points: () => Vertex[]; track: <T>(job: Promise<T>) => Promise<T>;
/** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */
have: () => [number, number, number, number] | null;
}): PlanLayers { }): PlanLayers {
const { projectId, map } = opts; const { projectId, map, track } = opts;
const on = readPref(PREF_KEY); const on = readPref(PREF_KEY);
const data: Partial<Prepared> = {}; const data: Partial<Prepared> = {};
const pending = new Map<DataKey | "satellite", Promise<void>>(); const pending = new Map<DataKey | "satellite", Promise<void>>();
let meta: VWorldMeta | null = null; let meta: VWorldMeta | null = null;
let loadedHave = ""; let area: [number, number, number, number] | null = null;
/** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */
let areaTurn = 0;
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */ /** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
let hillshade: { plain: GlImage; shade: GlImage } | null = null; let hillshade: { plain: GlImage; shade: GlImage } | null = null;
const gl = createGlBase(projectId, (why) => { const gl = createGlBase(projectId, (why) => {
@@ -274,14 +273,15 @@ export function createPlanLayers(opts: {
const slot = key === "satellite" ? key : dataKey(key); const slot = key === "satellite" ? key : dataKey(key);
const running = pending.get(slot); const running = pending.get(slot);
if (running) return running; if (running) return running;
const at = meta; const mine = areaTurn;
const job = fetchLayer(key, at) const job: Promise<void> = track(fetchLayer(key, meta, mine))
.then(() => { .then(() => {
if (meta !== at) return; if (mine !== areaTurn) return;
sync(); sync();
map.redrawBase(); map.redrawBase();
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
if (mine !== areaTurn) return;
const text = error instanceof Error ? error.message : String(error); const text = error instanceof Error ? error.message : String(error);
for (const def of LAYERS) for (const def of LAYERS)
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot)) if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
@@ -290,7 +290,7 @@ export function createPlanLayers(opts: {
map.redraw(); map.redraw();
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
}) })
.finally(() => pending.delete(slot)); .finally(() => pending.get(slot) === job && pending.delete(slot));
pending.set(slot, job); pending.set(slot, job);
return job; return job;
} }
@@ -298,7 +298,7 @@ export function createPlanLayers(opts: {
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ /** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> { async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
const at = meta; const at = meta;
const job = uploadPlanForestRoads(projectId, files).then((info) => { const job = track(uploadPlanForestRoads(projectId, files)).then((info) => {
if (meta !== at) return info; if (meta !== at) return info;
applyRoads(info); applyRoads(info);
showToast( showToast(
@@ -404,209 +404,206 @@ export function createPlanLayers(opts: {
else map.redrawBase(); else map.redrawBase();
} }
/** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */ /** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */
const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_"); async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise<void> {
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> => const got: Partial<Prepared> = {};
fetchCachedJson<GeoJsonCollection>( const sets: Array<Parameters<typeof gl.setData>> = [];
projectId, const put = (...args: Parameters<typeof gl.setData>): void => void sets.push(args);
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`, let shade = null as typeof hillshade;
); await fill();
if (mine !== areaTurn) {
if (got.slope?.url) URL.revokeObjectURL(got.slope.url);
return;
}
if (got.slope && data.slope?.url) URL.revokeObjectURL(data.slope.url);
Object.assign(data, got);
if (shade) hillshade = shade;
for (const args of sets) gl.setData(...args);
// 칠하는 동안 범위가 바뀜 = 새 범위로 다시
if (got.slope && got.slope.bands !== slopeBands) {
got.slope.bands = slopeBands;
void repaintSlope();
}
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> { async function fill(): Promise<void> {
const normalizer = createNormalizer(at); const normalizer = createNormalizer(at);
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
prepareLayer(collection, normalizer, labels); prepareLayer(collection, normalizer, labels);
const pick = ( const pick = (
collection: GeoJsonCollection, collection: GeoJsonCollection,
test: (props: Record<string, unknown>) => boolean, test: (props: Record<string, unknown>) => boolean,
): GeoJsonCollection => ({ ): GeoJsonCollection => ({
...collection, ...collection,
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})), features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
}); });
if (key === "contour") { if (key === "contour") {
// 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함 // 받은 범위로 서버가 자름 — 범위가 늘면 `load` 가 다시 받게 함
const points = opts.points(); const [collection] = await Promise.all([
const xs = points.map(([x]) => x); fetchPlanSheetLayer(projectId, "등고선"),
const ys = points.map(([, y]) => y); glFont("400", "0123456789-"),
const bbox = ( ]);
opts.have() ?? [ got.contour = indexLayerLod(prepare(collection, ["등고수치"]));
Math.min(...xs) - CONTOUR_PAD_M, put("contour", collection);
Math.min(...ys) - CONTOUR_PAD_M, } else if (key === "stream") {
Math.max(...xs) + CONTOUR_PAD_M, const collection = await fetchPlanSheetLayer(projectId, "하천중심선");
Math.max(...ys) + CONTOUR_PAD_M, got.stream = [
] prepare(pick(collection, (props) => props["구분"] === "세류")),
).map((v) => v.toFixed(0)); prepare(pick(collection, (props) => props["구분"] !== "세류")),
const [collection] = await Promise.all([ ];
sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`), put("stream", collection);
glFont("400", "0123456789-"), } else if (key === "cadastral") {
]); const collection = await fetchPlanCadastral(projectId);
data.contour = indexLayerLod(prepare(collection, ["등고수치"])); got.cadastral = prepare(collection);
gl.setData("contour", collection); put("cadastral", collection);
} else if (key === "stream") { } else if (key === "road" || key === "forestRoad") {
const collection = await sheetLayer("도엽_하천중심선"); const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
data.stream = [ got.road = {
prepare(pick(collection, (props) => props["구분"] === "세류")), all: prepare(collection),
prepare(pick(collection, (props) => props["구분"] !== "세류")), small: prepare(
]; pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
gl.setData("stream", collection); ),
} else if (key === "cadastral") {
const collection = await fetchPlanCadastral(projectId);
data.cadastral = prepare(collection);
gl.setData("cadastral", collection);
} else if (key === "road" || key === "forestRoad") {
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
data.road = {
all: prepare(collection),
small: prepare(
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
),
};
gl.setData("road", collection);
} else if (key === "forestRoadFile") {
const info = await fetchPlanForestRoads(projectId);
data.forestRoadFile = { layer: prepare(info.collection), info };
gl.setData("forestRoadFile", info.collection);
} else if (key === "landslideZones") {
const info = await fetchPlanLandslideZones(projectId);
data.landslideZones = { parcels: prepare(info.parcels), info };
gl.setData("landslideZones", {
features: [
...(info.parcels.features ?? []),
...info.points.map((point) => ({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
} as GeoJsonCollection);
if (info.missing.length)
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
} 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 };
const plain = glImage(raster);
if (key !== "hillshade") gl.setData(key, plain);
else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
} else if (key === "water") {
const parts = await Promise.all(
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
);
const water = { features: parts.flatMap((part) => part.features ?? []) };
data.water = prepare(water);
gl.setData("water", water);
} else if (key === "building") {
const building = await fetchPlanSheetLayer(projectId, "건물");
data.building = prepare(building);
gl.setData("building", building);
} else if (key === "zones") {
const zones = await fetchPlanZones(projectId);
const kinds = new Map<string, PreparedLayer>();
for (const kind of Object.keys(zones.summary))
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
data.zones = { kinds, summary: zones.summary };
gl.setData(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
if (failed.length)
showToast(
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
"error",
);
} else if (key === "soils") {
const soils = await fetchPlanSoils(projectId);
data.soils = {
kinds: soils.summary.map((soil, i) => ({
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
color: SOIL_COLORS[i % SOIL_COLORS.length],
})),
summary: soils.summary,
};
const colorOf = new Map(
soils.summary.map((soil, i) => [soil.code, data.soils!.kinds[i].color]),
);
gl.setData(
"soils",
glTagged(soils, (props) =>
themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"),
),
);
} else if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
const slow = window.setTimeout(
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
600,
);
try {
const got = kind
? await fetchPlanLines(projectId, kind).then((lines) => ({
collection: lines,
...lines.summary,
}))
: await fetchPlanZeroOrder(projectId);
data[key] = {
layer: prepare(got.collection),
count: got.count,
length_m: got.length_m,
}; };
gl.setData(key, got.collection); put("road", collection);
} finally { } else if (key === "forestRoadFile") {
window.clearTimeout(slow); const info = await fetchPlanForestRoads(projectId);
} got.forestRoadFile = { layer: prepare(info.collection), info };
} else if (key === "bands") { put("forestRoadFile", info.collection);
const info = await fetchPlanBands(projectId); } else if (key === "landslideZones") {
data.bands = { const info = await fetchPlanLandslideZones(projectId);
stream: prepare(info.stream.collection), got.landslideZones = { parcels: prepare(info.parcels), info };
road: prepare(info.road.collection), put("landslideZones", {
info, features: [
}; ...(info.parcels.features ?? []),
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection => ...info.points.map((point) => ({
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), { type: "Feature",
_band: name, properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
} as GeoJsonCollection);
if (info.missing.length)
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
} 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);
const paintMs = Math.round(performance.now() - started);
got.slope = { raster, values, bands, ...painted, paintMs };
put("slope", glImage(raster, painted.url));
} else if (key === "hillshade" || key === "landslide") {
const raster = await fetchPlanRaster(projectId, key);
const image = await loadImage(raster.image);
got[key] = { raster, image };
const plain = glImage(raster);
if (key !== "hillshade") put(key, plain);
else if (plain) shade = { plain, shade: glImage(raster, shadeImage(image))! };
} else if (key === "water") {
const parts = await Promise.all(
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
);
const water = { features: parts.flatMap((part) => part.features ?? []) };
got.water = prepare(water);
put("water", water);
} else if (key === "building") {
const building = await fetchPlanSheetLayer(projectId, "건물");
got.building = prepare(building);
put("building", building);
} else if (key === "zones") {
const zones = await fetchPlanZones(projectId);
const kinds = new Map<string, PreparedLayer>();
for (const kind of Object.keys(zones.summary))
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
got.zones = { kinds, summary: zones.summary };
put(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
if (failed.length)
showToast(
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
"error",
);
} else if (key === "soils") {
const soils = await fetchPlanSoils(projectId);
got.soils = {
kinds: soils.summary.map((soil, i) => ({
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
color: SOIL_COLORS[i % SOIL_COLORS.length],
})),
summary: soils.summary,
};
const colorOf = new Map(
soils.summary.map((soil, i) => [soil.code, got.soils!.kinds[i].color]),
);
put(
"soils",
glTagged(soils, (props) =>
themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"),
),
);
} else if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
const slow = window.setTimeout(
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
600,
);
try {
const found = kind
? await fetchPlanLines(projectId, kind).then((lines) => ({
collection: lines,
...lines.summary,
}))
: await fetchPlanZeroOrder(projectId);
got[key] = {
layer: prepare(found.collection),
count: found.count,
length_m: found.length_m,
};
put(key, found.collection);
} finally {
window.clearTimeout(slow);
}
} else if (key === "bands") {
const info = await fetchPlanBands(projectId);
got.bands = {
stream: prepare(info.stream.collection),
road: prepare(info.road.collection),
info,
};
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
_band: name,
});
put("bands", {
features: [
...(band("road", "#facc15").features ?? []),
...(band("stream", "#22d3ee").features ?? []),
],
}); });
gl.setData("bands", { } else {
features: [ const collection = await fetchPlanSheetLayer(projectId, "지명");
...(band("road", "#facc15").features ?? []), got.place = (collection.features ?? []).flatMap((feature) => {
...(band("stream", "#22d3ee").features ?? []), const geometry = feature.geometry;
], const name = feature.properties?.["명칭"];
}); if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
} else { const [lon, lat] = geometry.coordinates as number[];
const collection = await fetchPlanSheetLayer(projectId, "지명"); return [{ lon, lat, name }];
data.place = (collection.features ?? []).flatMap((feature) => { });
const geometry = feature.geometry; await glFont("600", got.place.map((place) => place.name).join(""));
const name = feature.properties?.["명칭"]; put("place", {
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return []; features: got.place.map(({ lon, lat, name }) => ({
const [lon, lat] = geometry.coordinates as number[]; type: "Feature",
return [{ lon, lat, name }]; properties: { name },
}); geometry: { type: "Point", coordinates: [lon, lat] },
await glFont("600", data.place.map((place) => place.name).join("")); })),
gl.setData("place", { } as GeoJsonCollection);
features: data.place.map(({ lon, lat, name }) => ({ }
type: "Feature",
properties: { name },
geometry: { type: "Point", coordinates: [lon, lat] },
})),
} as GeoJsonCollection);
} }
} }
@@ -632,16 +629,16 @@ export function createPlanLayers(opts: {
}, },
]; ];
async function load(): Promise<void> { async function load(have: [number, number, number, number] | null): Promise<void> {
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
// 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4) // 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4)
const have = JSON.stringify(opts.have()); const frame = (m: VWorldMeta | null) =>
const changed = JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]);
have !== loadedHave || const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta);
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !== [meta, area] = [next, have];
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
[meta, loadedHave] = [next, have];
if (changed) { if (changed) {
areaTurn += 1;
pending.clear();
for (const key of Object.keys(data)) delete data[key as DataKey]; for (const key of Object.keys(data)) delete data[key as DataKey];
hillshade = null; hillshade = null;
for (const def of LAYERS) for (const def of LAYERS)
@@ -674,6 +671,8 @@ export function createPlanLayers(opts: {
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length, : gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]), ]),
), ),
area,
turn: areaTurn,
}), }),
}; };
} }
+31 -19
View File
@@ -4,7 +4,10 @@
* *
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 · * 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
* 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 = * 단추 · 막는 창 없음 · 진행 = 위치 칸 제목 오른쪽 서클(받는 일 모두 · 604-3) · 실패만 [다시 받기] ·
* 영역 루틴(475) = 사건(지점 · 선 · 위치 · 되돌리기 · 다시하기 · 처음 열기) → `ensureCovered` → 받기 →
* 지형면 · 받은 범위 먼저 → 겹(`backdrop.load(have)` · 지문 바뀜 = 켠 겹 모두 다시) ·
* 기준점 10 km 밖 = 위치 칸 알림 · 지운 지점 영역은 그대로) · 지점(목록 · 핀 · 우클릭 메뉴 =
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) · * `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화]. * 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2). * 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
@@ -64,7 +67,9 @@ import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
import { import {
aroundSite, aroundSite,
boxOfLonLat, boxOfLonLat,
createWorkCircle,
errorText, errorText,
farFromSite,
nearSite, nearSite,
watchTerrain, watchTerrain,
type Site, type Site,
@@ -212,16 +217,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
on3d: open3d, on3d: open3d,
onMeasure: () => setMeasure(!measuring), onMeasure: () => setMeasure(!measuring),
}); });
const backdrop = createPlanLayers({ // 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3)
projectId, const circle = createProgressCircle({ size: 16, inline: true });
map, const work = createWorkCircle(circle);
points: () => [...points, ...allVertices()], const backdrop = createPlanLayers({ projectId, map, track: work.track });
have: () => have, map.root.append(backdrop.bar, backdrop.legend, props.panel);
});
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
const circle = createProgressCircle({ size: 40, corner: true });
circle.root.hidden = true;
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
const main = document.createElement("div"); const main = document.createElement("div");
main.className = "d03-plan"; main.className = "d03-plan";
main.append(map.root); main.append(map.root);
@@ -243,6 +243,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}, },
}); });
const siteCard = createPanelCard("위치", false); const siteCard = createPanelCard("위치", false);
siteCard.firstElementChild!.append(circle.root);
siteCard.append(...siteBox.parts); siteCard.append(...siteBox.parts);
const pointCard = createPanelCard("지점", false); const pointCard = createPanelCard("지점", false);
pointCard.append(marks.panel); pointCard.append(marks.panel);
@@ -317,6 +318,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
({ network, points, pointNames, site } = last); ({ network, points, pointNames, site } = last);
keepDraft(); keepDraft();
refresh(); refresh();
void ensureCovered(); // 영역 루틴 — 되돌린 지점 · 선도 받은 범위 밖이면 받음
} }
function redo(): void { function redo(): void {
@@ -326,6 +328,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
({ network, points, pointNames, site } = next); ({ network, points, pointNames, site } = next);
keepDraft(); keepDraft();
refresh(); refresh();
void ensureCovered();
} }
function setMeasure(on: boolean): void { function setMeasure(on: boolean): void {
@@ -379,9 +382,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
props.render(); props.render();
pieces.render(); pieces.render();
marks.render(); marks.render();
const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0;
const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : "";
siteBox.render({ siteBox.render({
address: site?.address ?? null, address: site?.address ?? null,
note: failed ? `지형 받기 실패 — ${failed}` : note, note: [failed ? `지형 받기 실패 — ${failed}` : note, farNote].filter(Boolean).join(" · "),
retry: !busy && !!failed, retry: !busy && !!failed,
editable, editable,
}); });
@@ -454,12 +459,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]); if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
} }
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ /** 도는 받기를 지켜보고 끝나면 지형면 · 받은 범위를 먼저 다 읽은 뒤 겹(475 — 동시면 겹이 옛 범위로 판정). */
async function follow(): Promise<void> { async function follow(): Promise<void> {
const job = await watch(); const job = await watch();
if (!job || !root.isConnected) return; if (!job || !root.isConnected) return;
failed = job.error; failed = job.error;
await Promise.all([loadSurface(), backdrop.load()]); await loadSurface();
refresh();
await backdrop.load(have);
refresh(); refresh();
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기 await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
} }
@@ -468,18 +475,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
watchTerrain({ watchTerrain({
projectId, projectId,
alive: () => root.isConnected, alive: () => root.isConnected,
circle, work,
setBusy: (on) => { setBusy: (on) => {
busy = on; busy = on;
refresh(); refresh();
}, },
}); });
/** 지형면 · 받은 범위(have) — 서클에 셈. */
async function loadSurface(): Promise<void> { async function loadSurface(): Promise<void> {
try { try {
surface = await fetchPlanSurface(projectId); [surface, have] = await work.track(
const extent = await fetchTerrainExtent(projectId, null, []); Promise.all([
have = extent.have_m; fetchPlanSurface(projectId),
fetchTerrainExtent(projectId, null, []).then((extent) => extent.have_m),
]),
);
} catch (error) { } catch (error) {
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error"); showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
} }
@@ -573,6 +584,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
base: () => points, base: () => points,
have: () => have, have: () => have,
busy: () => busy, busy: () => busy,
work: work.count,
site: () => site, site: () => site,
pickSite, pickSite,
lineNames: () => lineNames(network.lines), lineNames: () => lineNames(network.lines),
@@ -636,7 +648,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
refresh(); refresh();
await loadSurface(); await loadSurface();
refresh(); refresh();
await backdrop.load(); await backdrop.load(have);
const job = (await fetchPlanProgress(projectId)).terrain; const job = (await fetchPlanProgress(projectId)).terrain;
if (job.running) { if (job.running) {
ready = true; ready = true;
+57 -11
View File
@@ -1,7 +1,8 @@
/* ============================================================================= /* =============================================================================
* D03_PlanAnalysis_UI_Terrain.ts * D03_PlanAnalysis_UI_Terrain.ts
* D 예정노선 받기 이음(PLAN 597 · 요구 463-7) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · 받기 지켜보기(서클). * D 예정노선 받기 이음(PLAN 597 · 604 · 요구 463-7 · 475 · 476-2) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 ·
* 상태는 페이지가 쥐고 여기는 인자로만 받음(화면 요소 만들기 없음 · 서클은 부품 그대로). * 받기 지켜보기 · 진행 서클 하나(위치 칸 제목 오른쪽 · 들어간 일 수 = 지형 받기 · 겹 받기 · 처음 열기 · 임도망 올리기).
* 상태는 페이지가 쥐고 여기는 인자로만 받음(서클은 부품 그대로).
* ========================================================================== */ * ========================================================================== */
import { showToast } from "@ui/ui_template_elements"; import { showToast } from "@ui/ui_template_elements";
@@ -56,27 +57,72 @@ export function nearSite(epsg: number, at: Site | null, list: Vertex[]): Vertex[
); );
} }
/** 기준점에서 `COVER_MAX_M` 넘는 점 수 — 위치 칸 알림(조용히 안 받음 475). */
export const farFromSite = (epsg: number, at: Site | null, list: Vertex[]): number =>
list.length - nearSite(epsg, at, list).length;
/** 진행 서클 하나 · 들어간 일 수(0 = 숨김) — 지형 받기 = 진행률 · 나머지 일 = 돎. */
export interface WorkCircle {
/** 일 하나를 셈(끝나면 뺌 · 답은 그대로). */
track: <T>(job: Promise<T>) => Promise<T>;
/** 지형 받기 진행(없음 = 끝). */
terrain: (state: { ratio: number; text: string } | null) => void;
/** 들어간 일 수(검증 창구). */
count: () => number;
}
export function createWorkCircle(circle: ReturnType<typeof createProgressCircle>): WorkCircle {
let count = 0;
let terrain: { ratio: number; text: string } | null = null;
const show = (): void => {
circle.root.hidden = count === 0;
if (terrain) circle.set(terrain.ratio, "지형");
else circle.set(null, count > 1 ? `받는 중 ${count}` : "받는 중");
circle.root.title = terrain
? `지형 받기 ${Math.round(terrain.ratio * 100)}% · ${terrain.text}`
: `겹 · 파일 받는 중 ${count}`;
};
show();
return {
track(job) {
count += 1;
show();
return job.finally(() => {
count -= 1;
show();
});
},
terrain(state) {
terrain = state;
show();
},
count: () => count,
};
}
export interface WatchParams { export interface WatchParams {
projectId: string; projectId: string;
alive: () => boolean; alive: () => boolean;
circle: ReturnType<typeof createProgressCircle>; work: WorkCircle;
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */ /** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
setBusy: (on: boolean) => void; setBusy: (on: boolean) => void;
} }
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · /** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> { export function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
const { projectId, alive, circle, setBusy } = params; return params.work.track(watching(params));
}
async function watching(params: WatchParams): Promise<PlanJob | null> {
const { projectId, alive, work, setBusy } = params;
setBusy(true); setBusy(true);
circle.set(null, "지형"); work.terrain({ ratio: 0, text: "" });
circle.root.hidden = false;
try { try {
for (;;) { for (;;) {
if (!alive()) return null; if (!alive()) return null;
const job = (await fetchPlanProgress(projectId)).terrain; const job = (await fetchPlanProgress(projectId)).terrain;
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim()); work.terrain({ ratio: job.percent / 100, text: job.message ?? "" });
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
if (!job.running) { if (!job.running) {
const cut = !job.error && (job.interrupted || job.percent < 100); const cut = !job.error && (job.interrupted || job.percent < 100);
if (job.error) showToast(job.error, "error"); if (job.error) showToast(job.error, "error");
@@ -88,7 +134,7 @@ export async function watchTerrain(params: WatchParams): Promise<PlanJob | null>
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
return { running: false, percent: 0, message: null, error: INTERRUPTED }; return { running: false, percent: 0, message: null, error: INTERRUPTED };
} finally { } finally {
circle.root.hidden = true; work.terrain(null);
setBusy(false); setBusy(false);
} }
} }
@@ -929,3 +929,7 @@ resource폴더에 미리 계산해서 저장한고 필요한 시점에 사용한
## 요구 491 (10-30 · 사용자) ## 요구 491 (10-30 · 사용자)
일단 진행해주는데 조심해야된느 부분은 B04 B05에서의 도엽등고를 치환하는 변환하는 곳을 조심해. 지금 B그룹은 개발이 완료된 상태라 조심해야해. 진행해줘. 일단 진행해주는데 조심해야된느 부분은 B04 B05에서의 도엽등고를 치환하는 변환하는 곳을 조심해. 지금 B그룹은 개발이 완료된 상태라 조심해야해. 진행해줘.
## 요구 492 (10-30 · 사용자)
나는 이제 자러갈거야. 최종적으로 코드 반영하고 마지막에 아티팩트로 검증해서 보고서를 출력해줘.
@@ -0,0 +1,131 @@
"""D 예정노선 영역 루틴(PLAN 604 · 요구 475 · 476-2) — 도엽 겹 = 서버가 받은 범위로 자름(범위 바뀜 = 새 사본) ·
받기 끝 = 지형면 · 받은 범위 먼저 → 겹(지문 바뀜 = 차례 +1 · 옛 답 버림) · 되돌리기 · 다시하기도 영역 확인 ·
기준점 10 km 밖 = 위치 칸 알림 · 서클 = 위치 칸 제목 오른쪽 하나(들어간 일 수).
./venv/Scripts/python.exe -m pytest resources/tester/test_475_plan_area_refresh.py -q
"""
import json
from pathlib import Path
from common_util.common_util_crs import tm_to_lonlat
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router
ROOT = Path(__file__).resolve().parents[2]
D03 = ROOT / "D03_PlanAnalysis"
EPSG = 5186
BOX_A = (206000.0, 467000.0, 208000.0, 469000.0)
BOX_B = (204000.0, 466300.0, 208000.0, 469000.0) # 서쪽 2 km · 남쪽 0.7 km 늚
def _src(name: str) -> str:
return (D03 / name).read_text(encoding="utf-8")
def _line(x: float, y0: float, y1: float) -> dict:
coords = [list(tm_to_lonlat(x, y, EPSG)) for y in (y0, y1)]
return {
"type": "Feature",
"properties": {"x": x},
"geometry": {"type": "LineString", "coordinates": coords},
}
def _xs(path: Path) -> set[float]:
return {f["properties"]["x"] for f in json.loads(path.read_text("utf-8"))["features"]}
# ── 서버 — 도엽 겹 받은 범위 자름 ──
def test_sheet_clipped_to_received_box(tmp_path) -> None:
source = tmp_path / "도엽_등고선.display.geojson"
lines = [_line(x, 465000.0, 470000.0) for x in (203000.0, 205000.0, 207000.0, 209000.0)]
source.write_text(json.dumps({"type": "FeatureCollection", "features": lines}), "utf-8")
a = router._clip_to_box(tmp_path, source, BOX_A, EPSG)
assert a != source and _xs(a) == {207000.0} # 옛 범위 = 가운데 한 가닥
b = router._clip_to_box(tmp_path, source, BOX_B, EPSG)
assert b != a and _xs(b) == {205000.0, 207000.0} # 서쪽 2 km 띠 가닥이 들어옴
assert router._clip_to_box(tmp_path, source, BOX_B, EPSG) == b # 같은 범위 = 같은 저장본
for feature in json.loads(b.read_text("utf-8"))["features"]: # 잘린 꼴 ⊆ 받은 범위(경위도 외접)
for lon, lat in feature["geometry"]["coordinates"]:
west, south = tm_to_lonlat(BOX_B[0] - 50, BOX_B[1] - 50, EPSG)
east, north = tm_to_lonlat(BOX_B[2] + 50, BOX_B[3] + 50, EPSG)
assert west <= lon <= east and south <= lat <= north
def test_sheet_clip_failure_falls_back(tmp_path) -> None:
broken = tmp_path / "깨짐.geojson"
broken.write_text("{", "utf-8")
assert router._clip_to_box(tmp_path, broken, BOX_A, EPSG) == broken
def test_sheet_endpoint_clips() -> None:
src = (D03 / "D03_PlanAnalysis_Router.py").read_text("utf-8")
body = src[src.index("async def plan_layer_sheet") :]
body = body[: body.index("@router")]
assert "_clip_to_box, root, display, box, epsg" in body
# ── 화면 — 받기 끝 차례 · 차례 번호 · 등고 · 세류 길 ──
def test_follow_reads_surface_before_layers() -> None:
page = _src("D03_PlanAnalysis_UI_Page.ts")
follow = page[page.index("async function follow") :]
follow = follow[: follow.index("\n }\n")]
assert "Promise.all([loadSurface(), backdrop.load" not in page
assert follow.index("await loadSurface();") < follow.index("await backdrop.load(have);")
assert follow.index("await backdrop.load(have);") < follow.index("await ensureCovered();")
assert page.count("await backdrop.load(have);") == 2 # 받기 끝 · 처음 열기
def test_undo_redo_check_area() -> None:
page = _src("D03_PlanAnalysis_UI_Page.ts")
for name in ("function undo()", "function redo()"):
body = page[page.index(name) :]
assert "void ensureCovered();" in body[: body.index("\n }\n")], name
def test_layers_turn_drops_old_answers() -> None:
layers = _src("D03_PlanAnalysis_UI_Layers.ts")
load = layers[layers.index("async function load(have") :]
load = load[: load.index("\n }\n")]
assert "JSON.stringify(have) !== JSON.stringify(area)" in load
assert "areaTurn += 1;" in load and "pending.clear();" in load
fetch = layers[layers.index("async function fetchLayer") :]
assert fetch.index("if (mine !== areaTurn)") < fetch.index("Object.assign(data, got);")
fill = fetch[fetch.index("async function fill") : fetch.index("const canvasLayers")]
assert "data." not in fill and "gl.setData(" not in fill # 모아 두었다가 지금 차례만 씀
assert (
"pending.get(slot) === job && pending.delete(slot)" in layers
) # 옛 일이 새 기다림을 안 지움
# 등고 · 세류 = 예정노선 겹 길(서버가 받은 범위로 자름) · 화면 상자 · 보관함 지문 걷음
assert 'fetchPlanSheetLayer(projectId, "등고선")' in layers
assert 'fetchPlanSheetLayer(projectId, "하천중심선")' in layers
for gone in ("/geojson?layer=", "CONTOUR_PAD_M", "haveTag", "opts.have"):
assert gone not in layers, gone
# ── 서클 · 10 km 알림 ──
def test_one_circle_in_site_title() -> None:
page = _src("D03_PlanAnalysis_UI_Page.ts")
assert "createProgressCircle({ size: 16, inline: true })" in page
assert "siteCard.firstElementChild!.append(circle.root);" in page
assert (
"corner: true" not in page
and "circle.root);\n" not in page.split("map.root.append(")[1][:80]
)
assert "createPlanLayers({ projectId, map, track: work.track })" in page
terrain = _src("D03_PlanAnalysis_UI_Terrain.ts")
assert "params.work.track(watching(params))" in terrain # 지형 받기도 같은 셈
layers = _src("D03_PlanAnalysis_UI_Layers.ts")
assert "track(fetchLayer(" in layers and "track(uploadPlanForestRoads(" in layers
progress = (ROOT / "ui_template" / "ui_template_progress.ts").read_text("utf-8")
assert "ui-progress-circle--inline" in progress
css = (ROOT / "ui_template" / "ui_template_progress.css").read_text("utf-8")
assert ".ui-progress-circle--inline {" in css
def test_far_note() -> None:
page = _src("D03_PlanAnalysis_UI_Page.ts")
assert "farFromSite(epsg, site, [...points, ...allVertices()])" in page
assert "기준점 10 km 밖 점" in page
terrain = _src("D03_PlanAnalysis_UI_Terrain.ts")
assert "list.length - nearSite(epsg, at, list).length" in terrain
+1 -1
View File
@@ -35,7 +35,7 @@ def test_d03_parts():
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클
site = _src( site = _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
+26
View File
@@ -28,6 +28,32 @@
flex-direction: row-reverse; flex-direction: row-reverse;
} }
/* 제목 줄 안 — 패널 카드 제목 오른쪽(캐럿 왼쪽) · 제목 높이 그대로(음수 margin) · 글은 원 왼쪽 한 줄 */
.ui-progress-circle--inline {
--ui-progress-size: 16px;
flex-direction: row-reverse;
gap: var(--spacing-4);
min-width: 0;
margin: -0.4em 0 -0.4em auto;
}
.ui-progress-circle--inline :is(.ui-progress-circle__track, .ui-progress-circle__bar) {
stroke-width: 14;
}
.ui-progress-circle--inline .ui-progress-circle__percent {
display: none;
}
.ui-progress-circle--inline .ui-progress-circle__label {
overflow: hidden;
padding: 0;
background: none;
font-weight: var(--font-weight-regular);
text-overflow: ellipsis;
white-space: nowrap;
}
.ui-progress-circle__dial { .ui-progress-circle__dial {
position: relative; position: relative;
width: var(--ui-progress-size); width: var(--ui-progress-size);
+12 -5
View File
@@ -5,6 +5,7 @@ import "./ui_template_progress.css";
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원. * 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래) * 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽) * · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
* · 제목 줄 안(`inline` · 카드 제목 오른쪽 · 캐럿 왼쪽 · 작은 원 + 글 한 줄 · 진행률은 글 끝에)
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본) * 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
* 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad) * 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad)
* 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })` * 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })`
@@ -21,6 +22,7 @@ const SVG_NS = "http://www.w3.org/2000/svg";
/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */ /** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */
const RADIUS = 42; const RADIUS = 42;
const CIRCUMFERENCE = 2 * Math.PI * RADIUS; const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
export interface ProgressCircleOptions { export interface ProgressCircleOptions {
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */ /** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
@@ -31,6 +33,8 @@ export interface ProgressCircleOptions {
overlay?: boolean; overlay?: boolean;
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */ /** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
corner?: boolean; corner?: boolean;
/** 제목 줄 안(패널 카드 제목 오른쪽 · 캐럿 왼쪽) — 작은 원 + 글 한 줄 · 진행률은 글 끝에 붙음. */
inline?: boolean;
} }
export interface ProgressCircleHandle { export interface ProgressCircleHandle {
@@ -46,7 +50,8 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
root.className = root.className =
"ui-progress-circle" + "ui-progress-circle" +
(options.overlay ? " ui-progress-circle--overlay" : "") + (options.overlay ? " ui-progress-circle--overlay" : "") +
(options.corner ? " ui-progress-circle--corner" : ""); (options.corner ? " ui-progress-circle--corner" : "") +
(options.inline ? " ui-progress-circle--inline" : "");
root.setAttribute("role", "status"); root.setAttribute("role", "status");
root.setAttribute("aria-live", "polite"); root.setAttribute("aria-live", "polite");
if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`);
@@ -86,15 +91,19 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
dial.append(spin, percent); dial.append(spin, percent);
root.append(dial, label); root.append(dial, label);
let text = options.label ?? "";
function set(ratio: number | null, nextLabel?: string): void { function set(ratio: number | null, nextLabel?: string): void {
if (nextLabel !== undefined) label.textContent = nextLabel; if (nextLabel !== undefined) text = label.textContent = nextLabel;
if (options.inline)
label.textContent =
ratio === null ? text : `${text} ${Math.round(clamp01(ratio) * 100)}%`.trim();
if (ratio === null) { if (ratio === null) {
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다). // 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75)); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
percent.textContent = ""; percent.textContent = "";
return; return;
} }
const clamped = Math.min(1, Math.max(0, ratio)); const clamped = clamp01(ratio);
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped))); bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
percent.textContent = `${Math.round(clamped * 100)}%`; percent.textContent = `${Math.round(clamped * 100)}%`;
} }
@@ -126,8 +135,6 @@ export interface ProgressBarHandle {
set: (value: number, label?: string) => void; set: (value: number, label?: string) => void;
} }
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
/** 진행 막대 — 값 하나 또는 쌓임 */ /** 진행 막대 — 값 하나 또는 쌓임 */
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle { export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
const root = document.createElement("div"); const root = document.createElement("div");