Files
Aislo/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts
T
eomsangdonandClaude Opus 5.5 99ec4b8efe feat(B05 평면 지도): 공용 도구줄 표 · 겹 줄 · 범례 · 지형 칩 붙임 · 노선추천 = 도구줄 미리보기(넷 · 하나만 · 끔) · [적용] · 추천 모달 · 좌측 칸 걷음 · 지형 바꿈 = 지형 칩 (PLAN 624-3 · 626-1 · 요구 530 · 531)
- 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>
2026-10-31 21:30:49 +09:00

211 lines
8.9 KiB
TypeScript

/* =============================================================================
* B05_Profile_UI_RouteEdit_Gl.ts
* 평면 노선 편집 GL 바탕(PLAN 589-2) — 바탕칠 · 등고선(LAS 5 m · 1 m 선 · 도엽) · 도엽 하천을 MapLibre 로.
* TM 겹(LAS) · 도엽 모두 준비한 겹(정규화 · TM 자리)을 경위도로 되돌려 올림(`preparedToGeoJson`) —
* 캔버스와 같은 자리 · 같은 솎기(간격 배수 · 1 m 선 켬 조건) · 노선 둘레 띠 자르기(지도 칸 clip-path) ·
* 하천 = LAS 등고선이 있는 데까지(자료를 상자로 자름 · 0-9 ⑮).
* 고른 등고선 · 등고 글 · 노선 · 꼭짓점 · 곡선 · 측점 · 재기 · 끌기 = 위 캔버스 그대로 · 집기 판정 그대로.
* 공용 지도 겹(624-3) = `_Layers` — 등고선 · 하천 켬끔은 그 겹 줄 · 그 밖 겹은 띠로 안 자르는 둘째 칸(같이 따라감) ·
* 켬끔 단추 = 겹 줄 끝 그래픽 카드 단추(`toggle`).
* ========================================================================== */
import type { FilterSpecification } from "maplibre-gl";
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { createTmGl, lineSpecs } from "../B04_PreProcess/B04_PreProcess_UI_MapGl";
import {
createTmNormalizer,
normalizedToScreen,
type Normalizer,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
lonLatOfNormFn,
preparedToGeoJson,
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import type { RouteEditLayers } from "./B05_Profile_UI_RouteEdit_Layers";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
type Key = "sheets" | "minor" | "contour";
/** 간격 배수만(높이 모르는 줄은 늘) — 캔버스 `drawPreparedLayer(everyM)` 와 같은 거름. */
const stepFilter = (step: number): FilterSpecification =>
(step > 0
? ["any", ["!", ["has", "level"]], ["==", ["%", ["get", "level"], step], 0]]
: ["literal", true]) as unknown as FilterSpecification;
export interface RouteEditGl {
/** 그리기마다 — 켜져 있으면 화면을 따라가고 참(캔버스는 바탕을 쉼) · `again` = 장면 다시 그리기(켬끔 뒤). */
follow: (scene: RouteEditScene, bandM: number, again: () => void) => boolean;
/** GL ↔ 옛 캔버스(겹 줄 그래픽 카드 단추). */
toggle: () => void;
/** 마지막 장면 다시 그리기(겹 켬끔 뒤). */
redraw: () => void;
dispose: () => void;
}
const BY_CANVAS = new WeakMap<HTMLCanvasElement, RouteEditGl>();
/** 그 캔버스의 GL 바탕(편집 뼈대가 만듦) — 없으면 옛 캔버스 그대로. */
export const routeGlOf = (canvas: HTMLCanvasElement): RouteEditGl | undefined =>
BY_CANVAS.get(canvas);
/** `canvas` = 편집 캔버스(칸 = 그 부모) · `layers` = 공용 지도 겹(624-3) · `host` = 떠날 때 버림. */
export function createRouteEditGl(
canvas: HTMLCanvasElement,
layers: RouteEditLayers,
host?: { onDispose: (fn: () => void) => void },
): RouteEditGl {
const wrap = canvas.parentElement!;
let redraw: (() => void) | null = null;
let shown: [RouteEditContours | null, ReadonlyArray<PreparedLayer> | null] = [null, null];
let step = 0;
let meta: VWorldMeta | null = null;
let normalizer: Normalizer | null = null;
let toLonLat: ((nx: number, ny: number) => [number, number]) | null = null;
let minorShown: PreparedLayer | null = null;
let synced: [boolean, VWorldMeta | null] = [false, null];
const tm = createTmGl<Key>({
parent: wrap,
specs: () => [
...lineSpecs<Key>("sheets", themeColor("--map-sheet-stream", "#2563eb"), 1.2, {
marker: "dot",
}),
...lineSpecs<Key>(
"minor",
themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)"),
0.5,
),
...lineSpecs<Key>("contour", themeColor("--map-contour-major", "#818cf8"), 0.8, {
filter: stepFilter(step),
}),
],
themeNames: ["--draw-surface", "--map-contour-major", "--map-sheet-stream"],
onChange: (on) => {
mark(on);
redraw?.();
},
});
const mark = (on: boolean): void => {
synced = [on, meta];
layers.sync(on, meta);
if (!on) layers.park();
// 바탕칠 = 칸(지도 칸은 띠로 잘림 · 캔버스는 투명)
wrap.style.background = on ? "var(--draw-surface)" : "";
};
mark(tm.on());
/** 바탕 한 벌을 바꿈(받은 뒤 · 띠 따라 다시 받은 뒤). */
function setBackground(
nextMeta: VWorldMeta,
contours: RouteEditContours | null,
otherSheets: ReadonlyArray<PreparedLayer>,
): void {
meta = nextMeta;
normalizer = createTmNormalizer(nextMeta);
toLonLat = lonLatOfNormFn(normalizer);
minorShown = null;
tm.gl.setData("minor", null);
tm.gl.setData("contour", contours ? preparedToGeoJson(contours.layer, toLonLat) : null);
// 하천 = LAS 등고선이 있는 데까지(정규화 상자)
const features = contours?.source === "las" ? contours.layer.features : [];
const box = features.length
? features.reduce(
(got, feature) =>
[
Math.min(got[0], feature.minX),
Math.min(got[1], feature.minY),
Math.max(got[2], feature.maxX),
Math.max(got[3], feature.maxY),
] as const,
[Infinity, Infinity, -Infinity, -Infinity] as const,
)
: undefined;
tm.gl.setData("sheets", {
features: otherSheets.flatMap((layer) => preparedToGeoJson(layer, toLonLat!, box).features),
});
}
const api: RouteEditGl = {
follow(scene, bandM, again) {
redraw = again;
const container = tm.gl.container();
const at = scene.contours?.meta;
if (at && (synced[0] !== tm.on() || synced[1] !== at)) {
synced = [tm.on(), at]; // 겹 줄 · 범례 = 켬끔 · 범위가 바뀔 때만
layers.sync(...synced);
}
if (!tm.on() || !at || !container) return false;
if (shown[0] !== scene.contours || shown[1] !== scene.otherSheets) {
shown = [scene.contours, scene.otherSheets];
setBackground(at, scene.contours, scene.otherSheets);
}
if (!meta || !normalizer) return false;
const own = layers.own();
const { view } = scene;
const { gl } = tm;
const minor =
!!scene.minorContours && !!scene.contours && scene.contourStepM <= scene.contours.intervalM;
if (minor && scene.minorContours !== minorShown && toLonLat) {
minorShown = scene.minorContours;
gl.setData("minor", preparedToGeoJson(scene.minorContours!, toLonLat));
}
gl.setVisible("sheets", own.stream);
gl.setVisible("contour", own.contour);
gl.setVisible("minor", minor && own.contour);
if (scene.contourStepM !== step) {
step = scene.contourStepM;
if (gl.map()?.getLayer("contour-line"))
gl.map()!.setFilter("contour-line", [
"all",
["!=", ["geometry-type"], "Point"],
stepFilter(step),
] as unknown as FilterSpecification);
}
// 캔버스 돌림(한가운데 축) — 같은 식으로 카메라 · 띠 귀퉁이
const cos = Math.cos(scene.rotationRad);
const sin = Math.sin(scene.rotationRad);
const cx = view.width / 2;
const cy = view.height / 2;
const turn = (x: number, y: number): [number, number] => [
cx + (x - cx) * cos - (y - cy) * sin,
cy + (x - cx) * sin + (y - cy) * cos,
];
const toScreen = (nx: number, ny: number): [number, number] =>
turn(...normalizedToScreen(view, nx, ny));
tm.follow(meta, normalizer, toScreen, view.width, view.height);
layers.follow(meta, normalizer, toScreen, view.width, view.height);
// 노선 둘레 띠만(캔버스 자르기와 같은 네모 · 돌림 · 칸 안 px)
const line = (scene.plannedLine.length ? scene.plannedLine : scene.planned) as Vertex[];
const band = scene.hasMeta ? contourBandRect(line, scene.toScreen, bandM) : null;
const corners: [number, number][] = band
? [
[band.x, band.y],
[band.x + band.width, band.y],
[band.x + band.width, band.y + band.height],
[band.x, band.y + band.height],
]
: [];
container.style.clipPath = band
? `polygon(${corners
.map(([x, y]) =>
tm
.local(...turn(x, y))
.map((v) => `${v}px`)
.join(" "),
)
.join(", ")})`
: "";
return true;
},
toggle: () => tm.toggle(),
redraw: () => redraw?.(),
dispose: () => (layers.park(), tm.dispose()),
};
BY_CANVAS.set(canvas, api);
host?.onDispose(api.dispose);
return api;
}