/* 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, }), );