Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -290,12 +290,51 @@ async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.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)
|
||||
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
|
||||
|
||||
root, _, _ = await _layer_root(project_id)
|
||||
root, epsg, box = await _layer_root(project_id)
|
||||
try:
|
||||
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
|
||||
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:
|
||||
raise HTTPException(status_code=404, detail=str(error)) from error
|
||||
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)
|
||||
|
||||
@@ -10,8 +10,9 @@
|
||||
* 선 겹(단층 · 지질구조선 · 0차곡) ·
|
||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
|
||||
* 옛 차례 답은 버림(475) · 범위는 서버가 받은 범위로 정함(등고 · 세류 · 도엽 겹 = 서버가 자름) · 지형을 안 받았으면 단추를 막음 ·
|
||||
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
|
||||
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
@@ -27,8 +28,6 @@ import { createLegend } from "@ui/ui_template_flow_legend";
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
import { createMapTools } from "@ui/ui_template_map_tools";
|
||||
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 { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||
import {
|
||||
@@ -83,7 +82,6 @@ import {
|
||||
writeSlopeBands,
|
||||
} from "./D03_PlanAnalysis_UI_SlopeBands";
|
||||
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
|
||||
|
||||
const PREF_KEY = "aislo:pref:d03.layers";
|
||||
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
|
||||
@@ -93,8 +91,6 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
|
||||
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
||||
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||
/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */
|
||||
const CONTOUR_PAD_M = 600;
|
||||
type DataKey = keyof Prepared;
|
||||
|
||||
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
|
||||
@@ -106,8 +102,8 @@ export interface PlanLayers {
|
||||
legend: HTMLElement;
|
||||
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
|
||||
layers: PlanMapLayer[];
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */
|
||||
load: () => Promise<void>;
|
||||
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */
|
||||
load: (have: [number, number, number, number] | null) => Promise<void>;
|
||||
/** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */
|
||||
uploadRoads: (files: File[]) => Promise<PlanForestRoads | null>;
|
||||
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
|
||||
@@ -117,23 +113,26 @@ export interface PlanLayers {
|
||||
counts: Record<string, number>;
|
||||
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
|
||||
gl: Record<string, number>;
|
||||
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
|
||||
area: [number, number, number, number] | null;
|
||||
turn: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function createPlanLayers(opts: {
|
||||
projectId: string;
|
||||
map: PlanMap;
|
||||
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */
|
||||
points: () => Vertex[];
|
||||
/** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */
|
||||
have: () => [number, number, number, number] | null;
|
||||
/** 받는 일을 진행 서클에 셈(끝나면 뺌). */
|
||||
track: <T>(job: Promise<T>) => Promise<T>;
|
||||
}): PlanLayers {
|
||||
const { projectId, map } = opts;
|
||||
const { projectId, map, track } = opts;
|
||||
const on = readPref(PREF_KEY);
|
||||
const data: Partial<Prepared> = {};
|
||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||
let meta: VWorldMeta | null = null;
|
||||
let loadedHave = "";
|
||||
let area: [number, number, number, number] | null = null;
|
||||
/** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */
|
||||
let areaTurn = 0;
|
||||
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
|
||||
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
|
||||
const gl = createGlBase(projectId, (why) => {
|
||||
@@ -268,14 +267,15 @@ export function createPlanLayers(opts: {
|
||||
const slot = key === "satellite" ? key : dataKey(key);
|
||||
const running = pending.get(slot);
|
||||
if (running) return running;
|
||||
const at = meta;
|
||||
const job = fetchLayer(key, at)
|
||||
const mine = areaTurn;
|
||||
const job: Promise<void> = track(fetchLayer(key, meta, mine))
|
||||
.then(() => {
|
||||
if (meta !== at) return;
|
||||
if (mine !== areaTurn) return;
|
||||
sync();
|
||||
map.redrawBase();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (mine !== areaTurn) return;
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
for (const def of LAYERS)
|
||||
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
|
||||
@@ -284,7 +284,7 @@ export function createPlanLayers(opts: {
|
||||
map.redraw();
|
||||
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);
|
||||
return job;
|
||||
}
|
||||
@@ -292,7 +292,7 @@ export function createPlanLayers(opts: {
|
||||
/** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */
|
||||
async function uploadRoads(files: File[]): Promise<PlanForestRoads | null> {
|
||||
const at = meta;
|
||||
const job = uploadPlanForestRoads(projectId, files).then((info) => {
|
||||
const job = track(uploadPlanForestRoads(projectId, files)).then((info) => {
|
||||
if (meta !== at) return info;
|
||||
if (at)
|
||||
data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info };
|
||||
@@ -328,209 +328,206 @@ export function createPlanLayers(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
/** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */
|
||||
const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_");
|
||||
const sheetLayer = (layer: string, extra = ""): Promise<GeoJsonCollection> =>
|
||||
fetchCachedJson<GeoJsonCollection>(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`,
|
||||
);
|
||||
/** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */
|
||||
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise<void> {
|
||||
const got: Partial<Prepared> = {};
|
||||
const sets: Array<Parameters<typeof gl.setData>> = [];
|
||||
const put = (...args: Parameters<typeof gl.setData>): void => void sets.push(args);
|
||||
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> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
prepareLayer(collection, normalizer, labels);
|
||||
const pick = (
|
||||
collection: GeoJsonCollection,
|
||||
test: (props: Record<string, unknown>) => boolean,
|
||||
): GeoJsonCollection => ({
|
||||
...collection,
|
||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||
});
|
||||
if (key === "contour") {
|
||||
// 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함
|
||||
const points = opts.points();
|
||||
const xs = points.map(([x]) => x);
|
||||
const ys = points.map(([, y]) => y);
|
||||
const bbox = (
|
||||
opts.have() ?? [
|
||||
Math.min(...xs) - CONTOUR_PAD_M,
|
||||
Math.min(...ys) - CONTOUR_PAD_M,
|
||||
Math.max(...xs) + CONTOUR_PAD_M,
|
||||
Math.max(...ys) + CONTOUR_PAD_M,
|
||||
]
|
||||
).map((v) => v.toFixed(0));
|
||||
const [collection] = await Promise.all([
|
||||
sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`),
|
||||
glFont("400", "0123456789-"),
|
||||
]);
|
||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
gl.setData("contour", collection);
|
||||
} else if (key === "stream") {
|
||||
const collection = await sheetLayer("도엽_하천중심선");
|
||||
data.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => 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,
|
||||
async function fill(): Promise<void> {
|
||||
const normalizer = createNormalizer(at);
|
||||
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
|
||||
prepareLayer(collection, normalizer, labels);
|
||||
const pick = (
|
||||
collection: GeoJsonCollection,
|
||||
test: (props: Record<string, unknown>) => boolean,
|
||||
): GeoJsonCollection => ({
|
||||
...collection,
|
||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||
});
|
||||
if (key === "contour") {
|
||||
// 받은 범위로 서버가 자름 — 범위가 늘면 `load` 가 다시 받게 함
|
||||
const [collection] = await Promise.all([
|
||||
fetchPlanSheetLayer(projectId, "등고선"),
|
||||
glFont("400", "0123456789-"),
|
||||
]);
|
||||
got.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
put("contour", collection);
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "하천중심선");
|
||||
got.stream = [
|
||||
prepare(pick(collection, (props) => props["구분"] === "세류")),
|
||||
prepare(pick(collection, (props) => props["구분"] !== "세류")),
|
||||
];
|
||||
put("stream", collection);
|
||||
} else if (key === "cadastral") {
|
||||
const collection = await fetchPlanCadastral(projectId);
|
||||
got.cadastral = prepare(collection);
|
||||
put("cadastral", collection);
|
||||
} else if (key === "road" || key === "forestRoad") {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
|
||||
got.road = {
|
||||
all: prepare(collection),
|
||||
small: prepare(
|
||||
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
|
||||
),
|
||||
};
|
||||
gl.setData(key, got.collection);
|
||||
} finally {
|
||||
window.clearTimeout(slow);
|
||||
}
|
||||
} else if (key === "bands") {
|
||||
const info = await fetchPlanBands(projectId);
|
||||
data.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("road", collection);
|
||||
} else if (key === "forestRoadFile") {
|
||||
const info = await fetchPlanForestRoads(projectId);
|
||||
got.forestRoadFile = { layer: prepare(info.collection), info };
|
||||
put("forestRoadFile", info.collection);
|
||||
} else if (key === "landslideZones") {
|
||||
const info = await fetchPlanLandslideZones(projectId);
|
||||
got.landslideZones = { parcels: prepare(info.parcels), info };
|
||||
put("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);
|
||||
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", {
|
||||
features: [
|
||||
...(band("road", "#facc15").features ?? []),
|
||||
...(band("stream", "#22d3ee").features ?? []),
|
||||
],
|
||||
});
|
||||
} else {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
||||
data.place = (collection.features ?? []).flatMap((feature) => {
|
||||
const geometry = feature.geometry;
|
||||
const name = feature.properties?.["명칭"];
|
||||
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
||||
const [lon, lat] = geometry.coordinates as number[];
|
||||
return [{ lon, lat, name }];
|
||||
});
|
||||
await glFont("600", data.place.map((place) => place.name).join(""));
|
||||
gl.setData("place", {
|
||||
features: data.place.map(({ lon, lat, name }) => ({
|
||||
type: "Feature",
|
||||
properties: { name },
|
||||
geometry: { type: "Point", coordinates: [lon, lat] },
|
||||
})),
|
||||
} as GeoJsonCollection);
|
||||
} else {
|
||||
const collection = await fetchPlanSheetLayer(projectId, "지명");
|
||||
got.place = (collection.features ?? []).flatMap((feature) => {
|
||||
const geometry = feature.geometry;
|
||||
const name = feature.properties?.["명칭"];
|
||||
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
|
||||
const [lon, lat] = geometry.coordinates as number[];
|
||||
return [{ lon, lat, name }];
|
||||
});
|
||||
await glFont("600", got.place.map((place) => place.name).join(""));
|
||||
put("place", {
|
||||
features: got.place.map(({ lon, lat, name }) => ({
|
||||
type: "Feature",
|
||||
properties: { name },
|
||||
geometry: { type: "Point", coordinates: [lon, lat] },
|
||||
})),
|
||||
} as GeoJsonCollection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,16 +552,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);
|
||||
// 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4)
|
||||
const have = JSON.stringify(opts.have());
|
||||
const changed =
|
||||
have !== loadedHave ||
|
||||
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
|
||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
||||
[meta, loadedHave] = [next, have];
|
||||
// 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4)
|
||||
const frame = (m: VWorldMeta | null) =>
|
||||
JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]);
|
||||
const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta);
|
||||
[meta, area] = [next, have];
|
||||
if (changed) {
|
||||
areaTurn += 1;
|
||||
pending.clear();
|
||||
for (const key of Object.keys(data)) delete data[key as DataKey];
|
||||
hillshade = null;
|
||||
for (const def of LAYERS)
|
||||
@@ -595,6 +592,8 @@ export function createPlanLayers(opts: {
|
||||
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
|
||||
]),
|
||||
),
|
||||
area,
|
||||
turn: areaTurn,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@
|
||||
*
|
||||
* 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯.
|
||||
* 좌 = 지형(조용히 받기 — 처음 = 등록 기준점 둘레 1 km · 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 ·
|
||||
* 단추 · 막는 창 없음 · 진행 = 이 칸 작은 막대 · 실패만 [다시 받기]) · 지점(목록 · 핀 · 우클릭 메뉴 =
|
||||
* 단추 · 막는 창 없음 · 진행 = 위치 칸 제목 오른쪽 서클(받는 일 모두 · 604-3) · 실패만 [다시 받기] ·
|
||||
* 영역 루틴(475) = 사건(지점 · 선 · 위치 · 되돌리기 · 다시하기 · 처음 열기) → `ensureCovered` → 받기 →
|
||||
* 지형면 · 받은 범위 먼저 → 겹(`backdrop.load(have)` · 지문 바뀜 = 켠 겹 모두 다시) ·
|
||||
* 기준점 10 km 밖 = 위치 칸 알림 · 지운 지점 영역은 그대로) · 지점(목록 · 핀 · 우클릭 메뉴 =
|
||||
* `D03_PlanAnalysis_UI_Points`) · 그리기([긋기] · 제약 칸 · 연도 = 도구줄 「연도 마커」 끌어 놓기 585-3) ·
|
||||
* 구역 · 연도(해 · 선 · 구간 줄 = 고르기 · 고른 줄 밝힘) · [저장] [초기화].
|
||||
* 고른 구간 속성(연도 · 넓이 · 방향 · 지움) = 지도 위 속성 판 `D03_PlanAnalysis_UI_Props`(585-2).
|
||||
@@ -64,7 +67,9 @@ import { createPlanSite } from "./D03_PlanAnalysis_UI_Site";
|
||||
import {
|
||||
aroundSite,
|
||||
boxOfLonLat,
|
||||
createWorkCircle,
|
||||
errorText,
|
||||
farFromSite,
|
||||
nearSite,
|
||||
watchTerrain,
|
||||
type Site,
|
||||
@@ -212,16 +217,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
on3d: open3d,
|
||||
onMeasure: () => setMeasure(!measuring),
|
||||
});
|
||||
const backdrop = createPlanLayers({
|
||||
projectId,
|
||||
map,
|
||||
points: () => [...points, ...allVertices()],
|
||||
have: () => have,
|
||||
});
|
||||
// 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3)
|
||||
const circle = createProgressCircle({ size: 40, corner: true });
|
||||
circle.root.hidden = true;
|
||||
map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root);
|
||||
// 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3)
|
||||
const circle = createProgressCircle({ size: 16, inline: true });
|
||||
const work = createWorkCircle(circle);
|
||||
const backdrop = createPlanLayers({ projectId, map, track: work.track });
|
||||
map.root.append(backdrop.bar, backdrop.legend, props.panel);
|
||||
const main = document.createElement("div");
|
||||
main.className = "d03-plan";
|
||||
main.append(map.root);
|
||||
@@ -235,6 +235,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
onUpload: (files) => void uploadRoads(files),
|
||||
});
|
||||
const siteCard = createPanelCard("위치", false);
|
||||
siteCard.firstElementChild!.append(circle.root);
|
||||
siteCard.append(...siteBox.parts);
|
||||
const pointCard = createPanelCard("지점", false);
|
||||
pointCard.append(marks.panel);
|
||||
@@ -309,6 +310,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
({ network, points, pointNames, site } = last);
|
||||
keepDraft();
|
||||
refresh();
|
||||
void ensureCovered(); // 영역 루틴 — 되돌린 지점 · 선도 받은 범위 밖이면 받음
|
||||
}
|
||||
|
||||
function redo(): void {
|
||||
@@ -318,6 +320,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
({ network, points, pointNames, site } = next);
|
||||
keepDraft();
|
||||
refresh();
|
||||
void ensureCovered();
|
||||
}
|
||||
|
||||
function setMeasure(on: boolean): void {
|
||||
@@ -371,9 +374,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
props.render();
|
||||
pieces.render();
|
||||
marks.render();
|
||||
const far = epsg ? farFromSite(epsg, site, [...points, ...allVertices()]) : 0;
|
||||
const farNote = far ? `기준점 10 km 밖 점 ${far}곳 — 지형 안 받음 · 위치를 옮기세요` : "";
|
||||
siteBox.render({
|
||||
address: site?.address ?? null,
|
||||
note: failed ? `지형 받기 실패 — ${failed}` : note,
|
||||
note: [failed ? `지형 받기 실패 — ${failed}` : note, farNote].filter(Boolean).join(" · "),
|
||||
retry: !busy && !!failed,
|
||||
editable,
|
||||
});
|
||||
@@ -446,12 +451,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
if (info) fitTo([...around(site), ...boxOfLonLat(epsg, info.bounds)]);
|
||||
}
|
||||
|
||||
/** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */
|
||||
/** 도는 받기를 지켜보고 끝나면 지형면 · 받은 범위를 먼저 다 읽은 뒤 겹(475 — 동시면 겹이 옛 범위로 판정). */
|
||||
async function follow(): Promise<void> {
|
||||
const job = await watch();
|
||||
if (!job || !root.isConnected) return;
|
||||
failed = job.error;
|
||||
await Promise.all([loadSurface(), backdrop.load()]);
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await backdrop.load(have);
|
||||
refresh();
|
||||
await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기
|
||||
}
|
||||
@@ -460,18 +467,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
watchTerrain({
|
||||
projectId,
|
||||
alive: () => root.isConnected,
|
||||
circle,
|
||||
work,
|
||||
setBusy: (on) => {
|
||||
busy = on;
|
||||
refresh();
|
||||
},
|
||||
});
|
||||
|
||||
/** 지형면 · 받은 범위(have) — 서클에 셈. */
|
||||
async function loadSurface(): Promise<void> {
|
||||
try {
|
||||
surface = await fetchPlanSurface(projectId);
|
||||
const extent = await fetchTerrainExtent(projectId, null, []);
|
||||
have = extent.have_m;
|
||||
[surface, have] = await work.track(
|
||||
Promise.all([
|
||||
fetchPlanSurface(projectId),
|
||||
fetchTerrainExtent(projectId, null, []).then((extent) => extent.have_m),
|
||||
]),
|
||||
);
|
||||
} catch (error) {
|
||||
showToast(`지형면을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
}
|
||||
@@ -565,6 +576,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
base: () => points,
|
||||
have: () => have,
|
||||
busy: () => busy,
|
||||
work: work.count,
|
||||
site: () => site,
|
||||
pickSite,
|
||||
lineNames: () => lineNames(network.lines),
|
||||
@@ -628,7 +640,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
|
||||
refresh();
|
||||
await loadSurface();
|
||||
refresh();
|
||||
await backdrop.load();
|
||||
await backdrop.load(have);
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
if (job.running) {
|
||||
ready = true;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/* =============================================================================
|
||||
* 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";
|
||||
@@ -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 {
|
||||
projectId: string;
|
||||
alive: () => boolean;
|
||||
circle: ReturnType<typeof createProgressCircle>;
|
||||
work: WorkCircle;
|
||||
/** 받는 중 켬끔(페이지 `busy`) 뒤 화면 갱신. */
|
||||
setBusy: (on: boolean) => void;
|
||||
}
|
||||
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
/** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 위치 칸 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 ·
|
||||
* 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */
|
||||
export async function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
|
||||
const { projectId, alive, circle, setBusy } = params;
|
||||
export function watchTerrain(params: WatchParams): Promise<PlanJob | null> {
|
||||
return params.work.track(watching(params));
|
||||
}
|
||||
|
||||
async function watching(params: WatchParams): Promise<PlanJob | null> {
|
||||
const { projectId, alive, work, setBusy } = params;
|
||||
setBusy(true);
|
||||
circle.set(null, "지형");
|
||||
circle.root.hidden = false;
|
||||
work.terrain({ ratio: 0, text: "" });
|
||||
try {
|
||||
for (;;) {
|
||||
if (!alive()) return null;
|
||||
const job = (await fetchPlanProgress(projectId)).terrain;
|
||||
circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim());
|
||||
circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`;
|
||||
work.terrain({ ratio: job.percent / 100, text: job.message ?? "" });
|
||||
if (!job.running) {
|
||||
const cut = !job.error && (job.interrupted || job.percent < 100);
|
||||
if (job.error) showToast(job.error, "error");
|
||||
@@ -88,7 +134,7 @@ export async function watchTerrain(params: WatchParams): Promise<PlanJob | null>
|
||||
showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error");
|
||||
return { running: false, percent: 0, message: null, error: INTERRUPTED };
|
||||
} finally {
|
||||
circle.root.hidden = true;
|
||||
work.terrain(null);
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -35,7 +35,7 @@ def test_d03_parts():
|
||||
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||
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 받기 = 지도 귀퉁이 서클
|
||||
assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클
|
||||
site = _src(
|
||||
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
|
||||
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
|
||||
|
||||
@@ -28,6 +28,32 @@
|
||||
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 {
|
||||
position: relative;
|
||||
width: var(--ui-progress-size);
|
||||
|
||||
@@ -5,6 +5,7 @@ import "./ui_template_progress.css";
|
||||
* 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원.
|
||||
* 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래)
|
||||
* · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽)
|
||||
* · 제목 줄 안(`inline` · 카드 제목 오른쪽 · 캐럿 왼쪽 · 작은 원 + 글 한 줄 · 진행률은 글 끝에)
|
||||
* 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본)
|
||||
* 상태 = 기본 · 오류(`.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" }] })`
|
||||
@@ -21,6 +22,7 @@ const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
/** viewBox 기준 반지름 — 실제 크기는 CSS(--ui-progress-size)로 정한다. */
|
||||
const RADIUS = 42;
|
||||
const CIRCUMFERENCE = 2 * Math.PI * RADIUS;
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
|
||||
export interface ProgressCircleOptions {
|
||||
/** 지름(px). 기본 72(공통 로딩 스피너와 같은 무게감). */
|
||||
@@ -31,6 +33,8 @@ export interface ProgressCircleOptions {
|
||||
overlay?: boolean;
|
||||
/** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */
|
||||
corner?: boolean;
|
||||
/** 제목 줄 안(패널 카드 제목 오른쪽 · 캐럿 왼쪽) — 작은 원 + 글 한 줄 · 진행률은 글 끝에 붙음. */
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export interface ProgressCircleHandle {
|
||||
@@ -46,7 +50,8 @@ export function createProgressCircle(options: ProgressCircleOptions = {}): Progr
|
||||
root.className =
|
||||
"ui-progress-circle" +
|
||||
(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("aria-live", "polite");
|
||||
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);
|
||||
root.append(dial, label);
|
||||
|
||||
let text = options.label ?? "";
|
||||
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) {
|
||||
// 진행률 미상 — 4분의 1 호만 남긴다(회전은 껍데기가 늘 맡는다).
|
||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * 0.75));
|
||||
percent.textContent = "";
|
||||
return;
|
||||
}
|
||||
const clamped = Math.min(1, Math.max(0, ratio));
|
||||
const clamped = clamp01(ratio);
|
||||
bar.setAttribute("stroke-dashoffset", String(CIRCUMFERENCE * (1 - clamped)));
|
||||
percent.textContent = `${Math.round(clamped * 100)}%`;
|
||||
}
|
||||
@@ -126,8 +135,6 @@ export interface ProgressBarHandle {
|
||||
set: (value: number, label?: string) => void;
|
||||
}
|
||||
|
||||
const clamp01 = (v: number): number => Math.min(1, Math.max(0, v));
|
||||
|
||||
/** 진행 막대 — 값 하나 또는 쌓임 */
|
||||
export function createProgressBar(opts: ProgressBarOptions = {}): ProgressBarHandle {
|
||||
const root = document.createElement("div");
|
||||
|
||||
Reference in New Issue
Block a user