UNPKG

@jonobr1/force-directed-graph

Version:

GPU supercharged attraction-graph visualizations for the web built on top of Three.js

1,032 lines (898 loc) 25.9 kB
import { BufferAttribute, CanvasTexture, ClampToEdgeWrapping, Color, InstancedBufferAttribute, InstancedBufferGeometry, LinearFilter, LinearMipmapLinearFilter, Matrix4, Mesh, ShaderMaterial, UniformsLib, Vector3, Vector4, } from 'three'; import shader from './shaders/labels.js'; const MODEL_VIEW_MATRIX = new Matrix4(); const CAMERA_RIGHT = new Vector3(); const CAMERA_UP = new Vector3(); const LOCAL_NODE = new Vector3(); const WORLD_CENTER = new Vector3(); const WORLD_CORNER = new Vector3(); const PROJECTED_CORNER = new Vector3(); const MV_CENTER = new Vector4(); const BASE_ATLAS_FONT_SIZE = 120; const BASE_ATLAS_PADDING = 4; const ATLAS_RASTER_SCALE = 2; const DEFAULT_MAX_LABEL_CANVAS_SIZE = 4096; const DEFAULT_FONT_FAMILY = 'Arial, sans-serif'; const LABEL_GRAPH_DISTANCE_HOPS = 6; const LABEL_NODE_COLOR = new Color(); const LabelAlignmentMap = { center: 0, left: 1, right: -1, }; const LabelBaselineMap = { top: 1, middle: 0, bottom: -1, }; function getLabelAlignmentName(value) { if (value > 0.5) { return 'left'; } if (value < -0.5) { return 'right'; } return 'center'; } function getLabelBaselineName(value) { if (value > 0.5) { return 'top'; } if (value < -0.5) { return 'bottom'; } return 'middle'; } function sanitizeLabelFontSize(fontSize) { if (!Number.isFinite(fontSize)) { return 1; } return Math.max(0.01, fontSize); } function sanitizeLabelNearDistance(nearDistance) { if (!Number.isFinite(nearDistance)) { return 0; } return Math.max(0, nearDistance); } function sanitizePositiveInteger(value, fallback) { if (!Number.isFinite(value) || value <= 0) { return fallback; } return Math.max(1, Math.floor(value)); } function getLabelAtlasMaxTextureSize(options = {}) { const rendererMaxTextureSize = sanitizePositiveInteger( options.maxTextureSize, 16384, ); const canvasMaxTextureSize = sanitizePositiveInteger( options.maxCanvasTextureSize, DEFAULT_MAX_LABEL_CANVAS_SIZE, ); return Math.min(rendererMaxTextureSize, canvasMaxTextureSize); } function getNodeColorComponents(node) { if (node?.color) { LABEL_NODE_COLOR.set(node.color); return [LABEL_NODE_COLOR.r, LABEL_NODE_COLOR.g, LABEL_NODE_COLOR.b]; } return [1, 1, 1]; } function compareSelectionCandidates(a, b) { if (b.hasManualPriority !== a.hasManualPriority) { return Number(b.hasManualPriority) - Number(a.hasManualPriority); } if (b.manualPriority !== a.manualPriority) { return b.manualPriority - a.manualPriority; } if (b.degree !== a.degree) { return b.degree - a.degree; } return a.entry.stableId - b.entry.stableId; } function relaxGraphDistances(sourceIndex, adjacency, distances, maxHops) { if (!Number.isInteger(sourceIndex) || sourceIndex < 0) { return; } const visited = new Int16Array(adjacency.length || distances.length); visited.fill(-1); const queue = [sourceIndex]; const depths = [0]; visited[sourceIndex] = 0; distances[sourceIndex] = 0; for (let i = 0; i < queue.length; i++) { const nodeIndex = queue[i]; const depth = depths[i]; if (depth >= maxHops) { continue; } const neighbors = adjacency[nodeIndex] || []; for (let j = 0; j < neighbors.length; j++) { const neighbor = neighbors[j]; if (!Number.isInteger(neighbor) || neighbor < 0 || neighbor >= visited.length) { continue; } if (visited[neighbor] >= 0) { continue; } const nextDepth = depth + 1; visited[neighbor] = nextDepth; if (nextDepth < distances[neighbor]) { distances[neighbor] = nextDepth; } queue.push(neighbor); depths.push(nextDepth); } } } function buildLabelSelectionOrder( entries, adjacency = [], nodes = [], degrees = [], maxHops = LABEL_GRAPH_DISTANCE_HOPS, ) { if (!entries || entries.length === 0) { return []; } const candidates = entries .map((entry) => { const node = nodes[entry.nodeIndex] || {}; const manualPriority = typeof node.labelPriority === 'number' && Number.isFinite(node.labelPriority) ? node.labelPriority : -Infinity; return { entry, degree: typeof degrees[entry.nodeIndex] === 'number' && Number.isFinite(degrees[entry.nodeIndex]) ? degrees[entry.nodeIndex] : 0, hasManualPriority: Number.isFinite(manualPriority), manualPriority, selected: false, }; }) .sort(compareSelectionCandidates); const distances = new Int16Array( Math.max( 1, nodes.length, adjacency.length, ...entries.map((entry) => entry.nodeIndex + 1), ), ); distances.fill(maxHops + 1); const order = []; let hasSelection = false; for (let threshold = maxHops + 1; threshold >= 0; threshold--) { for (let i = 0; i < candidates.length; i++) { const candidate = candidates[i]; if (candidate.selected) { continue; } const distance = distances[candidate.entry.nodeIndex]; if (hasSelection && distance < threshold) { continue; } candidate.selected = true; order.push(candidate.entry); relaxGraphDistances( candidate.entry.nodeIndex, adjacency, distances, maxHops, ); hasSelection = true; } } return order; } function layoutAtlasRows(items, maxTextureSize) { if (!Number.isFinite(maxTextureSize) || maxTextureSize <= 0) { return { fits: false, width: 0, height: 0, placements: [] }; } let x = 0; let y = 0; let rowHeight = 0; let maxWidth = 0; const placements = []; for (let i = 0; i < items.length; i++) { const item = items[i]; const width = Math.max(1, Math.ceil(item.labelWidth)); const height = Math.max(1, Math.ceil(item.labelHeight)); if (width > maxTextureSize || height > maxTextureSize) { return { fits: false, width: 0, height: 0, placements: [] }; } if (x > 0 && x + width > maxTextureSize) { x = 0; y += rowHeight; rowHeight = 0; } placements.push({ x, y, width, height, }); x += width; rowHeight = Math.max(rowHeight, height); maxWidth = Math.max(maxWidth, x); if (y + rowHeight > maxTextureSize) { return { fits: false, width: 0, height: 0, placements: [] }; } } return { fits: true, width: Math.max(1, maxWidth), height: Math.max(1, y + rowHeight), placements, }; } function measureAtlasCandidate( tempCtx, rawItems, { requestedFontSize, requestedPadding, fontFamily, scale, maxTextureSize }, ) { const padding = Math.max(1, Math.round(requestedPadding * scale)); const fontSize = Math.max(1, Math.round(requestedFontSize * scale)); const tileH = fontSize + padding * 2; if (tileH > maxTextureSize) { return { fits: false }; } tempCtx.font = `${fontSize}px ${fontFamily}`; const items = rawItems.map((item) => { const labelWidth = Math.ceil(tempCtx.measureText(item.text).width) + padding * 2; return { ...item, labelWidth, labelHeight: tileH, aspectRatio: labelWidth / tileH, }; }); const layout = layoutAtlasRows(items, maxTextureSize); return { fits: layout.fits, padding, fontSize, tileH, items, layout, }; } function fitAtlasLayout( tempCtx, rawItems, { requestedFontSize, requestedPadding, fontFamily, maxTextureSize }, ) { let lo = 0; let hi = 1; let best = null; for (let i = 0; i < 12; i++) { const scale = (lo + hi) * 0.5; const candidate = measureAtlasCandidate(tempCtx, rawItems, { requestedFontSize, requestedPadding, fontFamily, scale, maxTextureSize, }); if (candidate.fits) { best = { ...candidate, scale, }; lo = scale; } else { hi = scale; } } if (best) { return best; } return measureAtlasCandidate(tempCtx, rawItems, { requestedFontSize, requestedPadding, fontFamily, scale: 0.01, maxTextureSize, }); } /** * Build a canvas-based texture atlas containing one text entry per labeled node. * Returns null when no nodes have a `label` property. * * @param {Array} nodes - Array of node data objects * @param {number[]} [degrees=[]] - Per-node degree values used for label priority * @param {Object} [options={}] - font options * @returns {{ canvas: HTMLCanvasElement, entries: Array } | null} */ function buildTextAtlas(nodes, degrees = [], options = {}) { const fontScale = sanitizeLabelFontSize(options.fontSize); const atlasScale = ATLAS_RASTER_SCALE; const requestedPadding = Math.max( 1, Math.round(BASE_ATLAS_PADDING * fontScale * atlasScale), ); const requestedFontSize = Math.max( 1, Math.round(BASE_ATLAS_FONT_SIZE * fontScale * atlasScale), ); const fontFamily = options.fontFamily || DEFAULT_FONT_FAMILY; const maxTextureSize = getLabelAtlasMaxTextureSize(options); const textColor = '#fff'; const temp = document.createElement('canvas'); const tempCtx = temp.getContext('2d'); tempCtx.font = `${requestedFontSize}px ${fontFamily}`; const rawItems = []; for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (node.label === null || node.label === undefined) { continue; } const text = String(node.label); rawItems.push({ text, nodeIndex: i, pointSize: typeof node.size === 'number' && Number.isFinite(node.size) ? node.size : 1, basePriority: getLabelBasePriority(node, degrees[i] || 0), }); } if (rawItems.length === 0) { return null; } const fittedAtlas = fitAtlasLayout(tempCtx, rawItems, { requestedFontSize, requestedPadding, fontFamily, maxTextureSize, }); if (!fittedAtlas.fits) { return null; } const canvas = document.createElement('canvas'); canvas.width = fittedAtlas.layout.width; canvas.height = fittedAtlas.layout.height; const ctx = canvas.getContext('2d'); ctx.imageSmoothingEnabled = true; ctx.font = `${fittedAtlas.fontSize}px ${fontFamily}`; ctx.fillStyle = textColor; ctx.textBaseline = 'middle'; ctx.textAlign = 'center'; const entries = []; for (let i = 0; i < fittedAtlas.items.length; i++) { const item = fittedAtlas.items[i]; const placement = fittedAtlas.layout.placements[i]; const px = placement.x; const py = placement.y; ctx.fillText( item.text, px + placement.width / 2, py + placement.height / 2, ); entries.push({ ...item, labelId: i, stableId: item.nodeIndex, persistence: 0, atlasUV: { u: px / canvas.width, v: 1.0 - (py + placement.height) / canvas.height, uw: placement.width / canvas.width, uh: placement.height / canvas.height, }, }); } return { canvas, entries }; } function clamp01(value) { return Math.max(0, Math.min(1, value)); } function getVisibleQuota(obscurity, labelCount) { const quota = Math.round((1 - clamp01(obscurity)) * labelCount); return Math.max(0, Math.min(labelCount, quota)); } function getLabelBasePriority(node, degree = 0) { if ( typeof node.labelPriority === 'number' && Number.isFinite(node.labelPriority) ) { return node.labelPriority; } if (typeof node.size === 'number' && Number.isFinite(node.size)) { return node.size; } if (Number.isFinite(degree)) { return degree; } return 0; } function compareLabelEntries(a, b) { if (b.basePriority !== a.basePriority) { return b.basePriority - a.basePriority; } return a.stableId - b.stableId; } function compareProjectedEntries(a, b) { if (b.entry.basePriority !== a.entry.basePriority) { return b.entry.basePriority - a.entry.basePriority; } const aPersistence = a.entry.persistence || 0; const bPersistence = b.entry.persistence || 0; if (bPersistence !== aPersistence) { return bPersistence - aPersistence; } if (b.depthPriority !== a.depthPriority) { return b.depthPriority - a.depthPriority; } return a.entry.stableId - b.entry.stableId; } function buildSelectionRanks(entries, selectionOrder) { const ranksByLabelId = new Float32Array(entries.length); const orderedEntries = selectionOrder && selectionOrder.length > 0 ? selectionOrder : entries.slice().sort(compareLabelEntries); for (let i = 0; i < orderedEntries.length; i++) { ranksByLabelId[orderedEntries[i].labelId] = i; } return ranksByLabelId; } function packCollisionCellKey(cellX, cellY, gridWidth) { return cellY * gridWidth + cellX; } function buildSortTuple(cellId, entry) { return { cellId, priorityKey: -entry.basePriority, stableId: entry.stableId, labelId: entry.labelId, }; } function getPlacementTextureDimensions(itemCount) { const width = Math.max(1, Math.ceil(Math.sqrt(itemCount))); const height = Math.max(1, Math.ceil(itemCount / width)); return { width, height }; } function getLabelAlignmentOffset(alignment) { if (alignment > 0.5) { return 1; } if (alignment < -0.5) { return -1; } return 0; } function getLabelBaselineOffset(baseline) { if (baseline > 0.5) { return 1; } if (baseline < -0.5) { return -1; } return 0; } function intersectsBounds(a, b, margin = 0) { return !( a.maxX + margin <= b.minX || a.minX >= b.maxX + margin || a.maxY + margin <= b.minY || a.minY >= b.maxY + margin ); } function getCollisionCellBounds(bounds, cellSize, gridWidth, gridHeight) { const minCellX = Math.max(0, Math.floor(bounds.minX / cellSize)); const maxCellX = Math.min(gridWidth - 1, Math.floor(bounds.maxX / cellSize)); const minCellY = Math.max(0, Math.floor(bounds.minY / cellSize)); const maxCellY = Math.min(gridHeight - 1, Math.floor(bounds.maxY / cellSize)); if ( maxCellX < 0 || maxCellY < 0 || minCellX >= gridWidth || minCellY >= gridHeight ) { return null; } return { minCellX, maxCellX, minCellY, maxCellY, }; } function projectLabelBounds({ nodePosition, objectMatrixWorld, camera, viewportWidth, viewportHeight, frustumSize, is2D, sizeAttenuation, nodeRadius, nodeScale, aspectRatio, labelAlignment = 0, labelBaseline = 1, labelFontSize = 1, labelNear = 0, labelOffset = { x: 0, y: 0 }, pointSize = 1, }) { LOCAL_NODE.copy(nodePosition); LOCAL_NODE.z *= 1.0 - Number(Boolean(is2D)); MODEL_VIEW_MATRIX.multiplyMatrices( camera.matrixWorldInverse, objectMatrixWorld, ); MV_CENTER.set(LOCAL_NODE.x, LOCAL_NODE.y, LOCAL_NODE.z, 1.0); MV_CENTER.applyMatrix4(MODEL_VIEW_MATRIX); if (!Number.isFinite(MV_CENTER.z) || MV_CENTER.z >= 0) { return null; } const viewDistance = -MV_CENTER.z; const nearDistance = sanitizeLabelNearDistance(labelNear); if (viewDistance <= nearDistance) { return null; } const sizeScale = sizeAttenuation ? frustumSize / Math.max(viewDistance, 0.001) : 1.0; const labelPixelHeight = 0.1 * nodeRadius * pointSize * nodeScale * sizeScale * sanitizeLabelFontSize(labelFontSize); const projectionScaleY = Math.max( Math.abs(camera.projectionMatrix.elements[5]), 0.0001, ); const depthScale = camera.isPerspectiveCamera ? viewDistance : 1.0; const worldUnitsPerPixel = (2.0 * depthScale) / Math.max(projectionScaleY * Math.max(viewportHeight, 1), 0.001); const labelHeight = labelPixelHeight * worldUnitsPerPixel; const labelWidth = labelHeight * aspectRatio; const offsetX = (labelOffset?.x || 0) * labelHeight; const offsetY = (labelOffset?.y || 0) * labelHeight; WORLD_CENTER.copy(LOCAL_NODE).applyMatrix4(objectMatrixWorld); CAMERA_RIGHT.setFromMatrixColumn(camera.matrixWorld, 0).normalize(); CAMERA_UP.setFromMatrixColumn(camera.matrixWorld, 1).normalize(); const anchor = WORLD_CORNER.copy(WORLD_CENTER) .addScaledVector( CAMERA_RIGHT, labelWidth * 0.5 * getLabelAlignmentOffset(labelAlignment) + offsetX, ) .addScaledVector( CAMERA_UP, labelHeight * getLabelBaselineOffset(labelBaseline) + offsetY, ); let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity; for (let ix = -1; ix <= 1; ix += 2) { for (let iy = -1; iy <= 1; iy += 2) { PROJECTED_CORNER.copy(anchor) .addScaledVector(CAMERA_RIGHT, ix * labelWidth * 0.5) .addScaledVector(CAMERA_UP, iy * labelHeight * 0.5) .project(camera); if ( !Number.isFinite(PROJECTED_CORNER.x) || !Number.isFinite(PROJECTED_CORNER.y) ) { return null; } const x = (PROJECTED_CORNER.x * 0.5 + 0.5) * viewportWidth; const y = (1.0 - (PROJECTED_CORNER.y * 0.5 + 0.5)) * viewportHeight; minX = Math.min(minX, x); minY = Math.min(minY, y); maxX = Math.max(maxX, x); maxY = Math.max(maxY, y); } } if ( maxX <= 0 || maxY <= 0 || minX >= viewportWidth || minY >= viewportHeight ) { return null; } return { minX, minY, maxX, maxY, width: maxX - minX, height: maxY - minY, centerX: (minX + maxX) * 0.5, centerY: (minY + maxY) * 0.5, viewDistance, depthPriority: 1.0 / Math.max(viewDistance, 0.001), clipped: minX < 0 || minY < 0 || maxX > viewportWidth || maxY > viewportHeight, }; } function configureAtlasTexture(texture, options = {}) { const useMipmaps = Boolean(options.useMipmaps); texture.minFilter = useMipmaps ? LinearMipmapLinearFilter : LinearFilter; texture.magFilter = LinearFilter; texture.wrapS = ClampToEdgeWrapping; texture.wrapT = ClampToEdgeWrapping; texture.generateMipmaps = useMipmaps; texture.needsUpdate = true; return texture; } class Labels extends Mesh { constructor({ geometry, texture, entries, fontFamily }, uniforms) { const material = new ShaderMaterial({ uniforms: { ...UniformsLib.fog, ...{ texturePositions: { value: null }, textureAtlas: { value: texture }, opacity: uniforms.opacity, obscurity: uniforms.obscurity, frustumSize: uniforms.frustumSize, inheritColors: uniforms.labelsInheritColor, is2D: uniforms.is2D, sizeAttenuation: uniforms.sizeAttenuation, resolution: uniforms.resolution, nodeRadius: uniforms.nodeRadius, nodeScale: uniforms.nodeScale, uLabelCount: { value: entries.length }, uColor: uniforms.labelColor, labelAlignment: uniforms.labelAlignment, labelBaseline: uniforms.labelBaseline, labelFontSize: uniforms.labelFontSize, labelNear: uniforms.labelNear, labelOffset: uniforms.labelOffset, uBeginning: uniforms.uBeginning, uEnding: uniforms.uEnding, uNodeAmount: uniforms.uNodeAmount, }, }, vertexShader: shader.vertexShader, fragmentShader: shader.fragmentShader, transparent: true, vertexColors: true, depthWrite: false, depthTest: false, fog: true, }); super(geometry, material); this.frustumCulled = false; this.entries = entries; this.userData.fontFamily = fontFamily || DEFAULT_FONT_FAMILY; this.userData.fontSize = uniforms.labelFontSize.value; this.userData.near = sanitizeLabelNearDistance(uniforms.labelNear.value); } dispose() { this.material.uniforms.textureAtlas.value?.dispose?.(); this.material.dispose(); this.geometry.dispose(); } replaceData({ geometry, texture, entries, fontFamily, fontSize, }) { this.geometry.dispose(); this.material.uniforms.textureAtlas.value?.dispose?.(); this.geometry = geometry; this.entries = entries; this.material.uniforms.textureAtlas.value = texture; this.material.uniforms.uLabelCount.value = entries.length; this.userData.fontFamily = fontFamily || DEFAULT_FONT_FAMILY; this.userData.fontSize = sanitizeLabelFontSize(fontSize); this.userData.near = sanitizeLabelNearDistance( this.material.uniforms.labelNear.value, ); } get fontSize() { if (this.parent?.userData?.uniforms?.labelFontSize) { return this.parent.userData.uniforms.labelFontSize.value; } return this.userData.fontSize; } set fontSize(v) { const nextValue = sanitizeLabelFontSize(v); this.userData.fontSize = nextValue; if (!this.material?.uniforms?.labelFontSize) { return; } if (this.material.uniforms.labelFontSize.value === nextValue) { return; } this.material.uniforms.labelFontSize.value = nextValue; } get fontFamily() { if (this.parent?.userData?.labelFontFamily) { return this.parent.userData.labelFontFamily; } return this.userData.fontFamily; } set fontFamily(v) { const nextValue = typeof v === 'string' && v.trim().length > 0 ? v.trim() : DEFAULT_FONT_FAMILY; this.userData.fontFamily = nextValue; if (!this.parent?.userData) { return; } if (this.parent.userData.labelFontFamily === nextValue) { return; } this.parent.userData.labelFontFamily = nextValue; this.parent.refreshLabels(); } get alignment() { return getLabelAlignmentName(this.material.uniforms.labelAlignment.value); } set alignment(v) { this.material.uniforms.labelAlignment.value = LabelAlignmentMap[v] ?? LabelAlignmentMap.center; } get baseline() { return getLabelBaselineName(this.material.uniforms.labelBaseline.value); } set baseline(v) { this.material.uniforms.labelBaseline.value = LabelBaselineMap[v] ?? LabelBaselineMap.top; } get offset() { return this.material.uniforms.labelOffset.value; } set offset(v) { if (!v || !Number.isFinite(v.x) || !Number.isFinite(v.y)) { return; } this.material.uniforms.labelOffset.value.set(v.x, v.y); } get near() { if (this.parent?.userData?.uniforms?.labelNear) { return this.parent.userData.uniforms.labelNear.value; } return this.userData.near; } set near(v) { const nextValue = sanitizeLabelNearDistance(v); this.userData.near = nextValue; if (!this.material?.uniforms?.labelNear) { return; } if (this.material.uniforms.labelNear.value === nextValue) { return; } this.material.uniforms.labelNear.value = nextValue; } static parse(size, data, options = {}) { const atlas = buildTextAtlas(data.nodes, options.degrees || [], options); if (!atlas) { return Promise.resolve(null); } const { canvas, entries } = atlas; const quadVerts = new Float32Array([ -1, -1, 0, 1, -1, 0, -1, 1, 0, 1, 1, 0, ]); const quadUVs = new Float32Array([0, 0, 1, 0, 0, 1, 1, 1]); const quadIdx = [0, 1, 2, 2, 1, 3]; const geometry = new InstancedBufferGeometry(); geometry.setAttribute('position', new BufferAttribute(quadVerts, 3)); geometry.setAttribute('uv', new BufferAttribute(quadUVs, 2)); geometry.setIndex(quadIdx); const sources = []; const colors = []; const labelUVs = []; const aspectRatios = []; const pointSizes = []; const selectionOrder = buildLabelSelectionOrder( entries, options.adjacency || [], data.nodes, options.degrees || [], ); const selectionRanksByLabelId = buildSelectionRanks(entries, selectionOrder); const selectionRanks = []; for (let i = 0; i < entries.length; i++) { const entry = entries[i]; const x = (entry.nodeIndex % size) / size; const y = Math.floor(entry.nodeIndex / size) / size; const z = entry.nodeIndex + 1; sources.push(x, y, z); colors.push(...getNodeColorComponents(data.nodes[entry.nodeIndex])); labelUVs.push( entry.atlasUV.u, entry.atlasUV.v, entry.atlasUV.uw, entry.atlasUV.uh, ); aspectRatios.push(entry.aspectRatio); pointSizes.push(entry.pointSize); selectionRanks.push(selectionRanksByLabelId[entry.labelId]); } geometry.setAttribute( 'source', new InstancedBufferAttribute(new Float32Array(sources), 3), ); geometry.setAttribute( 'color', new InstancedBufferAttribute(new Float32Array(colors), 3), ); geometry.setAttribute( 'labelUV', new InstancedBufferAttribute(new Float32Array(labelUVs), 4), ); geometry.setAttribute( 'aspectRatio', new InstancedBufferAttribute(new Float32Array(aspectRatios), 1), ); geometry.setAttribute( 'pointSize', new InstancedBufferAttribute(new Float32Array(pointSizes), 1), ); geometry.setAttribute( 'selectionRank', new InstancedBufferAttribute(new Float32Array(selectionRanks), 1), ); geometry.instanceCount = entries.length; const texture = configureAtlasTexture(new CanvasTexture(canvas), options); return Promise.resolve({ geometry, texture, entries, fontFamily: options.fontFamily || DEFAULT_FONT_FAMILY, fontSize: sanitizeLabelFontSize(options.fontSize), }); } } const __TEST__ = { buildLabelSelectionOrder, buildSelectionRanks, buildSortTuple, clamp01, compareLabelEntries, compareProjectedEntries, getCollisionCellBounds, getLabelBasePriority, getLabelAlignmentOffset, getLabelBaselineOffset, getVisibleQuota, getPlacementTextureDimensions, getNodeColorComponents, getLabelAtlasMaxTextureSize, sanitizeLabelFontSize, sanitizeLabelNearDistance, intersectsBounds, packCollisionCellKey, projectLabelBounds, configureAtlasTexture, }; export { Labels, __TEST__ };