perf(전처리 화면): 343-3 B04 다시 열기 받는 양 — 보관함 저장 실패 고침 · 점군 고정 표본 이진 · 꺼진 층은 켤 때 · 남은 층 ETag · 등고선 주소 한 벌
- b_asset_cache — 쓰기 끝 = 트랜잭션 완료(용량 초과 취소 잡음) · 실패 한 줄 경고(까닭당 한 번) · onblocked · onversionchange · 열기 실패 null 안 기억 · 지우기는 색인 열쇠 · 앞머리 범위(값 안 읽음)
- 점군 미리보기 — 전처리 때 고정 표본 이진(preview_points_{필터}.bin · 원천 지문 같으면 건너뜀) · GET point-cloud/binary(ETag) · 화면은 fetchCachedBytes(IndexedDB) + decodePointPreview(py 짝)
- B04 지도 — 켜진 층만 처음에 받음 · 꺼진 층은 켤 때 · 같은 층 겹쳐 받기 막음
- geojson(지적도 · 행정구역 · 등고선) 파일 그대로 cached_file_response(ETag) · 등고선 단순화본은 원본이 새로우면 다시 만듦
- 등고선 주소 contourAssetUrl 하나(B04 · 미리 받기) — B05 두 곳은 sub5 몫
- 시험 test_343_3_browser_cache 13건 + helper_343_3_asset_cache.cjs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014utrjfoW831Vc5wJUd13kL
This commit is contained in:
@@ -18,6 +18,7 @@ import {
|
||||
API_TIMEOUT_MS,
|
||||
} from "@config/config_frontend";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
|
||||
import type { GroupItems, SummaryItem } from "../common_util/common_util_summary_item";
|
||||
|
||||
/** 지표면 분석 실행 요청 (SurfaceAnalyzeRequest) */
|
||||
@@ -244,17 +245,49 @@ export async function listSurfaceInputFiles(
|
||||
});
|
||||
}
|
||||
|
||||
/** 점군 미리보기 고정 표본 이진(IndexedDB 보관 · ETag) — 다시 열 때 받지 않는다. */
|
||||
export async function fetchSurfacePointCloud(
|
||||
projectId: string,
|
||||
filter?: string,
|
||||
): Promise<SurfacePointCloudSampleResponse> {
|
||||
const query = filter ? `?filter=${encodeURIComponent(filter)}` : "";
|
||||
return requestJson<SurfacePointCloudSampleResponse>(
|
||||
`/projects/${projectId}/surface/point-cloud${query}`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
const bytes = await fetchCachedBytes(
|
||||
projectId,
|
||||
`${API_BASE_URL}/projects/${projectId}/surface/point-cloud/binary${query}`,
|
||||
);
|
||||
return { status: "success", project_id: projectId, ...decodePointPreview(bytes) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 점군 미리보기 이진 풀기 — 짝 = `B04_PreProcess_Engine_PointPreview.py` `ensure_point_preview`.
|
||||
* [머리 길이 u32][머리 JSON][4 배수 채움][xyz f32 × 3n][rgb u16 × 3n].
|
||||
*/
|
||||
export function decodePointPreview(
|
||||
bytes: ArrayBuffer,
|
||||
): Omit<SurfacePointCloudSampleResponse, "status" | "project_id"> {
|
||||
const headLength = new DataView(bytes).getUint32(0, true);
|
||||
const head = JSON.parse(new TextDecoder().decode(new Uint8Array(bytes, 4, headLength))) as {
|
||||
point_count: number;
|
||||
sampled_count: number;
|
||||
bounds: SurfaceBounds;
|
||||
has_rgb: boolean;
|
||||
};
|
||||
const count = head.sampled_count;
|
||||
const offset = Math.ceil((4 + headLength) / 4) * 4;
|
||||
const xyz = new Float32Array(bytes, offset, count * 3);
|
||||
const points: [number, number, number][] = new Array(count);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
points[index] = [xyz[index * 3], xyz[index * 3 + 1], xyz[index * 3 + 2]];
|
||||
}
|
||||
let rgb: [number, number, number][] | undefined;
|
||||
if (head.has_rgb) {
|
||||
const colors = new Uint16Array(bytes, offset + xyz.byteLength, count * 3);
|
||||
rgb = new Array(count);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
rgb[index] = [colors[index * 3], colors[index * 3 + 1], colors[index * 3 + 2]];
|
||||
}
|
||||
}
|
||||
return { point_count: head.point_count, sampled_count: count, bounds: head.bounds, points, rgb };
|
||||
}
|
||||
|
||||
/** 확정 지표면 구성 + 지형 가장자리만 조회한다(수 KB).
|
||||
|
||||
@@ -334,6 +334,15 @@ def run_surface_analysis(
|
||||
):
|
||||
continue
|
||||
cache_ground_points(structured_path, filter_key, mask)
|
||||
# 점군 미리보기 고정 표본(이진) — 원천이 그대로면 머리만 보고 건너뜀
|
||||
try:
|
||||
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview
|
||||
|
||||
ensure_point_preview(structured_path, None, data["xyz"])
|
||||
for filter_key in masks:
|
||||
ensure_point_preview(processed_dir / f"ground_points_{filter_key}.npz", filter_key)
|
||||
except Exception as exc: # noqa: BLE001 — 미리보기 실패가 분석을 막지 않음
|
||||
logger.warning("B04 점군 미리보기 이진 생성 실패: %s", exc)
|
||||
|
||||
# 3. 지표면 5종 모델 빌드
|
||||
_report(70, "surface_model", "지표면 모델 생성 중")
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""B04 점군 미리보기 — 고정 표본 이진 파일(전처리 때 한 번 · ETag 로 내보냄).
|
||||
|
||||
JSON(27.8 MB · 요청마다 점군 풀고 표본)을 대신한다. 표본 규칙은 JSON 길과 같다
|
||||
(`sample_points` · 같은 씨앗) — 같은 점 · 같은 float32 값.
|
||||
|
||||
파일 = [머리 길이 uint32 LE][머리 JSON utf-8][0 채움 → 4 배수][xyz float32 × 3n][rgb uint16 × 3n].
|
||||
머리 = {version, source(원천 npz mtime_ns:크기), point_count, sampled_count, bounds, has_rgb}.
|
||||
TS 짝(읽기) = `B04_PreProcess_Api_Fetch.ts` `decodePointPreview`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
|
||||
from common_util.common_util_atomic import atomic_write_bytes
|
||||
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 500_000
|
||||
PREVIEW_VERSION = 1
|
||||
_SAMPLE_SEED = 20260710
|
||||
|
||||
|
||||
def sample_points(xyz: np.ndarray, rgb: np.ndarray | None) -> tuple[np.ndarray, np.ndarray | None]:
|
||||
"""화면용 고정 표본 — 상한을 넘으면 같은 씨앗으로 고른다(열 때마다 같은 그림)."""
|
||||
source_count = int(len(xyz))
|
||||
if rgb is not None and (rgb.ndim == 0 or len(rgb) != source_count):
|
||||
rgb = None
|
||||
if source_count <= POINT_CLOUD_SAMPLE_LIMIT:
|
||||
return xyz, rgb
|
||||
indexes = np.random.default_rng(_SAMPLE_SEED).choice(
|
||||
source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False
|
||||
)
|
||||
return xyz[indexes], (rgb[indexes] if rgb is not None else None)
|
||||
|
||||
|
||||
def preview_path(processed_dir: Path, filter_key: str | None) -> Path:
|
||||
return processed_dir / f"preview_points_{filter_key or 'raw'}.bin"
|
||||
|
||||
|
||||
def _stamp(path: Path) -> str:
|
||||
stat = path.stat()
|
||||
return f"{stat.st_mtime_ns}:{stat.st_size}"
|
||||
|
||||
|
||||
def read_preview_header(path: Path) -> dict[str, Any] | None:
|
||||
"""머리만 읽는다 — 없거나 깨졌으면 None."""
|
||||
try:
|
||||
with path.open("rb") as file:
|
||||
(length,) = struct.unpack("<I", file.read(4))
|
||||
return json.loads(file.read(length).decode("utf-8"))
|
||||
except (OSError, ValueError, struct.error):
|
||||
return None
|
||||
|
||||
|
||||
def ensure_point_preview(source_path: Path, filter_key: str | None, xyz_source: Any = None) -> Path:
|
||||
"""원천 npz(structured · ground_points_*) 그대로면 있는 파일 · 아니면 한 번 만든다.
|
||||
|
||||
`xyz_source` — 이미 메모리에 있는 xyz(또는 쓸 때 푸는 대리자) · 없으면 npz 에서 푼다.
|
||||
"""
|
||||
target = preview_path(source_path.parent, filter_key)
|
||||
stamp = _stamp(source_path)
|
||||
header = read_preview_header(target)
|
||||
if header and header.get("version") == PREVIEW_VERSION and header.get("source") == stamp:
|
||||
return target
|
||||
with np.load(source_path) as data:
|
||||
xyz = np.asarray(data["xyz"] if xyz_source is None else xyz_source, dtype=np.float32)
|
||||
bounds = np.asarray(data["bounds"], dtype=np.float64)
|
||||
point_count = int(data["point_count"]) if "point_count" in data else int(len(xyz))
|
||||
rgb = np.asarray(data["rgb"]) if "rgb" in data else None
|
||||
sample, rgb_sample = sample_points(xyz, rgb)
|
||||
header = {
|
||||
"version": PREVIEW_VERSION,
|
||||
"source": stamp,
|
||||
"point_count": point_count,
|
||||
"sampled_count": int(len(sample)),
|
||||
"bounds": {
|
||||
"x_min": float(bounds[0, 0]),
|
||||
"x_max": float(bounds[0, 1]),
|
||||
"y_min": float(bounds[1, 0]),
|
||||
"y_max": float(bounds[1, 1]),
|
||||
"z_min": float(bounds[2, 0]),
|
||||
"z_max": float(bounds[2, 1]),
|
||||
},
|
||||
"has_rgb": rgb_sample is not None,
|
||||
}
|
||||
head = json.dumps(header).encode("utf-8")
|
||||
pad = (-(4 + len(head))) % 4
|
||||
parts = [struct.pack("<I", len(head)), head, b"\0" * pad]
|
||||
parts.append(np.ascontiguousarray(sample, dtype="<f4").tobytes())
|
||||
if rgb_sample is not None:
|
||||
parts.append(np.ascontiguousarray(rgb_sample, dtype="<u2").tobytes())
|
||||
atomic_write_bytes(target, b"".join(parts))
|
||||
return target
|
||||
@@ -8,8 +8,8 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import numpy as np
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.responses import JSONResponse, Response
|
||||
|
||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||
from B04_PreProcess.B04_PreProcess_Engine import (
|
||||
@@ -22,6 +22,7 @@ from B04_PreProcess.B04_PreProcess_Engine_Extent import (
|
||||
project_epsg_from_prj,
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Engine_Ground import available_filters
|
||||
from B04_PreProcess.B04_PreProcess_Engine_PointPreview import ensure_point_preview, sample_points
|
||||
from B04_PreProcess.B04_PreProcess_Repository import (
|
||||
clear_confirmed_surface_models,
|
||||
get_input_file,
|
||||
@@ -63,6 +64,7 @@ from B04_PreProcess.B04_PreProcess_Schema import (
|
||||
)
|
||||
from B04_PreProcess.B04_PreProcess_Service import confirm_surface_selection
|
||||
from common_util.common_util_auth import require_system_admin
|
||||
from common_util.common_util_http_cache import cached_file_response
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import (
|
||||
get_surface_confirmation_params,
|
||||
@@ -77,7 +79,6 @@ from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 500_000
|
||||
|
||||
# 상태 조회·모델 프리뷰 엔드포인트는 700줄 제한으로 `_Router_Status` 로 떼어내
|
||||
# 여기서 그대로 실어 붙인다(경로·응답 불변, 2026-09-04).
|
||||
@@ -298,43 +299,70 @@ async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListRespo
|
||||
)
|
||||
|
||||
|
||||
async def _point_cloud_source(project_id: UUID, filter: str | None) -> Path | JSONResponse:
|
||||
"""미리보기 원천 npz — 원본(structured) 또는 필터 지면점 저장본(낡으면 다시 만듦)."""
|
||||
async with get_db_pool().acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
if filter is None:
|
||||
return structured_path
|
||||
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
|
||||
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
|
||||
if filter not in available_filters():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
||||
)
|
||||
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
||||
cache_is_current = False
|
||||
if source_path.is_file():
|
||||
with np.load(source_path) as cached:
|
||||
cache_is_current = (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
)
|
||||
if not cache_is_current:
|
||||
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
||||
return source_path
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/point-cloud/binary", response_model=None)
|
||||
async def get_surface_point_cloud_binary(
|
||||
project_id: UUID, request: Request, filter: str | None = None
|
||||
) -> Response:
|
||||
"""점군 미리보기 고정 표본 이진(ETag) — 전처리 때 만든 파일 · 없거나 낡으면 한 번 만듦."""
|
||||
try:
|
||||
source_path = await _point_cloud_source(project_id, filter)
|
||||
if isinstance(source_path, JSONResponse):
|
||||
return source_path
|
||||
target = await asyncio.to_thread(ensure_point_preview, source_path, filter)
|
||||
return cached_file_response(request, target, "application/octet-stream")
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 포인트클라우드 이진 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
|
||||
async def get_surface_point_cloud(
|
||||
project_id: UUID,
|
||||
filter: str | None = None,
|
||||
) -> SurfacePointCloudSampleResponse | JSONResponse:
|
||||
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다."""
|
||||
pool = get_db_pool()
|
||||
"""원본 또는 필터링된 B04 3D 미리보기 포인트를 반환한다(JSON · 화면은 이진 길을 씀)."""
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
structured_path = project_root / "B04_PreProcess" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
|
||||
source_path = structured_path
|
||||
if filter is not None:
|
||||
# 필터 목록은 Ground 의 등록부 하나에서 나온다 — 여기 따로 적어 두면
|
||||
# 필터가 늘 때마다 이 화면만 400으로 막힌다(2026-09-01 실사고).
|
||||
if filter not in available_filters():
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={"status": "error", "message": "지원하지 않는 지면 필터입니다."},
|
||||
)
|
||||
source_path = structured_path.parent / f"ground_points_{filter}.npz"
|
||||
cache_is_current = False
|
||||
if source_path.is_file():
|
||||
with np.load(source_path) as cached:
|
||||
cache_is_current = (
|
||||
"cache_version" in cached
|
||||
and int(cached["cache_version"]) == GROUND_POINT_CACHE_VERSION
|
||||
)
|
||||
if not cache_is_current:
|
||||
source_path = await asyncio.to_thread(cache_ground_points, structured_path, filter)
|
||||
source_path = await _point_cloud_source(project_id, filter)
|
||||
if isinstance(source_path, JSONResponse):
|
||||
return source_path
|
||||
|
||||
with np.load(source_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
@@ -342,22 +370,8 @@ async def get_surface_point_cloud(
|
||||
point_count = (
|
||||
int(structured["point_count"]) if "point_count" in structured else int(len(xyz))
|
||||
)
|
||||
source_count = int(len(xyz))
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260710)
|
||||
indexes = rng.choice(source_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
sample = xyz[indexes]
|
||||
else:
|
||||
sample = xyz
|
||||
|
||||
rgb_sample = None
|
||||
if "rgb" in structured:
|
||||
rgb_arr = np.asarray(structured["rgb"])
|
||||
if rgb_arr.ndim > 0 and len(rgb_arr) == source_count:
|
||||
if source_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rgb_sample = rgb_arr[indexes]
|
||||
else:
|
||||
rgb_sample = rgb_arr
|
||||
rgb = np.asarray(structured["rgb"]) if "rgb" in structured else None
|
||||
sample, rgb_sample = sample_points(xyz, rgb)
|
||||
|
||||
return SurfacePointCloudSampleResponse(
|
||||
project_id=str(project_id),
|
||||
|
||||
@@ -161,25 +161,28 @@ async def get_project_geojson(
|
||||
|
||||
if layer == "등고선":
|
||||
simplified_filepath = target_dir / "등고선_bounds_simplified.geojson"
|
||||
if simplified_filepath.exists():
|
||||
# 원본이 새로 받혔으면(전처리 다시) 단순화본도 다시 만든다.
|
||||
fresh = (
|
||||
simplified_filepath.exists()
|
||||
and simplified_filepath.stat().st_mtime_ns >= filepath.stat().st_mtime_ns
|
||||
)
|
||||
if not fresh:
|
||||
try:
|
||||
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
pass
|
||||
import geopandas as gpd
|
||||
|
||||
try:
|
||||
import geopandas as gpd
|
||||
gdf = gpd.read_file(filepath)
|
||||
gdf["geometry"] = gdf["geometry"].simplify(
|
||||
tolerance=0.00003, preserve_topology=True
|
||||
)
|
||||
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
|
||||
fresh = True
|
||||
except Exception as e:
|
||||
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
|
||||
if fresh:
|
||||
filepath = simplified_filepath
|
||||
|
||||
gdf = gpd.read_file(filepath)
|
||||
gdf["geometry"] = gdf["geometry"].simplify(
|
||||
tolerance=0.00003, preserve_topology=True
|
||||
)
|
||||
simplified_filepath.write_text(gdf.to_json(), encoding="utf-8")
|
||||
return json.loads(simplified_filepath.read_text(encoding="utf-8"))
|
||||
except Exception as e:
|
||||
logger.warning("등고선 단순화 처리 실패 (원본 전송): %s", e)
|
||||
|
||||
return json.loads(filepath.read_text(encoding="utf-8"))
|
||||
# 파일 그대로 + ETag — 다시 묶지 않고 두 번째부터 304.
|
||||
return cached_file_response(request, filepath, "application/geo+json")
|
||||
except Exception as exc:
|
||||
return JSONResponse(status_code=500, content={"status": "error", "message": str(exc)})
|
||||
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
* ========================================================================== */
|
||||
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch";
|
||||
import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender";
|
||||
|
||||
export const BACKGROUND_LAYERS = ["white", "satellite", "hybrid"] as const;
|
||||
// 유수방향은 화면에 쓰지 않기로 해 목록에서 뺐다(2026-08-01 사용자 지시).
|
||||
@@ -45,6 +48,37 @@ export const GIS_DEFAULT_ON: Record<GisLayer, boolean> = {
|
||||
도엽_옹벽석축: false,
|
||||
};
|
||||
|
||||
/** 받는 중인 층 — 같은 층을 겹쳐 받지 않는다. */
|
||||
const inFlight = new Map<string, Promise<GeoJsonCollection | null>>();
|
||||
|
||||
/** 레이어 하나 받기 — 도엽은 보관함(IndexedDB) · 나머지는 서버 ETag. 실패면 null.
|
||||
* 꺼진 층은 켤 때 부른다(처음 열기에 도엽 등고선 37 MB 를 받지 않음). */
|
||||
export function fetchGisLayer(
|
||||
projectId: string,
|
||||
layer: GisLayer,
|
||||
): Promise<GeoJsonCollection | null> {
|
||||
const key = `${projectId}|${layer}`;
|
||||
const pending = inFlight.get(key) ?? downloadLayer(projectId, layer);
|
||||
inFlight.set(key, pending);
|
||||
void pending.finally(() => inFlight.delete(key));
|
||||
return pending;
|
||||
}
|
||||
|
||||
async function downloadLayer(
|
||||
projectId: string,
|
||||
layer: GisLayer,
|
||||
): Promise<GeoJsonCollection | null> {
|
||||
try {
|
||||
return (
|
||||
layer.startsWith("도엽_")
|
||||
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
|
||||
: await fetchGisGeoJson(projectId, layer)
|
||||
) as GeoJsonCollection;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 등고 라벨(계곡선 수치) 기본 표시 여부. */
|
||||
export const CONTOUR_LABEL_DEFAULT_ON = false;
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import { wheelZoomsIn } from "../A00_Common/touch_bridge";
|
||||
import { createProgressCircle } from "@ui/ui_template_progress";
|
||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
||||
import {
|
||||
fetchGisGeoJson,
|
||||
fetchPlannedRoute,
|
||||
fetchVWorldMeta,
|
||||
getVWorldMapUrl,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
CONTOUR_LABEL_KEYS,
|
||||
GIS_DEFAULT_ON,
|
||||
GIS_LAYERS,
|
||||
fetchGisLayer,
|
||||
gisLayerColor,
|
||||
type BackgroundLayer,
|
||||
type GisLayer,
|
||||
@@ -36,7 +35,6 @@ import {
|
||||
drawPreparedLayer,
|
||||
routeLineColor,
|
||||
ROUTE_LINE_WIDTH,
|
||||
type GeoJsonCollection,
|
||||
type MapRect,
|
||||
type Normalizer,
|
||||
type PlanBounds,
|
||||
@@ -330,6 +328,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
backgroundImages.forEach((image, layer) => {
|
||||
image.hidden = !activeBackgrounds.has(layer);
|
||||
});
|
||||
activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer));
|
||||
empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0;
|
||||
scheduleDraw();
|
||||
}
|
||||
@@ -453,6 +452,16 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
});
|
||||
}
|
||||
|
||||
/** 켠 층이 아직 없으면 그때 받는다(같은 층 겹쳐 받기는 fetchGisLayer 가 막음). */
|
||||
async function loadLayerOnDemand(layer: GisLayer): Promise<void> {
|
||||
const projectId = currentProjectId;
|
||||
if (!projectId || !meta || preparedLayers.has(layer)) return;
|
||||
const data = await fetchGisLayer(projectId, layer);
|
||||
if (!data || projectId !== currentProjectId || !normalizer) return;
|
||||
preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer]));
|
||||
scheduleDraw();
|
||||
}
|
||||
|
||||
async function loadLayers(): Promise<void> {
|
||||
if (!currentProjectId) return;
|
||||
const projectId = currentProjectId;
|
||||
@@ -471,26 +480,17 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
||||
fetchVWorldMeta(projectId, "satellite"),
|
||||
fetchPlannedRoute(projectId).catch(() => ({ status: "error", points: [] })),
|
||||
]);
|
||||
// 레이어가 끝나는 대로 진행률을 올린다 — 10종을 다 받을 때까지 화면이 비어 있어서다.
|
||||
// 켜진 층만 처음에 받는다(꺼진 층은 켤 때) — 끝나는 대로 진행률을 올린다.
|
||||
const firstLayers = GIS_LAYERS.filter((layer) => activeGisLayers.has(layer));
|
||||
let done = 0;
|
||||
const loadedLayers = await Promise.all(
|
||||
GIS_LAYERS.map(async (layer) => {
|
||||
try {
|
||||
// 도엽 레이어는 표시용 사본이라 보관함에 담아 두고 새로고침 때 그대로 쓴다.
|
||||
const data = (
|
||||
layer.startsWith("도엽_")
|
||||
? await fetchCachedSheetLayer<GeoJsonCollection>(projectId, layer)
|
||||
: await fetchGisGeoJson(projectId, layer)
|
||||
) as GeoJsonCollection;
|
||||
return [layer, data] as const;
|
||||
} catch {
|
||||
return [layer, null] as const;
|
||||
} finally {
|
||||
done += 1;
|
||||
if (sequence === loadSequence) {
|
||||
showProgress(done / GIS_LAYERS.length, `도엽 레이어 ${done}/${GIS_LAYERS.length}`);
|
||||
}
|
||||
firstLayers.map(async (layer) => {
|
||||
const data = await fetchGisLayer(projectId, layer);
|
||||
done += 1;
|
||||
if (sequence === loadSequence) {
|
||||
showProgress(done / firstLayers.length, `도엽 레이어 ${done}/${firstLayers.length}`);
|
||||
}
|
||||
return [layer, data] as const;
|
||||
}),
|
||||
);
|
||||
if (sequence !== loadSequence) return;
|
||||
|
||||
@@ -9,8 +9,7 @@
|
||||
|
||||
import * as THREE from "three";
|
||||
|
||||
import { API_BASE_URL } from "@config/config_frontend";
|
||||
import { fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
import { contourAssetUrl, fetchCachedJson } from "../A00_Common/b_asset_cache";
|
||||
|
||||
/** 화면에 띄우는 등고 라벨 상한 — 긴 등고선부터 채운다(분리 전 상수 그대로). */
|
||||
const MAX_CONTOUR_LABELS = 40;
|
||||
@@ -43,7 +42,7 @@ export async function loadContourLinesInto(
|
||||
): Promise<boolean> {
|
||||
const interval = parseFloat(ctx.intervalInput.value) || 1.0;
|
||||
const projectId = ctx.projectId();
|
||||
const contourUrl = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}&recalculate=${recalculate}`;
|
||||
const contourUrl = contourAssetUrl(projectId, modelId, interval, isSmooth, recalculate);
|
||||
|
||||
try {
|
||||
// 등고선도 보관함에서 먼저 찾는다 — 같은 파일을 새로고침마다 다시 내려받지 않는다.
|
||||
|
||||
Reference in New Issue
Block a user