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:
2026-10-30 09:41:00 +09:00
co-authored by Claude Opus 5.5
parent 1c0c44f9e2
commit a17ad1dc41
14 changed files with 232 additions and 71 deletions
-4
View File
@@ -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;
+10 -5
View File
@@ -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 마디
+8 -4
View File
@@ -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,
+7 -3
View File
@@ -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);
}); });
+1 -3
View File
@@ -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;
+9 -7
View File
@@ -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);
}, },
}, },
+15 -29
View File
@@ -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)));
} }
+1 -7
View File
@@ -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;
+10 -2
View File
@@ -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
+65
View File
@@ -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
+2
View File
@@ -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 = [
+101
View File
@@ -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 == []