perf(D03): 노선분석 평면 두 장 캔버스 · 한 화면 한 번 그리기 · 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로 (PLAN 582-0 · 582-1 · 요구 440-9 · 441)
- D03 평면 = 바탕(위성 · 바탕 겹 다섯) / 위(그은 선 · 재기) 두 장 · 마우스 움직임 · 누름 = 위만 · 화면 바뀜 = 둘 다 · rAF 한 화면 한 번 - PlanMap.redraw = 위 겹만 · redrawBase = 바탕까지(겹 켬끔 · 받음) - 공용 그리기에 선택 칸만 더함 — 조각 색인(part.lod · indexLayerLod 를 부른 레이어만) · 글 폭 기억(widths 인자) · B04 · B05 는 안 켬 - 한 판 한 번 긋기(d)는 뺌 — 반투명 선 겹침 색이 바뀌고(픽셀 14만 차) 래스터가 더 느림(36 → 60 ms) - 지킴 시험 test_582_contour_lod — 색인 그린 명령 = 지금 명령(27 줌 · 자리 · 닫힌 고리 · 짧은 조각) · 글 폭 기억 같은 글 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { themeColor } from "@ui/ui_template_palette";
|
||||
import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch";
|
||||
import { pickLodList } from "./logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
|
||||
// 2D 지도 벡터 레이어 렌더 엔진.
|
||||
// GeoJSON 좌표를 로드 시 1회만 정규화 맵 좌표(0~1)로 사전 투영해 두고,
|
||||
@@ -46,6 +47,14 @@ export type PreparedPart = {
|
||||
coords: Float64Array;
|
||||
closed: boolean;
|
||||
weights: Float64Array | null;
|
||||
/** 줌 단계 색인(582 · `indexLayerLod` 가 붙임 · 없으면 정점 전부 훑음) — 그리는 정점은 같음. */
|
||||
lod?: PartLod;
|
||||
};
|
||||
|
||||
/** 단마다 「무게 ≥ 그 단」 정점 번호(원래 차례). thresholds 는 큰 → 작은 차례. */
|
||||
export type PartLod = {
|
||||
thresholds: number[];
|
||||
lists: Uint32Array[];
|
||||
};
|
||||
|
||||
/** 사전 투영된 피처 1개. bbox는 정규화 좌표 기준이며 컬링에 사용한다. */
|
||||
@@ -375,6 +384,9 @@ function drawLineParts(
|
||||
const coords = part.coords;
|
||||
if (coords.length < 4) continue;
|
||||
const weights = part.weights;
|
||||
// 줌 단계 색인(582) = 「무게 ≥ 그 단」 정점만 훑음 — 아래 거름이 그대로라 그리는 정점 같음.
|
||||
const list = part.lod ? pickLodList(part.lod, tolerance) : null;
|
||||
const count = list ? list.length : coords.length / 2;
|
||||
// 현재 줌에서 화면 오차 LOD_PX 미만인 정점만 생략 (끝점은 weight=∞라 항상 유지).
|
||||
// 정점 사이 보간은 하지 않는다 — 원본 데이터의 형상 그대로 표시 (2026-07-28 사용자 지시).
|
||||
context.beginPath();
|
||||
@@ -388,8 +400,10 @@ function drawLineParts(
|
||||
let prevX = 0;
|
||||
let prevY = 0;
|
||||
let prevSide = 0;
|
||||
for (let i = 0; i < coords.length; i += 2) {
|
||||
if (weights && weights[i / 2] < tolerance) continue;
|
||||
for (let k = 0; k < count; k += 1) {
|
||||
const v = list ? list[k] : k;
|
||||
if (weights && weights[v] < tolerance) continue;
|
||||
const i = v * 2;
|
||||
const x = coords[i] * affine.ax + affine.bx;
|
||||
const y = coords[i + 1] * affine.ay + affine.by;
|
||||
const side = sideOf(x, y);
|
||||
@@ -584,6 +598,8 @@ export function drawPreparedLabels(
|
||||
uprightRad = 0,
|
||||
/** 글자 테두리 색 — 지도 바탕색과 같아야 글자가 선을 끊어 보인다. 기본 = `--map-halo`. */
|
||||
halo = haloColor(),
|
||||
/** 글 폭 기억(582 · 글 → 폭 px) — 부르는 쪽이 글꼴마다 하나 둠 · 없으면 그때마다 잼. */
|
||||
widths?: Map<string, number>,
|
||||
): void {
|
||||
const affine = affineOf(view);
|
||||
const margin = CULL_MARGIN;
|
||||
@@ -600,7 +616,12 @@ export function drawPreparedLabels(
|
||||
const y = feature.labelAnchorY * affine.ay + affine.by;
|
||||
if (x < -margin || x > view.width + margin) continue;
|
||||
if (y < -margin || y > view.height + margin) continue;
|
||||
const half = context.measureText(feature.labelText).width / 2 + LABEL_GAP_PX;
|
||||
let width = widths?.get(feature.labelText);
|
||||
if (width === undefined) {
|
||||
width = context.measureText(feature.labelText).width;
|
||||
widths?.set(feature.labelText, width);
|
||||
}
|
||||
const half = width / 2 + LABEL_GAP_PX;
|
||||
if (
|
||||
drawn.some(
|
||||
(item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < LABEL_ROW_PX,
|
||||
|
||||
@@ -13,11 +13,44 @@ import type {
|
||||
GeoJsonCollection,
|
||||
GeoJsonGeometry,
|
||||
Normalizer,
|
||||
PartLod,
|
||||
PreparedFeature,
|
||||
PreparedLayer,
|
||||
PreparedPart,
|
||||
} from "../B04_PreProcess_UI_MapRender";
|
||||
|
||||
/** 줌 단계 무게(정규화 좌표 · 큰 → 작은 · 4 배 간격) — 화면 1 폭 ≈ 1,400 px 이면 다 줌아웃 ~ 2,000 배. */
|
||||
const LOD_THRESHOLDS = [4 ** -5, 4 ** -6, 4 ** -7, 4 ** -8, 4 ** -9, 4 ** -10];
|
||||
/** 이보다 짧은 조각은 색인 없이 전부 훑음(훑기가 이미 짧음). */
|
||||
const LOD_MIN_VERTICES = 64;
|
||||
|
||||
/** 조각 하나의 줌 단계 색인. 짧거나 무게가 없으면 undefined. */
|
||||
export function buildPartLod(weights: Float64Array | null): PartLod | undefined {
|
||||
if (!weights || weights.length < LOD_MIN_VERTICES) return undefined;
|
||||
const lists = LOD_THRESHOLDS.map((threshold) => {
|
||||
const kept: number[] = [];
|
||||
for (let v = 0; v < weights.length; v += 1) if (weights[v] >= threshold) kept.push(v);
|
||||
return Uint32Array.from(kept);
|
||||
});
|
||||
return { thresholds: LOD_THRESHOLDS, lists };
|
||||
}
|
||||
|
||||
/** 허용 오차에 맞는 가장 성긴 단 — 「무게 ≥ 오차」 정점을 모두 담음. 맞는 단이 없으면 null(전부 훑음). */
|
||||
export function pickLodList(lod: PartLod, tolerance: number): Uint32Array | null {
|
||||
for (let k = 0; k < lod.thresholds.length; k += 1)
|
||||
if (lod.thresholds[k] <= tolerance) return lod.lists[k];
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 레이어 선 조각마다 줌 단계 색인을 붙임(제자리 · 582 — 지금 D03 만 부름). */
|
||||
export function indexLayerLod(layer: PreparedLayer): PreparedLayer {
|
||||
for (const feature of layer.features) {
|
||||
if (feature.kind !== "line") continue;
|
||||
for (const part of feature.parts) part.lod = buildPartLod(part.weights);
|
||||
}
|
||||
return layer;
|
||||
}
|
||||
|
||||
function isPoint(value: unknown): value is [number, number] {
|
||||
return Array.isArray(value) && typeof value[0] === "number" && typeof value[1] === "number";
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
|
||||
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
|
||||
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
|
||||
* 겹 다섯 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
|
||||
* ========================================================================== */
|
||||
|
||||
import { createIconButton } from "@ui/ui_template_button";
|
||||
@@ -30,7 +31,10 @@ import {
|
||||
type GeoJsonCollection,
|
||||
type PreparedLayer,
|
||||
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
|
||||
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
indexLayerLod,
|
||||
prepareLayer,
|
||||
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
|
||||
import {
|
||||
fetchPlanCadastral,
|
||||
fetchPlanRaster,
|
||||
@@ -159,7 +163,7 @@ export function createPlanLayers(opts: {
|
||||
void ensure(key);
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
map.redrawBase();
|
||||
}
|
||||
|
||||
function showSatellite(): void {
|
||||
@@ -178,7 +182,7 @@ export function createPlanLayers(opts: {
|
||||
if (meta !== at) return;
|
||||
showSatellite();
|
||||
sync();
|
||||
map.redraw();
|
||||
map.redrawBase();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
const text = error instanceof Error ? error.message : String(error);
|
||||
@@ -213,7 +217,7 @@ export function createPlanLayers(opts: {
|
||||
"route",
|
||||
bbox.join(","),
|
||||
);
|
||||
data.contour = prepare(collection, ["등고수치"]);
|
||||
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
|
||||
} else if (key === "stream") {
|
||||
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
|
||||
projectId,
|
||||
@@ -258,8 +262,11 @@ export function createPlanLayers(opts: {
|
||||
drawPreparedLayer(context, layer, map.view, "dot", everyM);
|
||||
};
|
||||
|
||||
/** 등고 글 폭 기억(글꼴 하나 · 582). */
|
||||
const labelWidths = new Map<string, number>();
|
||||
const layers: PlanMapLayer[] = [
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
const slope = on.slope && data.slope;
|
||||
if (!slope) return;
|
||||
@@ -271,6 +278,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.cadastral || !data.cadastral) return;
|
||||
context.globalAlpha = 0.8;
|
||||
@@ -278,6 +286,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
const contour = on.contour && data.contour;
|
||||
if (!contour) return;
|
||||
@@ -297,10 +306,12 @@ export function createPlanLayers(opts: {
|
||||
25,
|
||||
map.uprightRad,
|
||||
"rgba(0, 0, 0, 0.6)",
|
||||
labelWidths,
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.road || !data.road) return;
|
||||
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
|
||||
@@ -308,6 +319,7 @@ export function createPlanLayers(opts: {
|
||||
},
|
||||
},
|
||||
{
|
||||
base: true,
|
||||
draw: (context, map) => {
|
||||
if (!on.stream || !data.stream) return;
|
||||
const color = themeColor("--draw-map-stream", "#38bdf8");
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
* 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠).
|
||||
* 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향).
|
||||
* 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김.
|
||||
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
|
||||
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
|
||||
* ========================================================================== */
|
||||
|
||||
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
|
||||
@@ -66,6 +68,8 @@ export interface ScreenMap {
|
||||
|
||||
/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */
|
||||
export interface PlanMapLayer {
|
||||
/** 바탕 겹(아래 캔버스 · 마우스 움직임엔 안 다시 그림). */
|
||||
base?: boolean;
|
||||
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
|
||||
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean;
|
||||
hover?: (px: number, py: number, map: ScreenMap) => void;
|
||||
@@ -92,8 +96,10 @@ export interface PlanMap {
|
||||
update: (patch: Partial<PlanMapScene>) => void;
|
||||
setLayers: (layers: PlanMapLayer[]) => void;
|
||||
setTools: (tools: Partial<PlanTools>) => void;
|
||||
/** 다시 그림(겹 값이 바뀜). */
|
||||
/** 위 겹 다시 그림(그은 선 · 재기 값이 바뀜). */
|
||||
redraw: () => void;
|
||||
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
|
||||
redrawBase: () => void;
|
||||
/** 맞출 점이 들어오게 다시 맞춤. */
|
||||
fit: () => void;
|
||||
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
|
||||
@@ -130,6 +136,8 @@ function fakeMeta(points: Vertex[]): VWorldMeta | null {
|
||||
export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
const root = document.createElement("div");
|
||||
root.className = "d03-plan__map";
|
||||
const baseCanvas = document.createElement("canvas");
|
||||
baseCanvas.className = "d03-plan__base";
|
||||
const canvas = document.createElement("canvas");
|
||||
let marks = { stations: true, slopes: true };
|
||||
const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => {
|
||||
@@ -139,7 +147,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
onClick: () => {
|
||||
marks = { ...marks, [act]: !marks[act] };
|
||||
button.setAttribute("aria-pressed", String(marks[act]));
|
||||
draw();
|
||||
drawTop();
|
||||
},
|
||||
});
|
||||
return button;
|
||||
@@ -170,7 +178,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
});
|
||||
const toolbar = tools.root;
|
||||
toolbar.classList.add("d03-plan__tools");
|
||||
root.append(canvas, toolbar);
|
||||
root.append(baseCanvas, canvas, toolbar);
|
||||
const baseContext = baseCanvas.getContext("2d")!;
|
||||
const context = canvas.getContext("2d")!;
|
||||
let scene: PlanMapScene = { meta: null, image: null, fit: [] };
|
||||
let layers: PlanMapLayer[] = [];
|
||||
@@ -231,33 +240,58 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
fitted = true;
|
||||
}
|
||||
|
||||
function draw(): void {
|
||||
context.clearRect(0, 0, view.width, view.height);
|
||||
/** 한 장 그림 — 아래 = 위성 + 바탕 겹 · 위 = 나머지 겹. */
|
||||
function paint(ctx: CanvasRenderingContext2D, base: boolean): void {
|
||||
ctx.clearRect(0, 0, view.width, view.height);
|
||||
if (!meta) return;
|
||||
context.save();
|
||||
context.translate(view.width / 2, view.height / 2);
|
||||
context.rotate(rotation.radians());
|
||||
context.translate(-view.width / 2, -view.height / 2);
|
||||
if (scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
ctx.save();
|
||||
ctx.translate(view.width / 2, view.height / 2);
|
||||
ctx.rotate(rotation.radians());
|
||||
ctx.translate(-view.width / 2, -view.height / 2);
|
||||
if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) {
|
||||
const [x0, y0] = normalizedToScreen(view, 0, 0);
|
||||
const [x1, y1] = normalizedToScreen(view, 1, 1);
|
||||
context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0);
|
||||
}
|
||||
const map = screenMap();
|
||||
for (const layer of layers) {
|
||||
context.save();
|
||||
layer.draw(context, map);
|
||||
context.restore();
|
||||
if (!layer.base !== !base) continue;
|
||||
ctx.save();
|
||||
layer.draw(ctx, map);
|
||||
ctx.restore();
|
||||
}
|
||||
context.restore();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
let frame = 0;
|
||||
let baseDirty = false;
|
||||
let topDirty = false;
|
||||
/** 모인 다시 그림을 지금 그림. */
|
||||
function flush(): void {
|
||||
cancelAnimationFrame(frame);
|
||||
frame = 0;
|
||||
if (baseDirty) paint(baseContext, true);
|
||||
if (topDirty) paint(context, false);
|
||||
baseDirty = topDirty = false;
|
||||
}
|
||||
/** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */
|
||||
function schedule(base: boolean): void {
|
||||
baseDirty ||= base;
|
||||
topDirty = true;
|
||||
if (!frame) frame = requestAnimationFrame(flush);
|
||||
}
|
||||
const draw = (): void => schedule(true);
|
||||
const drawTop = (): void => schedule(false);
|
||||
|
||||
function resize(): void {
|
||||
fitCanvas(baseCanvas, baseContext);
|
||||
const { width, height } = fitCanvas(canvas, context);
|
||||
if (!width || !height) return;
|
||||
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
|
||||
if (!fitted) refit();
|
||||
draw();
|
||||
// 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음)
|
||||
baseDirty = topDirty = true;
|
||||
flush();
|
||||
}
|
||||
|
||||
/** 누른 화면 자리 → 그림 px(돌리기 전). */
|
||||
@@ -270,25 +304,25 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (!meta || event.button !== 0) return;
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 0))) drawTop();
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
for (const layer of layers) layer.hover?.(px, py, map);
|
||||
draw();
|
||||
drawTop();
|
||||
});
|
||||
canvas.addEventListener("pointerleave", () => {
|
||||
for (const layer of layers) layer.leave?.();
|
||||
draw();
|
||||
drawTop();
|
||||
});
|
||||
canvas.addEventListener("contextmenu", (event) => {
|
||||
event.preventDefault();
|
||||
if (!meta) return;
|
||||
const [px, py] = at(event);
|
||||
const map = screenMap();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 2))) draw();
|
||||
if (layers.some((layer) => layer.press?.(px, py, map, 2))) drawTop();
|
||||
});
|
||||
bindRouteEditNavigation({
|
||||
canvas,
|
||||
@@ -322,7 +356,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
if (tools.measure !== undefined)
|
||||
measureButton.setAttribute("aria-pressed", String(tools.measure));
|
||||
},
|
||||
redraw: draw,
|
||||
redraw: drawTop,
|
||||
redrawBase: draw,
|
||||
fit() {
|
||||
refit();
|
||||
draw();
|
||||
@@ -333,6 +368,9 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
|
||||
return rotation.rerotate(x, y);
|
||||
},
|
||||
rotationDeg: () => (rotation.radians() * 180) / Math.PI,
|
||||
dispose: () => observer.disconnect(),
|
||||
dispose: () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(frame);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,17 @@
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* 582 바탕 캔버스 — 위 캔버스 밑에 겹침 · 누름은 위가 받음 */
|
||||
.d03-plan__map .d03-plan__base {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.d03-plan__map canvas:not(.d03-plan__base) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 571-1 — 왼쪽은 겹 단추줄(222px) 오른쪽까지 · 그 사이가 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
|
||||
.d03-plan__tools {
|
||||
position: absolute;
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
/* 582 등고선 줌 단계 색인 헬퍼 — `B04_PreProcess_UI_MapRender_Prepare.ts` 의 `indexLayerLod` 를 붙인
|
||||
* 레이어와 안 붙인 레이어를 같은 `drawPreparedLayer` 로 가짜 캔버스에 그려 그리기 명령(차례 · 좌표)을
|
||||
* 줌 · 자리마다 맞대고 JSON 으로 낸다. 글 폭 기억(`drawPreparedLabels` widths)도 같은 방식.
|
||||
*/
|
||||
const path = require("path");
|
||||
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) });
|
||||
const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts");
|
||||
const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts");
|
||||
|
||||
const W = 1400;
|
||||
const H = 900;
|
||||
const META = { width_meters: 10000, height_meters: 8000 };
|
||||
const NORMALIZER = { lonMin: 127, latMin: 36, lonRange: 0.1, latRange: 0.08, aspect: 1.25 };
|
||||
|
||||
/** 그리기 명령을 글로 적는 가짜 캔버스. */
|
||||
function recorder() {
|
||||
const log = [];
|
||||
const rec =
|
||||
(name) =>
|
||||
(...args) =>
|
||||
log.push(`${name}(${args.map((v) => (typeof v === "number" ? v.toFixed(6) : v)).join(",")})`);
|
||||
let measured = 0;
|
||||
return {
|
||||
log,
|
||||
measured: () => measured,
|
||||
beginPath: rec("beginPath"),
|
||||
moveTo: rec("moveTo"),
|
||||
lineTo: rec("lineTo"),
|
||||
closePath: rec("closePath"),
|
||||
stroke: rec("stroke"),
|
||||
arc: rec("arc"),
|
||||
fill: rec("fill"),
|
||||
save: rec("save"),
|
||||
restore: rec("restore"),
|
||||
translate: rec("translate"),
|
||||
rotate: rec("rotate"),
|
||||
strokeText: rec("strokeText"),
|
||||
fillText: rec("fillText"),
|
||||
measureText: (text) => {
|
||||
measured += 1;
|
||||
return { width: text.length * 6.1 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 정해진 씨앗의 난수(결과가 매번 같게). */
|
||||
function random(seed) {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (s * 1664525 + 1013904223) >>> 0;
|
||||
return s / 2 ** 32;
|
||||
};
|
||||
}
|
||||
|
||||
/** 등고선 비슷한 가닥 — 구불구불한 열린 선 · 닫힌 고리 · 짧은 조각. */
|
||||
function collection() {
|
||||
const rnd = random(582);
|
||||
const features = [];
|
||||
const lonLat = (x, y) => [127 + x * 0.1, 36 + (1 - y) * 0.08];
|
||||
for (let f = 0; f < 60; f += 1) {
|
||||
const kind = f % 3; // 0 열린 긴 선 · 1 닫힌 고리 · 2 짧은 조각
|
||||
const n = kind === 2 ? 3 + Math.floor(rnd() * 40) : 200 + Math.floor(rnd() * 1800);
|
||||
const cx = rnd();
|
||||
const cy = rnd();
|
||||
const pts = [];
|
||||
if (kind === 1) {
|
||||
const r = 0.02 + rnd() * 0.2;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
const a = (i / n) * Math.PI * 2;
|
||||
const wobble = 1 + 0.15 * Math.sin(a * 7 + f) + 0.01 * (rnd() - 0.5);
|
||||
pts.push(lonLat(cx + r * wobble * Math.cos(a), cy + r * wobble * Math.sin(a)));
|
||||
}
|
||||
pts.push(pts[0]);
|
||||
} else {
|
||||
let x = cx;
|
||||
let y = cy;
|
||||
let heading = rnd() * Math.PI * 2;
|
||||
for (let i = 0; i < n; i += 1) {
|
||||
heading += (rnd() - 0.5) * 0.6;
|
||||
const step = 0.0004 + rnd() * 0.0008;
|
||||
x += Math.cos(heading) * step;
|
||||
y += Math.sin(heading) * step;
|
||||
pts.push(lonLat(x, y));
|
||||
}
|
||||
}
|
||||
const geometry =
|
||||
kind === 1
|
||||
? { type: "Polygon", coordinates: [pts] }
|
||||
: f % 5 === 0
|
||||
? { type: "MultiLineString", coordinates: [pts, pts.slice(0, Math.max(2, n >> 1))] }
|
||||
: { type: "LineString", coordinates: pts };
|
||||
features.push({ geometry, properties: { 등고수치: 400 + 5 * f } });
|
||||
}
|
||||
return { features };
|
||||
}
|
||||
|
||||
const plain = prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]);
|
||||
const indexed = prep.indexLayerLod(prep.prepareLayer(collection(), NORMALIZER, ["등고수치"]));
|
||||
|
||||
let lodParts = 0;
|
||||
let shortParts = 0;
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
if (part.lod) lodParts += 1;
|
||||
else shortParts += 1;
|
||||
}
|
||||
|
||||
const SCALES = [1, 1.7, 3, 7, 13, 50, 200, 1000, 2000];
|
||||
const OFFSETS = [
|
||||
[0, 0],
|
||||
[300, -200],
|
||||
[-900, 500],
|
||||
];
|
||||
const mapRect = render.computeMapRect(
|
||||
{ width_meters: META.width_meters, height_meters: META.height_meters },
|
||||
W,
|
||||
H,
|
||||
);
|
||||
const cases = [];
|
||||
for (const scale of SCALES)
|
||||
for (const [ox, oy] of OFFSETS) {
|
||||
const view = { width: W, height: H, scale, offsetX: ox, offsetY: oy, mapRect };
|
||||
const a = recorder();
|
||||
const b = recorder();
|
||||
for (const everyM of [undefined, 25]) {
|
||||
render.drawPreparedLayer(a, plain, view, "dot", everyM);
|
||||
render.drawPreparedLayer(b, indexed, view, "dot", everyM);
|
||||
}
|
||||
const widths = new Map();
|
||||
const la = recorder();
|
||||
const lb = recorder();
|
||||
render.drawPreparedLabels(la, plain, view, "#fff", 5, 0, "#000");
|
||||
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths);
|
||||
const measuredFirst = lb.measured();
|
||||
render.drawPreparedLabels(lb, indexed, view, "#fff", 5, 0, "#000", widths); // 두 번째 = 기억만 씀
|
||||
const measuredSecond = lb.measured() - measuredFirst;
|
||||
const half = lb.log.length / 2;
|
||||
cases.push({
|
||||
scale,
|
||||
offset: [ox, oy],
|
||||
commands: a.log.length,
|
||||
same: a.log.join("\n") === b.log.join("\n"),
|
||||
vertices: a.log.filter((line) => line.startsWith("lineTo")).length,
|
||||
labelsSame: la.log.join("\n") === lb.log.slice(0, half).join("\n"),
|
||||
labelsRepeatSame: lb.log.slice(0, half).join("\n") === lb.log.slice(half).join("\n"),
|
||||
labelMeasuredPlain: la.measured(),
|
||||
labelMeasuredCached: measuredSecond,
|
||||
});
|
||||
}
|
||||
|
||||
// 색인이 훑는 정점 수 — 다 줌아웃(배율 1)의 허용 오차에서 전부 대비 몇 %
|
||||
const tolerance = 0.75 / (mapRect.width * 1);
|
||||
let all = 0;
|
||||
let scanned = 0;
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
all += part.coords.length / 2;
|
||||
const list = part.lod ? prep.pickLodList(part.lod, tolerance) : null;
|
||||
scanned += list ? list.length : part.coords.length / 2;
|
||||
}
|
||||
|
||||
// 고른 단 = 「무게 ≥ 오차」 정점을 모두 담음(여러 오차)
|
||||
let supersetOk = true;
|
||||
for (const tol of [1e-2, 1e-3, 3e-4, 1e-4, 2e-5, 5e-6, 1e-6, 1e-7])
|
||||
for (const feature of indexed.features)
|
||||
for (const part of feature.parts) {
|
||||
if (!part.lod) continue;
|
||||
const list = prep.pickLodList(part.lod, tol);
|
||||
if (!list) continue;
|
||||
const inList = new Set(list);
|
||||
for (let v = 0; v < part.weights.length; v += 1)
|
||||
if (part.weights[v] >= tol && !inList.has(v)) supersetOk = false;
|
||||
for (let k = 1; k < list.length; k += 1) if (list[k] <= list[k - 1]) supersetOk = false;
|
||||
}
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
lodParts,
|
||||
shortParts,
|
||||
closedParts: indexed.features.filter((f) => f.parts.some((p) => p.closed)).length,
|
||||
cases,
|
||||
scannedRatio: scanned / all,
|
||||
supersetOk,
|
||||
noLodFinest: prep.pickLodList(indexed.features[0].parts[0].lod, 1e-9) === null,
|
||||
}),
|
||||
);
|
||||
@@ -0,0 +1,87 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""582 등고선 줌 단계 색인 · 글 폭 기억 — 그림 그대로(D03 먼저).
|
||||
|
||||
- 색인 붙인 레이어 = 안 붙인 레이어와 그리기 명령(차례 · 좌표)이 똑같음 — 여러 줌 · 자리 ·
|
||||
닫힌 고리 · 짧은 조각(색인 없음) · 간격 거름(25 m)
|
||||
- 고른 단은 「무게 ≥ 허용 오차」 정점을 모두 담고 원래 차례 · 아주 확대하면 색인 없이 전부 훑음
|
||||
- 다 줌아웃에서 훑는 정점이 줄어듦
|
||||
- 글 폭 기억 = 같은 글 · 같은 자리 · 두 번째부터 재지 않음
|
||||
- 색인 · 바탕 캔버스는 D03 만 켬(B04 · B05 는 안 부름)
|
||||
|
||||
Node 헬퍼(`helper_582_contour_lod.cjs`)가 진짜 TS 를 가짜 캔버스로 돌린다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = Path(HERE).parents[1]
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def r():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_582_contour_lod.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=120,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_조각_갈래가_다_있음(r):
|
||||
assert r["lodParts"] > 20
|
||||
assert r["shortParts"] > 10
|
||||
assert r["closedParts"] > 10
|
||||
|
||||
|
||||
def test_색인으로_그린_명령이_지금과_같음(r):
|
||||
assert len(r["cases"]) == 27
|
||||
for case in r["cases"]:
|
||||
assert case["same"], case
|
||||
# 줌 · 자리마다 그린 정점 수가 실제로 달라짐(거름이 일함)
|
||||
assert len({c["vertices"] for c in r["cases"]}) > 15
|
||||
assert all(c["vertices"] > 0 for c in r["cases"] if c["scale"] <= 13)
|
||||
|
||||
|
||||
def test_고른_단은_남는_정점을_모두_담음(r):
|
||||
assert r["supersetOk"]
|
||||
assert r["noLodFinest"]
|
||||
|
||||
|
||||
def test_다_줌아웃_훑기가_줄어듦(r):
|
||||
assert r["scannedRatio"] < 0.5
|
||||
|
||||
|
||||
def test_글_폭_기억은_같은_글(r):
|
||||
for case in r["cases"]:
|
||||
assert case["labelsSame"], case
|
||||
assert case["labelsRepeatSame"], case
|
||||
assert case["labelMeasuredCached"] == 0
|
||||
assert any(c["labelMeasuredPlain"] > 0 for c in r["cases"])
|
||||
|
||||
|
||||
def _read(rel: str) -> str:
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_D03_만_켬():
|
||||
layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
|
||||
assert "indexLayerLod(prepare(collection" in layers
|
||||
assert layers.count("base: true") == 5
|
||||
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
|
||||
assert "requestAnimationFrame(flush)" in plan_map
|
||||
assert "drawTop();" in plan_map
|
||||
users = [
|
||||
p.relative_to(ROOT).as_posix()
|
||||
for folder in ("B04_PreProcess", "B05_Profile", "B06_Section", "A00_Common")
|
||||
for p in (ROOT / folder).rglob("*.ts")
|
||||
if "indexLayerLod(" in p.read_text(encoding="utf-8")
|
||||
]
|
||||
assert users == ["B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare.ts"]
|
||||
Reference in New Issue
Block a user