feat(위성): 위성 조각 프로젝트당 150 MB 한도 · 오래 안 본 것부터 지움 · 한 장짜리 위성 걷음 · B 저장 복사에서 조각 뺌 (PLAN 590-2 · 요구 452 · 453)
- 조각 저장본 한 벌(_derived/plan_tile) — 처음 열 때 한 번 셈 · 받을 때 더함 · 넘치면 묵은 것부터 80 % 까지 · 하루 넘게 묵은 것만 본 때 찍음 - 한 장짜리 위성 그림 안 만듦(메타 = 지도 틀만) · vworld-map 위성 404 · 미리 받기 줄 걷음 - 옛 캔버스(D03 · B04 · B05 배수유역) = 위성 바탕 없이 + 알림 - 저장 떠 두기에서 조각 폴더 뺌 - 시험 590_2 새로 · 283 한 본문 손 고침 실패는 고치기 전부터(이 고침과 무관) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -477,10 +477,6 @@ export async function preloadSurfaceAssets(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// 위성사진은 <img>로 표시하므로 보관함이 아니라 브라우저 자체 캐시를 데워 둔다.
|
|
||||||
await fetch(`${API_BASE_URL}/projects/${projectId}/vworld-map?layer_name=satellite`, {
|
|
||||||
credentials: "include",
|
|
||||||
}).catch(() => null);
|
|
||||||
|
|
||||||
report("준비 완료", 1);
|
report("준비 완료", 1);
|
||||||
return confirmed.signature;
|
return confirmed.signature;
|
||||||
|
|||||||
@@ -74,7 +74,11 @@ def download_vworld_satellite_map(
|
|||||||
) -> Path | None:
|
) -> Path | None:
|
||||||
"""PRJ 좌표 정보와 지형 범위(bounds)를 바탕으로,
|
"""PRJ 좌표 정보와 지형 범위(bounds)를 바탕으로,
|
||||||
해당 임도 산림 지역을 감싸는 VWorld 지도 타일을 다운로드하여 하나의 PNG 텍스처로 병합합니다.
|
해당 임도 산림 지역을 감싸는 VWorld 지도 타일을 다운로드하여 하나의 PNG 텍스처로 병합합니다.
|
||||||
|
|
||||||
|
위성(`Satellite`)은 그림 없이 메타(지도 틀)만 씀 → 메타 경로를 돌려줌(590-2 · 위성 바탕 =
|
||||||
|
GL 조각 `plan/tile`).
|
||||||
"""
|
"""
|
||||||
|
meta_only = layer_name.lower() == "satellite"
|
||||||
output_dir.mkdir(parents=True, exist_ok=True)
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
file_name = f"vworld_{layer_name.lower()}.png"
|
file_name = f"vworld_{layer_name.lower()}.png"
|
||||||
map_png_path = output_dir / file_name
|
map_png_path = output_dir / file_name
|
||||||
@@ -118,11 +122,11 @@ def download_vworld_satellite_map(
|
|||||||
break
|
break
|
||||||
zoom -= 1
|
zoom -= 1
|
||||||
|
|
||||||
# 4. 개별 타일 다운로드 및 이미지 병합
|
# 4. 개별 타일 다운로드 및 이미지 병합(위성 = 건너뜀)
|
||||||
map_img = Image.new("RGBA", (tile_w * 256, tile_h * 256))
|
map_img = Image.new("RGBA", (1, 1) if meta_only else (tile_w * 256, tile_h * 256))
|
||||||
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
||||||
|
|
||||||
for ty in range(y_start, y_end + 1):
|
for ty in range(y_start, y_end + 1) if not meta_only else ():
|
||||||
for tx in range(x_start, x_end + 1):
|
for tx in range(x_start, x_end + 1):
|
||||||
try:
|
try:
|
||||||
normalized_layer = layer_name.lower()
|
normalized_layer = layer_name.lower()
|
||||||
@@ -176,7 +180,8 @@ def download_vworld_satellite_map(
|
|||||||
img_x_max, img_y_min = rev_transformer.transform(lon_right, lat_bottom)
|
img_x_max, img_y_min = rev_transformer.transform(lon_right, lat_bottom)
|
||||||
|
|
||||||
# 6. 이미지 저장 및 메타데이터 작성
|
# 6. 이미지 저장 및 메타데이터 작성
|
||||||
map_img.save(map_png_path, "PNG")
|
if not meta_only:
|
||||||
|
map_img.save(map_png_path, "PNG")
|
||||||
|
|
||||||
meta = {
|
meta = {
|
||||||
"x_min": img_x_min,
|
"x_min": img_x_min,
|
||||||
@@ -194,7 +199,7 @@ def download_vworld_satellite_map(
|
|||||||
}
|
}
|
||||||
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
meta_json_path.write_text(json.dumps(meta, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
return map_png_path
|
return meta_json_path if meta_only else map_png_path
|
||||||
|
|
||||||
|
|
||||||
# 경위도 격자 — 그림 상자와 둘레 한 폭씩(도엽이 상자 밖 0.8 폭까지 · 3 × 3 폭) · 한 변 25 마디
|
# 경위도 격자 — 그림 상자와 둘레 한 폭씩(도엽이 상자 밖 0.8 폭까지 · 3 × 3 폭) · 한 변 25 마디
|
||||||
|
|||||||
@@ -78,7 +78,14 @@ async def get_vworld_meta(
|
|||||||
async def get_vworld_map(
|
async def get_vworld_map(
|
||||||
project_id: UUID, request: Request, layer_name: str = "satellite"
|
project_id: UUID, request: Request, layer_name: str = "satellite"
|
||||||
) -> Response | JSONResponse:
|
) -> Response | JSONResponse:
|
||||||
"""배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304)."""
|
"""배경 지도 레이어 PNG 이미지를 반환합니다(ETag — 바뀌지 않았으면 304).
|
||||||
|
|
||||||
|
위성은 한 장 그림을 안 씀(590-2 · GL 조각 `plan/tile`) → 404."""
|
||||||
|
if layer_name.lower() == "satellite":
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "위성 한 장 그림은 쓰지 않습니다."},
|
||||||
|
)
|
||||||
pool = get_db_pool()
|
pool = get_db_pool()
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as connection:
|
async with pool.acquire() as connection:
|
||||||
@@ -93,9 +100,6 @@ async def get_vworld_map(
|
|||||||
map_name = f"vworld_{target_layer}.png"
|
map_name = f"vworld_{target_layer}.png"
|
||||||
map_path = target_dir / map_name
|
map_path = target_dir / map_name
|
||||||
|
|
||||||
if not map_path.exists() and target_layer == "satellite":
|
|
||||||
map_path = target_dir / "vworld_map.png"
|
|
||||||
|
|
||||||
if not map_path.exists():
|
if not map_path.exists():
|
||||||
return JSONResponse(
|
return JSONResponse(
|
||||||
status_code=404,
|
status_code=404,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
* 따라감 — 카메라 = 화면 가운데 1차 맞춤(`logic/…_MapGl_Frame` · 닮음꼴) + 남는 늘림 = 지도 칸 CSS 행렬 · 위 그림(노선 · 관 · 측점 · 고르기)은
|
* 따라감 — 카메라 = 화면 가운데 1차 맞춤(`logic/…_MapGl_Frame` · 닮음꼴) + 남는 늘림 = 지도 칸 CSS 행렬 · 위 그림(노선 · 관 · 측점 · 고르기)은
|
||||||
* 위 캔버스 그대로 · 누름 · 끌기 판정 그대로.
|
* 위 캔버스 그대로 · 누름 · 끌기 판정 그대로.
|
||||||
* 처음 = 켬 · 끔 = 옛 캔버스(취향 `aislo:pref:map.canvas` = "1" · 세 화면 하나) · WebGL 못 씀 · 잃음 =
|
* 처음 = 켬 · 끔 = 옛 캔버스(취향 `aislo:pref:map.canvas` = "1" · 세 화면 하나) · WebGL 못 씀 · 잃음 =
|
||||||
* 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
|
* 저절로 옛 캔버스 + 알림(취향은 안 바꿈) · 옛 캔버스 = 위성 바탕 없음(590-2 · 위성 = 조각만).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
|
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
|
||||||
@@ -25,7 +25,10 @@ import { glCameraOf } from "./logic/B04_PreProcess_UI_MapGl_Frame";
|
|||||||
|
|
||||||
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
||||||
const CANVAS_PREF_KEY = "aislo:pref:map.canvas";
|
const CANVAS_PREF_KEY = "aislo:pref:map.canvas";
|
||||||
export const GL_TOGGLE_TITLE = "그래픽 카드 그림(바탕 겹을 GPU 로 · 끔 = 옛 캔버스 그림)";
|
export const GL_TOGGLE_TITLE =
|
||||||
|
"그래픽 카드 그림(바탕 겹을 GPU 로 · 끔 = 옛 캔버스 그림 · 위성 바탕 없음)";
|
||||||
|
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||||
|
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||||
|
|
||||||
export interface TmGl<K extends string> {
|
export interface TmGl<K extends string> {
|
||||||
gl: GlMap<K>;
|
gl: GlMap<K>;
|
||||||
@@ -68,7 +71,7 @@ export function createTmGl<K extends string>(opts: {
|
|||||||
themeNames: opts.themeNames,
|
themeNames: opts.themeNames,
|
||||||
onFail: (why) => {
|
onFail: (why) => {
|
||||||
if (!wanted) return;
|
if (!wanted) return;
|
||||||
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
|
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info");
|
||||||
use(false);
|
use(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -87,6 +90,7 @@ export function createTmGl<K extends string>(opts: {
|
|||||||
toggle() {
|
toggle() {
|
||||||
writeByKey(CANVAS_PREF_KEY, active ? "1" : "0");
|
writeByKey(CANVAS_PREF_KEY, active ? "1" : "0");
|
||||||
use(!active);
|
use(!active);
|
||||||
|
if (!active) showToast(NO_SATELLITE_NOTE, "info");
|
||||||
},
|
},
|
||||||
follow(meta, normalizer, toScreen, width, height) {
|
follow(meta, normalizer, toScreen, width, height) {
|
||||||
if (!active || !meta || !normalizer || width <= 0 || height <= 0) return;
|
if (!active || !meta || !normalizer || width <= 0 || height <= 0) return;
|
||||||
|
|||||||
@@ -502,6 +502,8 @@ export function createSurfaceMapViewer(): SurfaceMapViewer {
|
|||||||
surfaceGl.setLayer(layer, data);
|
surfaceGl.setLayer(layer, data);
|
||||||
});
|
});
|
||||||
BACKGROUND_LAYERS.forEach((layer) => {
|
BACKGROUND_LAYERS.forEach((layer) => {
|
||||||
|
// 위성 = GL 조각만(옛 캔버스 = 위성 바탕 없음 · 590-2)
|
||||||
|
if (layer === "satellite") return;
|
||||||
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
||||||
backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer);
|
backgroundImages.get(layer)!.src = getVWorldMapUrl(projectId, layer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
|
|||||||
import { viewOf } from "../common_util/common_util_view_window";
|
import { viewOf } from "../common_util/common_util_view_window";
|
||||||
import {
|
import {
|
||||||
computeDetailBasins,
|
computeDetailBasins,
|
||||||
getVWorldMapUrl,
|
|
||||||
type DetailBasin,
|
type DetailBasin,
|
||||||
type DetailBasinResponse,
|
type DetailBasinResponse,
|
||||||
type PipeSource,
|
type PipeSource,
|
||||||
@@ -484,8 +483,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra
|
|||||||
preparedLayers.set(layer, prepareLayer(data, normalizer!));
|
preparedLayers.set(layer, prepareLayer(data, normalizer!));
|
||||||
drainGl.setLayer(layer, data);
|
drainGl.setLayer(layer, data);
|
||||||
});
|
});
|
||||||
// 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다.
|
// 위성 = GL 조각만(옛 캔버스 = 위성 바탕 없음 · 590-2)
|
||||||
backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite");
|
|
||||||
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
|
if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta);
|
||||||
pipeEditor.setContext(nextMeta, routePoints);
|
pipeEditor.setContext(nextMeta, routePoints);
|
||||||
status.hidden = featureCount > 0;
|
status.hidden = featureCount > 0;
|
||||||
|
|||||||
@@ -371,24 +371,25 @@ async def _tile_root(project_id: UUID) -> Path:
|
|||||||
async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
|
async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
|
||||||
"""위성 조각(PLAN 586 GL 바탕 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리 ·
|
"""위성 조각(PLAN 586 GL 바탕 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리 ·
|
||||||
받은 조각 = `_derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg`(지문 = 겹 · 조각 번호 ·
|
받은 조각 = `_derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg`(지문 = 겹 · 조각 번호 ·
|
||||||
지워도 다시 받음)."""
|
지워도 다시 받음 · 프로젝트당 `PLAN_TILE_CACHE_MB` 넘으면 오래 안 본 것부터 지움 · 590-2)."""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
|
||||||
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
|
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
|
||||||
|
from common_util import common_util_tile_cache as tile_cache
|
||||||
from common_util.common_util_derived_store import derived_dir
|
from common_util.common_util_derived_store import derived_dir
|
||||||
|
from config import config_system
|
||||||
|
|
||||||
if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z):
|
if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z):
|
||||||
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.")
|
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.")
|
||||||
path = (
|
folder = derived_dir(await _tile_root(project_id), TILE_KIND)
|
||||||
derived_dir(await _tile_root(project_id), TILE_KIND)
|
path = folder / "Satellite" / str(z) / f"{x}_{y}.jpeg"
|
||||||
/ "Satellite"
|
|
||||||
/ str(z)
|
|
||||||
/ f"{x}_{y}.jpeg"
|
|
||||||
)
|
|
||||||
url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg"
|
url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg"
|
||||||
|
limit = int(config_system.PLAN_TILE_CACHE_MB * 1024 * 1024)
|
||||||
|
|
||||||
def fetch() -> bytes:
|
def fetch() -> bytes:
|
||||||
if path.is_file():
|
if path.is_file():
|
||||||
|
tile_cache.note(folder, 0, limit) # 처음 열 때 한 번 셈 · 넘쳤으면 지움
|
||||||
|
tile_cache.seen(path)
|
||||||
return path.read_bytes()
|
return path.read_bytes()
|
||||||
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
|
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
|
||||||
with urllib.request.urlopen(request, timeout=8) as response:
|
with urllib.request.urlopen(request, timeout=8) as response:
|
||||||
@@ -397,6 +398,7 @@ async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Respo
|
|||||||
part = path.with_suffix(f".{uuid4().hex}.part")
|
part = path.with_suffix(f".{uuid4().hex}.part")
|
||||||
part.write_bytes(tile)
|
part.write_bytes(tile)
|
||||||
part.replace(path) # 반쯤 쓴 조각을 남기지 않음
|
part.replace(path) # 반쯤 쓴 조각을 남기지 않음
|
||||||
|
tile_cache.note(folder, len(tile), limit)
|
||||||
return tile
|
return tile
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -25,12 +25,11 @@ import {
|
|||||||
import type { Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
import type { Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
|
||||||
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
|
||||||
|
|
||||||
/** 바탕 겹(아래 → 위) — 켬끔 · 받은 자료 · 위성 틀 · 위성 그림(음영 곱하기)을 그때그때 읽음. */
|
/** 바탕 겹(아래 → 위) — 켬끔 · 받은 자료 · 위성 틀을 그때그때 읽음(옛 캔버스 = 위성 그림 없음 · 590-2). */
|
||||||
export function createCanvasLayers(opts: {
|
export function createCanvasLayers(opts: {
|
||||||
on: Record<PlanLayerKey, boolean>;
|
on: Record<PlanLayerKey, boolean>;
|
||||||
data: Partial<Prepared>;
|
data: Partial<Prepared>;
|
||||||
meta: () => VWorldMeta | null;
|
meta: () => VWorldMeta | null;
|
||||||
satellite: () => HTMLImageElement | null;
|
|
||||||
}): PlanMapLayer[] {
|
}): PlanMapLayer[] {
|
||||||
const { on, data } = opts;
|
const { on, data } = opts;
|
||||||
/** 등고 글 폭 기억(글꼴 하나 · 582). */
|
/** 등고 글 폭 기억(글꼴 하나 · 582). */
|
||||||
@@ -40,11 +39,6 @@ export function createCanvasLayers(opts: {
|
|||||||
base: true,
|
base: true,
|
||||||
draw: (context, map) => {
|
draw: (context, map) => {
|
||||||
if (!on.hillshade || !data.hillshade) return;
|
if (!on.hillshade || !data.hillshade) return;
|
||||||
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
|
|
||||||
if (on.satellite && opts.satellite()) {
|
|
||||||
context.globalCompositeOperation = "multiply";
|
|
||||||
context.globalAlpha = 0.75;
|
|
||||||
}
|
|
||||||
drawRaster(context, map, data.hillshade);
|
drawRaster(context, map, data.hillshade);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
|
||||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
|
||||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
|
||||||
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
|
||||||
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||||
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
|
||||||
@@ -28,11 +28,7 @@ import { createMapTools } from "@ui/ui_template_map_tools";
|
|||||||
import { themeColor } from "@ui/ui_template_palette";
|
import { themeColor } from "@ui/ui_template_palette";
|
||||||
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
|
import { fetchCachedSheetLayer } 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 {
|
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
||||||
fetchVWorldMeta,
|
|
||||||
getVWorldMapUrl,
|
|
||||||
type VWorldMeta,
|
|
||||||
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
||||||
import {
|
import {
|
||||||
createNormalizer,
|
createNormalizer,
|
||||||
pickLevelStep,
|
pickLevelStep,
|
||||||
@@ -83,6 +79,8 @@ const glFont = (weight: string, text: string): Promise<unknown> =>
|
|||||||
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
|
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
|
||||||
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
/** 옛 캔버스로 그림("1") — 처음 = GL. */
|
||||||
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
|
||||||
|
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
|
||||||
|
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
|
||||||
/** 등고선을 받아 올 그은 선 둘레(m). */
|
/** 등고선을 받아 올 그은 선 둘레(m). */
|
||||||
const CONTOUR_PAD_M = 600;
|
const CONTOUR_PAD_M = 600;
|
||||||
type DataKey = keyof Prepared;
|
type DataKey = keyof Prepared;
|
||||||
@@ -120,12 +118,11 @@ export function createPlanLayers(opts: {
|
|||||||
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
const pending = new Map<DataKey | "satellite", Promise<void>>();
|
||||||
let uploading = false;
|
let uploading = false;
|
||||||
let meta: VWorldMeta | null = null;
|
let meta: VWorldMeta | null = null;
|
||||||
let satellite: HTMLImageElement | null = null;
|
|
||||||
/** 음영 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) => {
|
||||||
if (!glOn) return;
|
if (!glOn) return;
|
||||||
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
|
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info");
|
||||||
useGl(false);
|
useGl(false);
|
||||||
});
|
});
|
||||||
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
|
||||||
@@ -166,6 +163,7 @@ export function createPlanLayers(opts: {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
|
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
|
||||||
useGl(!glOn);
|
useGl(!glOn);
|
||||||
|
if (!glOn) showToast(NO_SATELLITE_NOTE, "info");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
glButton.dataset.layer = "gl";
|
glButton.dataset.layer = "gl";
|
||||||
@@ -174,8 +172,7 @@ export function createPlanLayers(opts: {
|
|||||||
glOn = want && map.setGl(gl);
|
glOn = want && map.setGl(gl);
|
||||||
if (!glOn) map.setGl(null);
|
if (!glOn) map.setGl(null);
|
||||||
glButton.setAttribute("aria-pressed", String(glOn));
|
glButton.setAttribute("aria-pressed", String(glOn));
|
||||||
void ensure("satellite");
|
sync();
|
||||||
showSatellite();
|
|
||||||
map.redrawBase();
|
map.redrawBase();
|
||||||
}
|
}
|
||||||
groups.push([glButton]);
|
groups.push([glButton]);
|
||||||
@@ -190,16 +187,19 @@ export function createPlanLayers(opts: {
|
|||||||
legend.className = "d03-plan__legend";
|
legend.className = "d03-plan__legend";
|
||||||
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
|
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
|
||||||
|
|
||||||
/** 받음 — 위성은 GL 이면 조각(한 장 그림 안 받음). */
|
/** 받음 — 위성 = GL 조각(받을 것 없음 · 옛 캔버스 = 위성 없음). */
|
||||||
const loadedOf = (key: PlanLayerKey): boolean =>
|
const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" || !!data[dataKey(key)];
|
||||||
key === "satellite" ? glOn || !!satellite : !!data[dataKey(key)];
|
|
||||||
|
|
||||||
function sync(): void {
|
function sync(): void {
|
||||||
for (const def of LAYERS) {
|
for (const def of LAYERS) {
|
||||||
const button = buttons.get(def.key)!;
|
const button = buttons.get(def.key)!;
|
||||||
button.setAttribute("aria-pressed", String(on[def.key]));
|
button.setAttribute("aria-pressed", String(on[def.key]));
|
||||||
button.disabled = !meta;
|
button.disabled = !meta;
|
||||||
const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`;
|
const title = !meta
|
||||||
|
? `${def.title} — 지형 받기 뒤 보임`
|
||||||
|
: def.key === "satellite" && !glOn
|
||||||
|
? `${def.title} — ${NO_SATELLITE_NOTE}`
|
||||||
|
: def.title;
|
||||||
button.title = title;
|
button.title = title;
|
||||||
button.setAttribute("aria-label", title);
|
button.setAttribute("aria-label", title);
|
||||||
}
|
}
|
||||||
@@ -218,15 +218,10 @@ export function createPlanLayers(opts: {
|
|||||||
on[key] = !on[key];
|
on[key] = !on[key];
|
||||||
writeByKey(PREF_KEY, JSON.stringify(on));
|
writeByKey(PREF_KEY, JSON.stringify(on));
|
||||||
void ensure(key);
|
void ensure(key);
|
||||||
showSatellite();
|
|
||||||
sync();
|
sync();
|
||||||
map.redrawBase();
|
map.redrawBase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function showSatellite(): void {
|
|
||||||
map.update({ image: on.satellite ? satellite : null });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
|
||||||
function ensure(key: PlanLayerKey): Promise<void> {
|
function ensure(key: PlanLayerKey): Promise<void> {
|
||||||
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
|
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
|
||||||
@@ -237,7 +232,6 @@ export function createPlanLayers(opts: {
|
|||||||
const job = fetchLayer(key, at)
|
const job = fetchLayer(key, at)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (meta !== at) return;
|
if (meta !== at) return;
|
||||||
showSatellite();
|
|
||||||
sync();
|
sync();
|
||||||
map.redrawBase();
|
map.redrawBase();
|
||||||
})
|
})
|
||||||
@@ -302,12 +296,7 @@ export function createPlanLayers(opts: {
|
|||||||
...collection,
|
...collection,
|
||||||
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
|
||||||
});
|
});
|
||||||
if (key === "satellite") {
|
if (key === "contour") {
|
||||||
if (glOn) return; // GL = 조각
|
|
||||||
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
|
|
||||||
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
|
|
||||||
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
|
|
||||||
} else if (key === "contour") {
|
|
||||||
const points = opts.points();
|
const points = opts.points();
|
||||||
const xs = points.map(([x]) => x);
|
const xs = points.map(([x]) => x);
|
||||||
const ys = points.map(([, y]) => y);
|
const ys = points.map(([, y]) => y);
|
||||||
@@ -483,7 +472,6 @@ export function createPlanLayers(opts: {
|
|||||||
on,
|
on,
|
||||||
data,
|
data,
|
||||||
meta: () => meta,
|
meta: () => meta,
|
||||||
satellite: () => satellite,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
|
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
|
||||||
@@ -508,14 +496,12 @@ export function createPlanLayers(opts: {
|
|||||||
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
|
||||||
meta = next;
|
meta = next;
|
||||||
if (changed) {
|
if (changed) {
|
||||||
satellite = null;
|
|
||||||
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)
|
||||||
if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null);
|
if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null);
|
||||||
}
|
}
|
||||||
map.update({ meta });
|
map.update({ meta });
|
||||||
showSatellite();
|
|
||||||
sync();
|
sync();
|
||||||
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
await Promise.all(LAYERS.map((def) => ensure(def.key)));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,7 +56,6 @@ const FAKE_MARGIN_M = 600;
|
|||||||
|
|
||||||
export interface PlanMapScene {
|
export interface PlanMapScene {
|
||||||
meta: VWorldMeta | null;
|
meta: VWorldMeta | null;
|
||||||
image: HTMLImageElement | null;
|
|
||||||
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
|
/** 처음 맞출 점(시점 · 종점 · 그은 선). */
|
||||||
fit: Vertex[];
|
fit: Vertex[];
|
||||||
/** 작업 좌표계 — TM 겹을 경위도 바탕에 맞춤(586-0). */
|
/** 작업 좌표계 — TM 겹을 경위도 바탕에 맞춤(586-0). */
|
||||||
@@ -229,7 +228,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
|||||||
root.append(baseCanvas, canvas, toolbar);
|
root.append(baseCanvas, canvas, toolbar);
|
||||||
const baseContext = baseCanvas.getContext("2d")!;
|
const baseContext = baseCanvas.getContext("2d")!;
|
||||||
const context = canvas.getContext("2d")!;
|
const context = canvas.getContext("2d")!;
|
||||||
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
|
let scene: PlanMapScene = { meta: null, fit: [] };
|
||||||
let layers: PlanMapLayer[] = [];
|
let layers: PlanMapLayer[] = [];
|
||||||
let meta: VWorldMeta | null = null;
|
let meta: VWorldMeta | null = null;
|
||||||
let fitted = false;
|
let fitted = false;
|
||||||
@@ -381,11 +380,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
|||||||
ctx.translate(view.width / 2, view.height / 2);
|
ctx.translate(view.width / 2, view.height / 2);
|
||||||
ctx.rotate(rotation.radians());
|
ctx.rotate(rotation.radians());
|
||||||
ctx.translate(-view.width / 2, -view.height / 2);
|
ctx.translate(-view.width / 2, -view.height / 2);
|
||||||
if (base && !gl && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
|
||||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
|
||||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
|
||||||
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
|
||||||
}
|
|
||||||
const map = screenMap();
|
const map = screenMap();
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
if (!layer.base !== !base) continue;
|
if (!layer.base !== !base) continue;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import tempfile
|
import tempfile
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
@@ -37,6 +38,8 @@ STORAGE_SCOPE = (
|
|||||||
PIPE_SCOPE = ("B04_PreProcess/drainage",)
|
PIPE_SCOPE = ("B04_PreProcess/drainage",)
|
||||||
#: 노선 초기화(추천 [적용])가 다시 뜨는 초기값 사본.
|
#: 노선 초기화(추천 [적용])가 다시 뜨는 초기값 사본.
|
||||||
INITIAL_SCOPE = ("initial_snapshot",)
|
INITIAL_SCOPE = ("initial_snapshot",)
|
||||||
|
#: 떠 두지 않는 자리 — 위성 조각(저장이 안 씀 · 다시 받음 · 590-2).
|
||||||
|
SKIP_SCOPE = ("_derived/plan_tile",)
|
||||||
#: 노선 아래 표(노선 행이 지워지면 함께 지워짐 · CASCADE).
|
#: 노선 아래 표(노선 행이 지워지면 함께 지워짐 · CASCADE).
|
||||||
ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
|
ROUTE_CHILDREN = ("route_points", "route_statistics", "longitudinal_sections", "cross_sections")
|
||||||
|
|
||||||
@@ -68,8 +71,13 @@ def _files(root: Path, scope: tuple[str, ...]) -> dict[str, Path]:
|
|||||||
if target.is_file():
|
if target.is_file():
|
||||||
out[relative] = target
|
out[relative] = target
|
||||||
elif target.is_dir():
|
elif target.is_dir():
|
||||||
for path in target.rglob("*"):
|
for folder, dirs, names in os.walk(target):
|
||||||
if path.is_file():
|
here = Path(folder)
|
||||||
|
dirs[:] = [
|
||||||
|
d for d in dirs if (here / d).relative_to(root).as_posix() not in SKIP_SCOPE
|
||||||
|
]
|
||||||
|
for name in names:
|
||||||
|
path = here / name
|
||||||
out[path.relative_to(root).as_posix()] = path
|
out[path.relative_to(root).as_posix()] = path
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
"""위성 조각 저장본 한도(590-2 · 요구 452 · 453) — 프로젝트당 한 벌 `_derived/plan_tile`.
|
||||||
|
|
||||||
|
다시 받을 수 있는 저장본 · 프로젝트당 `limit` 바이트를 넘으면 오래 안 본 것부터 한도 80 % 까지
|
||||||
|
지움. 크기는 서버 메모리에 두고 프로젝트를 처음 볼 때 한 번만 폴더를 셈(그때 넘쳤으면 바로 지움).
|
||||||
|
본 때 = 파일 수정시각 · 볼 때 하루 넘게 묵은 것만 새로 찍음(시놀로지 동기 일감을 줄임).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
TOUCH_AFTER_S = 86400
|
||||||
|
KEEP_RATIO = 0.8
|
||||||
|
|
||||||
|
_SIZES: dict[Path, int] = {}
|
||||||
|
_LOCK = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def _tiles(folder: Path) -> list[tuple[float, int, Path]]:
|
||||||
|
"""(본 때, 크기, 경로) — 받는 중인 `.part` 는 뺌."""
|
||||||
|
out = []
|
||||||
|
for path in folder.rglob("*.jpeg"):
|
||||||
|
try:
|
||||||
|
stat = path.stat()
|
||||||
|
except FileNotFoundError:
|
||||||
|
continue
|
||||||
|
out.append((stat.st_mtime, stat.st_size, path))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def seen(path: Path) -> None:
|
||||||
|
"""조각을 봄 — 하루 넘게 묵었으면 본 때를 지금으로."""
|
||||||
|
try:
|
||||||
|
if time.time() - path.stat().st_mtime > TOUCH_AFTER_S:
|
||||||
|
os.utime(path)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def prune(folder: Path, limit: int) -> int:
|
||||||
|
"""한도를 넘으면 오래 안 본 것부터 한도 80 % 까지 지움 → 지운 수."""
|
||||||
|
tiles = sorted(_tiles(folder))
|
||||||
|
total = sum(size for _, size, _ in tiles)
|
||||||
|
removed = 0
|
||||||
|
if total > limit:
|
||||||
|
for _, size, path in tiles:
|
||||||
|
if total <= limit * KEEP_RATIO:
|
||||||
|
break
|
||||||
|
path.unlink(missing_ok=True)
|
||||||
|
total -= size
|
||||||
|
removed += 1
|
||||||
|
_SIZES[folder] = total
|
||||||
|
return removed
|
||||||
|
|
||||||
|
|
||||||
|
def note(folder: Path, added: int, limit: int) -> int:
|
||||||
|
"""`folder`(프로젝트 조각 뿌리)에 `added` 바이트가 늘었음(0 = 열기만) → 지운 수."""
|
||||||
|
with _LOCK:
|
||||||
|
if folder not in _SIZES:
|
||||||
|
return prune(folder, limit)
|
||||||
|
_SIZES[folder] += added
|
||||||
|
return prune(folder, limit) if _SIZES[folder] > limit else 0
|
||||||
@@ -141,6 +141,8 @@ SHEET_SURFACE_MARGIN_M = float(os.getenv("SHEET_SURFACE_MARGIN_M", "300.0"))
|
|||||||
SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
|
SHEET_SURFACE_GRID_M = float(os.getenv("SHEET_SURFACE_GRID_M", "1.0"))
|
||||||
# D 예정노선 지형 격자(m) — 길이 · 원지반 기울기를 대충 보는 계획 단계라 2m(B 는 위 1m 그대로).
|
# D 예정노선 지형 격자(m) — 길이 · 원지반 기울기를 대충 보는 계획 단계라 2m(B 는 위 1m 그대로).
|
||||||
PLAN_TERRAIN_GRID_M = float(os.getenv("PLAN_TERRAIN_GRID_M", "2.0"))
|
PLAN_TERRAIN_GRID_M = float(os.getenv("PLAN_TERRAIN_GRID_M", "2.0"))
|
||||||
|
# 위성 조각 저장본 한도(590-2 · 요구 453 사용자 확정) — 프로젝트당 MB · 넘으면 오래 안 본 것부터 지움.
|
||||||
|
PLAN_TILE_CACHE_MB = float(os.getenv("PLAN_TILE_CACHE_MB", "150"))
|
||||||
# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다
|
# 만들어 둘 등고선 보간 방식 — B04 화면에서 버튼으로 바꿔 가며 비교한다
|
||||||
# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py.
|
# (2026-08-30 사용자 지시). 정의는 B04_PreProcess_Engine_SheetMethods.py.
|
||||||
SHEET_SURFACE_METHODS = [
|
SHEET_SURFACE_METHODS = [
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
"""590-2 위성 조각 정리 — 프로젝트당 한도 · 오래 안 본 것부터 · 한 장짜리 안 만듦 · B 저장 복사에서 뺌."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from common_util import common_util_tile_cache as tile_cache
|
||||||
|
from common_util.common_util_save_guard import STORAGE_SCOPE, _files
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[2]
|
||||||
|
|
||||||
|
|
||||||
|
def _tile(folder: Path, name: str, size: int, age_s: float) -> Path:
|
||||||
|
path = folder / "Satellite" / "18" / f"{name}.jpeg"
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_bytes(b"x" * size)
|
||||||
|
at = time.time() - age_s
|
||||||
|
os.utime(path, (at, at))
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_oldest_first_down_to_keep_ratio(tmp_path: Path) -> None:
|
||||||
|
tiles = [_tile(tmp_path, f"t{i}", 100, age_s=1000 - i) for i in range(10)] # t0 = 가장 묵음
|
||||||
|
assert tile_cache.prune(tmp_path, limit=1000) == 0 # 한도 = 딱 맞음
|
||||||
|
assert tile_cache.prune(tmp_path, limit=900) == 3 # 1000 → 700(≤ 720)
|
||||||
|
assert [p.exists() for p in tiles] == [False] * 3 + [True] * 7
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_counts_once_then_adds(tmp_path: Path) -> None:
|
||||||
|
for i in range(5):
|
||||||
|
_tile(tmp_path, f"t{i}", 100, age_s=100 - i)
|
||||||
|
tile_cache._SIZES.pop(tmp_path, None)
|
||||||
|
assert tile_cache.note(tmp_path, 0, limit=1000) == 0 # 처음 = 폴더 셈
|
||||||
|
assert tile_cache._SIZES[tmp_path] == 500
|
||||||
|
_tile(tmp_path, "new", 600, age_s=0)
|
||||||
|
assert tile_cache.note(tmp_path, 600, limit=1000) == 3 # 1100 → 800 · 묵은 셋
|
||||||
|
assert tile_cache._SIZES[tmp_path] == 800
|
||||||
|
assert (tmp_path / "Satellite" / "18" / "new.jpeg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_note_first_sight_prunes_overfull(tmp_path: Path) -> None:
|
||||||
|
for i in range(4):
|
||||||
|
_tile(tmp_path, f"t{i}", 100, age_s=100 - i)
|
||||||
|
tile_cache._SIZES.pop(tmp_path, None)
|
||||||
|
assert tile_cache.note(tmp_path, 0, limit=250) == 2 # 열 때 넘침 → 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_seen_touches_only_stale(tmp_path: Path) -> None:
|
||||||
|
stale = _tile(tmp_path, "old", 1, age_s=tile_cache.TOUCH_AFTER_S + 60)
|
||||||
|
fresh = _tile(tmp_path, "new", 1, age_s=60)
|
||||||
|
fresh_at = fresh.stat().st_mtime
|
||||||
|
tile_cache.seen(stale)
|
||||||
|
tile_cache.seen(fresh)
|
||||||
|
assert time.time() - stale.stat().st_mtime < 60
|
||||||
|
assert fresh.stat().st_mtime == fresh_at
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_snapshot_skips_tiles(tmp_path: Path) -> None:
|
||||||
|
_tile(tmp_path / "_derived" / "plan_tile", "t", 10, age_s=0)
|
||||||
|
keep = tmp_path / "_derived" / "plan_x" / "k.json"
|
||||||
|
keep.parent.mkdir(parents=True)
|
||||||
|
keep.write_text("{}", encoding="utf-8")
|
||||||
|
assert "_derived" in STORAGE_SCOPE
|
||||||
|
assert list(_files(tmp_path, STORAGE_SCOPE)) == ["_derived/plan_x/k.json"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_satellite_writes_meta_only(tmp_path: Path) -> None:
|
||||||
|
from pyproj import CRS
|
||||||
|
|
||||||
|
from B04_PreProcess.B04_PreProcess_Engine_VWorld import download_vworld_satellite_map
|
||||||
|
|
||||||
|
prj = tmp_path / "plan.prj"
|
||||||
|
prj.write_text(CRS.from_epsg(5186).to_wkt("WKT1_ESRI"), encoding="utf-8")
|
||||||
|
bounds = {"x": [200000.0, 201000.0], "y": [500000.0, 501000.0]}
|
||||||
|
got = download_vworld_satellite_map(prj, bounds, tmp_path, layer_name="Satellite")
|
||||||
|
assert got == tmp_path / "vworld_satellite_meta.json" and got.is_file()
|
||||||
|
assert not (tmp_path / "vworld_satellite.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_satellite_map_route_refuses() -> None:
|
||||||
|
from B04_PreProcess.B04_PreProcess_Router_GIS import get_vworld_map
|
||||||
|
|
||||||
|
reply = asyncio.run(get_vworld_map(uuid4(), None, "satellite")) # type: ignore[arg-type]
|
||||||
|
assert reply.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_screens_do_not_ask_satellite_image() -> None:
|
||||||
|
hits = []
|
||||||
|
for folder in ("A00_Common", "B04_PreProcess", "B05_Profile", "D03_PlanAnalysis"):
|
||||||
|
for path in (ROOT / folder).rglob("*.ts"):
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
if re.search(
|
||||||
|
r'getVWorldMapUrl\([^)]*"satellite"|vworld-map\?layer_name=satellite', text
|
||||||
|
):
|
||||||
|
hits.append(path.name)
|
||||||
|
assert hits == []
|
||||||
Reference in New Issue
Block a user