- 624-3 도구줄 = 공용 표 「b05」 · 아래 줄 = 공용 겹 줄(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천) · 범례 = 공용 legendRows(상태 글판 위) - 노선 띠 300 m 자르기 = B05 자기 칸(등고선 · 하천 · 1 m)만 · 그 밖 겹 = 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹 있을 때만 · GL 만) · 등고선 끔 = 등고 글도 숨김 - 626-1 추천 보기 = 도구줄 아이콘 + 아래 판(영선 · 토공 최소 · 기울기 최소 · 비중 계산 · 끔) — route_recommend 저장본(지금 설계 지형) 선만 · 다시 셈 · 쓰기 없음 - 지형 칩 → 확인창 → 새 POST /route/design-terrain(설계 지형만 바꿈 · 노선 저장 체인 다시 · 노선 · 초기값 · 추천 저장본 그대로 · 잠금 · 실패 되돌림) - 걷음 — 추천 모달(_Recommend · _Help · _Map · CSS) · 좌측 「노선 추천」 칸 · saveRecommendedRoute · UI_RouteReset(서버 as_initial 길 · 시험은 남김) - 부품 상자 — createPickMenu(아이콘 고르기 메뉴 · 판 = body fixed · ✓) - 낡은 시험 고침 · 지움 — 모달 · 좌측 칸 · [적용] 화면 배선을 보던 시험(283 · 346_3 · 349 · 364_4 · 383_1 · 390_5 · 418 · 421 · 421_2 · 476_2 · 509_2 · 509_5 · 551_2 · 600_3 · b05_routeedit_initial · design_view_parity · modal_top · 133_1 · 133_2) · test_508_2 · helper_283 지움(모달 전용) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B05_Profile_UI_RouteEdit_Layers.ts
|
|
* 평면 노선 편집 지도 겹(PLAN 624-3 · 요구 530 · 결정 A② · A③ · A⑦ · A⑨) — 공용 겹 줄 · 범례(`A00_Common/map`)를 B05 평면에.
|
|
* 겹 목록 · 처음 켬 · 취향 = 공용 페이지 표 「b05」(띠 · 올린 임도망도 숨김 · 처음 = 등고선 · 하천만).
|
|
* 등고선 · 하천 = B05 자기 자료(LAS 5 m · 1 m · 도엽 · 집기 · 자물쇠 그대로 · `_Gl` 이 그림 · 노선 띠 300 m 자르기는 이 둘만) ·
|
|
* 켬끔은 GL 에서만(옛 캔버스 = 늘 보임).
|
|
* 그 밖 겹 = GL 만(A⑦) · 띠로 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹이 있을 때만 섬) · 꼴 = 공용 `mapLayerSpecs` ·
|
|
* 카메라 = `_Gl` 과 같은 화면 따라가기(`glCameraOf`).
|
|
* 받기 = 켤 때 한 번(서버 `/plan/…` · 624-1 임도 프로젝트 · 지형 = 설계 지형) · 도엽 선 겹 = 프로젝트 범위 도엽 조각 ·
|
|
* 실패 = 그 겹만 끔 + 알림 · 범례 = 켠 겹의 받은 자료 줄(공용 `legendRows`) · 급경사 ⚙ · 위성 밝기.
|
|
* 부르는 곳 = `_Chrome`(겹 줄 · 범례 자리) · `_Gl`(따라가기 · 켬끔).
|
|
* ========================================================================== */
|
|
|
|
import { showToast } from "@ui/ui_template_elements";
|
|
import { createGlMap, GL_FONT, satelliteTileSource, type GlData } from "@ui/ui_template_gl_map";
|
|
import { themeColor } from "@ui/ui_template_palette";
|
|
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
|
|
import type { Normalizer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
|
import { glCameraOf } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
|
|
import { forestSources, soilColor, tileOnly } from "../A00_Common/map/map_forest_layers";
|
|
import {
|
|
fetchPlanCadastral,
|
|
fetchPlanLandslideZones,
|
|
fetchPlanLines,
|
|
fetchPlanRaster,
|
|
fetchPlanSheetTile,
|
|
fetchPlanSheetTiles,
|
|
fetchPlanSoils,
|
|
fetchPlanZeroOrder,
|
|
fetchPlanZones,
|
|
type GeoJsonCollection,
|
|
} from "../A00_Common/map/map_layer_api";
|
|
import { createMapLayerBar } from "../A00_Common/map/map_layer_bar";
|
|
import {
|
|
FOREST_ROAD_KINDS,
|
|
WATER_KEYS,
|
|
glImage,
|
|
glTagged,
|
|
loadImage,
|
|
shadeImage,
|
|
} from "../A00_Common/map/map_layer_data";
|
|
import {
|
|
LAYERS,
|
|
LINE_LAYERS,
|
|
ZONE_COLORS,
|
|
type MapLayerKey,
|
|
} from "../A00_Common/map/map_layer_list";
|
|
import { createMapLegend, legendRows, type LegendData } from "../A00_Common/map/map_legend";
|
|
import { MAP_THEME_NAMES, mapLayerSpecs } from "../A00_Common/map/map_layer_specs";
|
|
import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light";
|
|
import {
|
|
openSlopeBands,
|
|
paintSlope,
|
|
readSlopeBands,
|
|
slopeGear,
|
|
slopeValues,
|
|
writeSlopeBands,
|
|
} from "../A00_Common/map/map_slope_bands";
|
|
|
|
/** B05 자기 자료로 그리는 겹(`_Gl` · 띠 자르기). */
|
|
const OWN: readonly MapLayerKey[] = ["contour", "stream"];
|
|
const isOwn = (key: MapLayerKey): boolean => OWN.includes(key);
|
|
/** 겹 → 도엽 조각 이름(서버 `TILE_NAMES` · D03 `SHEET_TILE_NAMES` 와 같음). */
|
|
const SHEET_NAMES: Partial<Record<MapLayerKey, string[]>> = {
|
|
water: WATER_KEYS,
|
|
road: ["도로중심선"],
|
|
forestRoad: ["도로중심선"],
|
|
building: ["건물"],
|
|
place: ["지명"],
|
|
};
|
|
/** 도엽이 이보다 많으면 선 겹 숨김(D03 `FULL_MAX` 와 같음). */
|
|
const SHEET_MAX = 12;
|
|
/** 지도 칸 둘레 여유(px · `.ui-gl-map` inset · B04 `createTmGl` 과 같음). */
|
|
const GL_MARGIN_PX = 8;
|
|
|
|
export interface RouteEditLayers {
|
|
/** 겹 줄(도구줄 아래 줄). */
|
|
bar: HTMLElement;
|
|
/** 켠 겹 범례(상태 글판 안). */
|
|
legend: HTMLElement;
|
|
/** B05 자기 겹 켬끔 — 등고선 · 하천을 그리나(옛 캔버스 = 늘 참). */
|
|
own: () => { contour: boolean; stream: boolean };
|
|
/** GL 켬끔 · 지도 범위가 바뀜 — 단추 · 범례 맞춤. */
|
|
sync: (glOn: boolean, meta: VWorldMeta | null) => void;
|
|
/** 그리기마다(GL 켬일 때만) — 켠 겹이 있으면 둘째 칸을 세우고 화면을 따라감. */
|
|
follow: (
|
|
meta: VWorldMeta,
|
|
normalizer: Normalizer,
|
|
toScreen: (nx: number, ny: number) => [number, number],
|
|
width: number,
|
|
height: number,
|
|
) => void;
|
|
/** GL 끔 · 떠남 — 둘째 칸 버림. */
|
|
park: () => void;
|
|
/** 검증 창구 — 켬끔 · 받은 겹 · 둘째 칸 · 겹마다 그린 지물 수. */
|
|
state: () => {
|
|
on: Record<MapLayerKey, boolean>;
|
|
loaded: MapLayerKey[];
|
|
map: boolean;
|
|
gl: Record<string, number>;
|
|
sheets: number;
|
|
};
|
|
}
|
|
|
|
export function createRouteEditLayers(opts: {
|
|
projectId: string;
|
|
/** 지도 칸(둘째 GL 칸을 맨 앞에 둠). */
|
|
wrap: HTMLElement;
|
|
/** 그래픽 카드 단추 — `_Gl` 켬끔. */
|
|
onGl: () => void;
|
|
/** 장면 다시 그리기. */
|
|
redraw: () => void;
|
|
}): RouteEditLayers {
|
|
const { projectId, wrap } = opts;
|
|
let glOn = false;
|
|
let meta: VWorldMeta | null = null;
|
|
let light = readSatLight();
|
|
const gl = createGlMap<MapLayerKey>({
|
|
specs: () => mapLayerSpecs(light, null),
|
|
fixed: { satellite: satelliteTileSource(projectId), ...forestSources(projectId) },
|
|
themeNames: MAP_THEME_NAMES,
|
|
onFail: (why) => showToast(`지도 겹을 그래픽 카드로 못 그림 — ${why}`, "info"),
|
|
});
|
|
const layerBar = createMapLayerBar({
|
|
page: "b05",
|
|
glOn,
|
|
onToggle: (key) => {
|
|
void ensure(key);
|
|
sync(glOn, meta);
|
|
opts.redraw();
|
|
},
|
|
onGl: opts.onGl,
|
|
});
|
|
const { on } = layerBar;
|
|
layerBar.root.classList.add("b05-routeedit__layers");
|
|
const legendBox = createMapLegend("b05-routeedit__maplegend");
|
|
const data: Partial<LegendData> = {};
|
|
const loaded = new Set<MapLayerKey>();
|
|
const pending = new Map<string, Promise<void>>();
|
|
let hillshade: { plain: GlData; shade: GlData } | null = null;
|
|
let size = "";
|
|
let enabled = false;
|
|
|
|
/** 급경사 범위(603 · D03 과 같은 취향) — 바꾸면 다시 칠함. */
|
|
let slopeBands = readSlopeBands();
|
|
let slopeValuesNow: ImageData | null = null;
|
|
const gear = slopeGear(() =>
|
|
openSlopeBands(slopeBands, (bands) => {
|
|
slopeBands = bands;
|
|
writeSlopeBands(bands);
|
|
void paintSlopeNow();
|
|
}),
|
|
);
|
|
async function paintSlopeNow(): Promise<void> {
|
|
const got = data.slope;
|
|
if (!got || !slopeValuesNow) return;
|
|
const bands = slopeBands;
|
|
const painted = await paintSlope(slopeValuesNow, bands);
|
|
if (bands !== slopeBands) return URL.revokeObjectURL(painted.url);
|
|
data.slope = { raster: got.raster, bands };
|
|
gl.setData("slope", glImage(got.raster, painted.url));
|
|
sync(glOn, meta);
|
|
opts.redraw();
|
|
}
|
|
const lightRow = createSatLightRow(light, (level) => {
|
|
light = level;
|
|
if (gl.map()?.getLayer("satellite"))
|
|
gl.map()!.setPaintProperty("satellite", "raster-brightness-max", level);
|
|
opts.redraw();
|
|
});
|
|
|
|
function sync(nextGl: boolean, nextMeta: VWorldMeta | null): void {
|
|
const first = !meta && nextMeta;
|
|
glOn = nextGl;
|
|
meta = nextMeta;
|
|
layerBar.sync({ ready: !!meta, glOn });
|
|
// 자기 겹(등고선 · 하천) = GL 에서만 켬끔 · 옛 캔버스는 늘 그림
|
|
for (const key of OWN) {
|
|
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${key}"]`);
|
|
if (!button || glOn) continue;
|
|
button.disabled = true;
|
|
button.title = `${LAYERS.find((def) => def.key === key)!.title} — 옛 그림(그래픽 카드 끔)엔 늘 보임`;
|
|
}
|
|
// 그 밖 겹 = GL 만(A⑦)
|
|
for (const def of LAYERS) {
|
|
if (isOwn(def.key)) continue;
|
|
gl.setVisible(def.key, on[def.key]);
|
|
const button = layerBar.root.querySelector<HTMLButtonElement>(`[data-layer="${def.key}"]`);
|
|
if (button && meta && !glOn) {
|
|
button.disabled = true;
|
|
button.title = `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`;
|
|
}
|
|
}
|
|
if (hillshade) gl.setData("hillshade", on.satellite ? hillshade.shade : hillshade.plain);
|
|
const shown = glOn ? on : ({} as Record<MapLayerKey, boolean>);
|
|
legendBox.show(meta ? legendRows(shown, data) : [], glOn && on.satellite ? [lightRow] : []);
|
|
if (glOn && on.slope && data.slope) legendBox.root.firstElementChild?.append(gear);
|
|
if (first) void Promise.all(LAYERS.map((def) => ensure(def.key)));
|
|
}
|
|
|
|
/** 켠 겹 자료 한 번 — 실패 = 그 겹만 끔 + 알림(취향은 안 바꿈). */
|
|
function ensure(key: MapLayerKey): Promise<void> {
|
|
if (!meta || !on[key] || isOwn(key) || tileOnly(key) || key === "landslide") return done();
|
|
if (loaded.has(key)) return done();
|
|
const slot = key === "forestRoad" ? "road" : key;
|
|
if (loaded.has(slot as MapLayerKey)) return done();
|
|
const running = pending.get(slot);
|
|
if (running) return running;
|
|
const at = meta;
|
|
const job = fill(key, at)
|
|
.then(() => {
|
|
loaded.add(key);
|
|
loaded.add(slot as MapLayerKey);
|
|
sync(glOn, meta);
|
|
opts.redraw();
|
|
})
|
|
.catch((error: unknown) => {
|
|
for (const def of LAYERS)
|
|
if (def.key === key || (def.key === "forestRoad" ? "road" : def.key) === slot)
|
|
on[def.key] = false;
|
|
sync(glOn, meta);
|
|
opts.redraw();
|
|
const text = error instanceof Error ? error.message : String(error);
|
|
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
|
|
})
|
|
.finally(() => pending.delete(slot));
|
|
pending.set(slot, job);
|
|
return job;
|
|
}
|
|
const done = (): Promise<void> => Promise.resolve();
|
|
|
|
/** 프로젝트 범위 도엽 — 한 번 셈 · 조각은 이름마다 한 번. */
|
|
let sheetList: Promise<string[]> | null = null;
|
|
const sheetCache = new Map<string, Promise<GeoJsonCollection>>();
|
|
async function sheetCollection(names: string[], at: VWorldMeta): Promise<GeoJsonCollection> {
|
|
sheetList ??= fetchPlanSheetTiles(projectId, [
|
|
at.lon_min,
|
|
at.lat_min,
|
|
at.lon_max,
|
|
at.lat_max,
|
|
]).then((got) => {
|
|
if (got.count > SHEET_MAX) throw new Error(`도엽 ${got.count}장 — 넓어서 선 겹 숨김`);
|
|
return got.sheets;
|
|
});
|
|
const sheets = await sheetList.catch((error: unknown) => {
|
|
sheetList = null;
|
|
throw error;
|
|
});
|
|
const parts = await Promise.all(
|
|
sheets.flatMap((sheet) =>
|
|
names.map((name) => {
|
|
const key = `${sheet}/${name}`;
|
|
if (!sheetCache.has(key))
|
|
sheetCache.set(
|
|
key,
|
|
fetchPlanSheetTile(projectId, sheet, name).catch(() => ({ features: [] })),
|
|
);
|
|
return sheetCache.get(key)!;
|
|
}),
|
|
),
|
|
);
|
|
return { features: parts.flatMap((part) => part.features ?? []) };
|
|
}
|
|
|
|
async function fill(key: MapLayerKey, at: VWorldMeta): Promise<void> {
|
|
const names = SHEET_NAMES[key];
|
|
if (names) {
|
|
const collection = await sheetCollection(names, at);
|
|
if (key === "road" || key === "forestRoad") {
|
|
const small = (collection.features ?? []).filter((feature) =>
|
|
FOREST_ROAD_KINDS.has(String(feature.properties?.["도로구분"])),
|
|
);
|
|
data.road = { small: { features: small } };
|
|
return gl.setData("road", collection);
|
|
}
|
|
if (key === "water") data.water = collection;
|
|
if (key === "building") data.building = collection;
|
|
if (key !== "place") return gl.setData(key, collection);
|
|
const places = (collection.features ?? []).flatMap((feature) => {
|
|
const name = feature.properties?.["명칭"];
|
|
return feature.geometry?.type === "Point" && typeof name === "string" && name
|
|
? [{ type: "Feature", properties: { name }, geometry: feature.geometry }]
|
|
: [];
|
|
});
|
|
await document.fonts
|
|
.load(`600 24px "${GL_FONT}"`, places.map((p) => p.properties.name).join("") || "가")
|
|
.catch(() => null);
|
|
return gl.setData("place", { features: places });
|
|
}
|
|
if (key === "hillshade") {
|
|
const raster = await fetchPlanRaster(projectId, "hillshade");
|
|
const image = await loadImage(raster.image);
|
|
const plain = glImage(raster);
|
|
if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
|
|
return;
|
|
}
|
|
if (key === "slope") {
|
|
const raster = await fetchPlanRaster(projectId, "slope");
|
|
slopeValuesNow = slopeValues(await loadImage(raster.image));
|
|
data.slope = { raster, bands: slopeBands };
|
|
return paintSlopeNow();
|
|
}
|
|
if (key === "cadastral") return gl.setData("cadastral", await fetchPlanCadastral(projectId));
|
|
if (key === "zones") {
|
|
const zones = await fetchPlanZones(projectId);
|
|
data.zones = { summary: zones.summary };
|
|
return gl.setData(
|
|
"zones",
|
|
glTagged(zones, (props) =>
|
|
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
|
|
),
|
|
);
|
|
}
|
|
if (key === "soils") {
|
|
// 그림 = 조각(622-2) · 범례 = 받은 범위 요약
|
|
const soils = await fetchPlanSoils(projectId);
|
|
data.soils = {
|
|
kinds: soils.summary.map((soil) => ({ color: soilColor(soil.code) })),
|
|
summary: soils.summary,
|
|
};
|
|
return;
|
|
}
|
|
if (key === "landslideZones") {
|
|
const info = await fetchPlanLandslideZones(projectId);
|
|
data.landslideZones = { info };
|
|
return gl.setData("landslideZones", {
|
|
features: [
|
|
...(info.parcels.features ?? []),
|
|
...info.points.map((point) => ({
|
|
type: "Feature",
|
|
properties: {},
|
|
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
|
|
})),
|
|
],
|
|
});
|
|
}
|
|
if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
|
|
const kind = LINE_LAYERS[key].kind;
|
|
const slow = window.setTimeout(
|
|
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
|
|
600,
|
|
);
|
|
try {
|
|
const found = kind
|
|
? await fetchPlanLines(projectId, kind).then((lines) => ({
|
|
collection: lines as GeoJsonCollection,
|
|
...lines.summary,
|
|
}))
|
|
: await fetchPlanZeroOrder(projectId);
|
|
data[key] = { count: found.count, length_m: found.length_m };
|
|
return gl.setData(key, found.collection);
|
|
} finally {
|
|
window.clearTimeout(slow);
|
|
}
|
|
}
|
|
}
|
|
|
|
const wanted = (): boolean => glOn && LAYERS.some((def) => !isOwn(def.key) && on[def.key]);
|
|
|
|
function park(): void {
|
|
if (!enabled) return;
|
|
enabled = false;
|
|
gl.enable(null);
|
|
}
|
|
|
|
return {
|
|
bar: layerBar.root,
|
|
legend: legendBox.root,
|
|
own: () => ({ contour: !glOn || on.contour, stream: !glOn || on.stream }),
|
|
sync,
|
|
follow(at, normalizer, toScreen, width, height) {
|
|
if (!wanted() || width <= 0 || height <= 0) return park();
|
|
if (!enabled) {
|
|
enabled = gl.enable(wrap);
|
|
size = "";
|
|
if (!enabled) return;
|
|
}
|
|
const container = gl.container();
|
|
if (!container) return;
|
|
// 맨 밑(띠로 자른 B05 칸 아래) — 그 칸을 다시 켜면 앞에 붙으므로 그릴 때마다 맞춤
|
|
if (wrap.firstElementChild !== container) wrap.prepend(container);
|
|
const now = `${width}x${height}`;
|
|
if (now !== size) {
|
|
size = now;
|
|
container.style.width = `${width + 2 * GL_MARGIN_PX}px`;
|
|
container.style.height = `${height + 2 * GL_MARGIN_PX}px`;
|
|
gl.resize();
|
|
}
|
|
const camera = glCameraOf(at, normalizer, toScreen, width, height);
|
|
if (!camera) return;
|
|
gl.camera(camera.center, camera.zoom, camera.bearing);
|
|
container.style.transform = `matrix(${camera.css.join(", ")}, 0, 0)`;
|
|
},
|
|
park,
|
|
state: () => ({
|
|
on: { ...on },
|
|
loaded: [...loaded],
|
|
map: enabled,
|
|
gl: Object.fromEntries(
|
|
(gl.map()?.getStyle().layers ?? [])
|
|
.filter((layer) => layer.layout?.visibility !== "none")
|
|
.map((layer) => [
|
|
layer.id,
|
|
layer.type === "raster"
|
|
? 1
|
|
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
|
|
]),
|
|
),
|
|
sheets: sheetCache.size,
|
|
}),
|
|
};
|
|
}
|