From 98383a34e06d9252760ce2c2f477d1fc394684de Mon Sep 17 00:00:00 2001 From: xiaoiver Date: Wed, 14 Aug 2019 19:52:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E5=AF=B9=E4=BA=8E?= =?UTF-8?q?=E7=82=B9=E8=A6=81=E7=B4=A0=E7=9A=84=E8=87=AA=E5=8A=A8=E6=A0=87?= =?UTF-8?q?=E6=B3=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- demos/06_text.html | 37 ++- src/core/scene.js | 29 ++- src/geom/buffer/point/text.js | 259 ++++++++++++--------- src/geom/buffer/point/text/font-manager.js | 17 +- src/geom/material/textMaterial.js | 23 +- src/geom/shader/text_frag.glsl | 57 +++-- src/geom/shader/text_vert.glsl | 53 +++-- src/layer/index.js | 2 + src/layer/render/index.js | 6 + src/layer/render/text/drawText.js | 104 +++++++++ src/layer/textLayer.js | 28 +++ src/map/AMap.js | 4 +- src/util/collision-index.js | 81 +++++++ src/util/font-util.js | 24 +- src/util/grid-index.js | 131 +++++++++++ src/util/symbol-layout.js | 234 +++++++++++++++++++ 16 files changed, 876 insertions(+), 213 deletions(-) create mode 100644 src/layer/render/text/drawText.js create mode 100644 src/layer/textLayer.js create mode 100644 src/util/collision-index.js create mode 100644 src/util/grid-index.js create mode 100644 src/util/symbol-layout.js diff --git a/demos/06_text.html b/demos/06_text.html index 3fd2bc4f61..074a8f1d4a 100644 --- a/demos/06_text.html +++ b/demos/06_text.html @@ -6,7 +6,7 @@ - point_circle + text layer @@ -29,21 +29,42 @@ const scene = new L7.Scene({ }); window.scene = scene; scene.on('loaded', () => { - $.get('./data/provincePoint.json', data => { + $.get('https://d2ad6b4ur7yvpq.cloudfront.net/naturalearth-3.3.0/ne_10m_geography_regions_points.geojson', data => { scene.PointLayer({ - zIndex: 2 + zIndex: 3 + }) + .source(data) + .shape('circle') + .active(true) + .size(4) + .color('#fff') + .style({ + stroke: '#999', + strokeWidth: 1, + opacity: 1.0 + }) + .render(); + + scene.TextLayer({ + zIndex: 4 }) .source(data) .shape('name', 'text') .active(true) - .size(12) // default 1 - .color('name') + .size('scalerank', [ 10, 20, 24 ]) + .color('scalerank', [ 'red', 'blue', 'black' ]) .style({ - stroke: '#999', - strokeWidth: 0, + // fontFamily: 'Monaco, monospace', // 字体 + fontWeight: 400, + textAnchor: 'center', // 文本相对锚点的位置 center|left|right|top|bottom|top-left + textOffset: [ 0, 0 ], // 文本相对锚点的偏移量 [水平, 垂直] + spacing: 2, // 字符间距 + padding: [ 4, 4 ], // 文本包围盒 padding [水平,垂直],影响碰撞检测结果,避免相邻文本靠的太近 + strokeColor: 'white', // 描边颜色 + strokeWidth: 2, // 描边宽度 opacity: 1.0 }) - .render(); + .render(); }); }); diff --git a/src/core/scene.js b/src/core/scene.js index 6786dde1db..4db7a7c7dc 100644 --- a/src/core/scene.js +++ b/src/core/scene.js @@ -21,6 +21,7 @@ export default class Scene extends Base { this.fontAtlasManager = new FontAtlasManager(); this._layers = []; this.animateCount = 0; + this.inited = false; } _initEngine(mapContainer) { @@ -44,16 +45,24 @@ export default class Scene extends Base { this.map = Map.map; this._initEngine(Map.renderDom); Map.asyncCamera(this._engine); - this.initLayer(); - this._registEvents(); - const hash = this.get('hash'); - if (hash) { - const Ctor = getInteraction('hash'); - const interaction = new Ctor({ layer: this }); - interaction._onHashChange(); - } - this.emit('loaded'); - this._engine.update(); + + // 等待相机同步之后再进行首次渲染 + Map.on('cameraloaded', () => { + if (!this.inited) { + this.initLayer(); + this._registEvents(); + const hash = this.get('hash'); + if (hash) { + const Ctor = getInteraction('hash'); + const interaction = new Ctor({ layer: this }); + interaction._onHashChange(); + } + this.emit('loaded'); + this.inited = true; + } + this._engine.update(); + this.emit('cameraloaded'); + }); }); } initLayer() { diff --git a/src/geom/buffer/point/text.js b/src/geom/buffer/point/text.js index 9b2f5f50b8..95614ea296 100644 --- a/src/geom/buffer/point/text.js +++ b/src/geom/buffer/point/text.js @@ -1,130 +1,165 @@ -export default function TextBuffer(layerData, fontAtlasManager) { +/** + * 为文本构建顶点数据,仅支持点要素自动标注。 + * @see https://zhuanlan.zhihu.com/p/72222549 + * @see https://zhuanlan.zhihu.com/p/74373214 + */ +import { shapeText, getGlyphQuads } from '../../../util/symbol-layout'; + +export default function TextBuffer( + layerData, + sourceData, + options, + fontAtlasManager, + collisionIndex, + mvpMatrix +) { + const { + textField, + fontWeight, + fontFamily + } = options; const characterSet = []; - layerData.forEach(element => { - let text = element.shape || ''; + sourceData.forEach(element => { + // shape 存储了 text-field + let text = element[textField] || ''; text = text.toString(); for (let j = 0; j < text.length; j++) { + // 去重 if (characterSet.indexOf(text[j]) === -1) { characterSet.push(text[j]); } } }); fontAtlasManager.setProps({ - characterSet + characterSet, + fontFamily, + fontWeight }); - const attr = drawGlyph(layerData, fontAtlasManager); - return attr; + return drawGlyph(layerData, sourceData, options, fontAtlasManager, collisionIndex, mvpMatrix); } -function drawGlyph(layerData, fontAtlasManager) { + +function drawGlyph( + layerData, sourceData, + { + textField, + spacing = 2, + textAnchor = 'center', + textOffset = [ 0, 0 ], + padding = [ 4, 4 ] + }, + fontAtlasManager, + collisionIndex, + mvpMatrix +) { + const { texture, fontAtlas, mapping } = fontAtlasManager; + const attributes = { - originPoints: [], - textSizes: [], - textOffsets: [], + fontAtlas, + texture, + positions: [], colors: [], - textureElements: [], - pickingIds: [] + pickingIds: [], + textUVs: [], + textOffsets: [], + textSizes: [], + index: [] }; - const { texture, fontAtlas, mapping, scale } = fontAtlasManager; - layerData.forEach(function(element) { - const size = element.size; - const pos = element.coordinates; - let text = element.shape || ''; - text = text.toString(); - const pen = { - x: (-text.length * size) / 2, - y: 0 - }; - for (let i = 0; i < text.length; i++) { - const metric = mapping[text[i]]; - const { x, y, width, height } = metric; - const color = element.color; - const offsetX = pen.x; - const offsetY = pen.y; - attributes.pickingIds.push( - element.id, - element.id, - element.id, - element.id, - element.id, - element.id - ); - attributes.textOffsets.push( - // 文字在词语的偏移量 - offsetX, - offsetY, - offsetX, - offsetY, - offsetX, - offsetY, - offsetX, - offsetY, - offsetX, - offsetY, - offsetX, - offsetY - ); - attributes.originPoints.push( - // 词语的经纬度坐标 - pos[0], - pos[1], - 0, - pos[0], - pos[1], - 0, - pos[0], - pos[1], - 0, - pos[0], - pos[1], - 0, - pos[0], - pos[1], - 0, - pos[0], - pos[1], - 0 - ); - attributes.textSizes.push( - size, - size * scale, - 0, - size * scale, - 0, - 0, - size, - size * scale, - 0, - 0, - size, - 0 - ); - attributes.colors.push( - ...color, - ...color, - ...color, - ...color, - ...color, - ...color - ); - attributes.textureElements.push( - // 文字纹理坐标 - x + width, - y, - x, - y, - x, - y + height, - x + width, - y, - x, - y + height, - x + width, - y + height - ); - pen.x = pen.x + size; + let indexCounter = 0; + layerData.forEach((feature, i) => { + const { size, coordinates } = feature; + // 根据字段获取文本 + const text = `${sourceData[i][textField] || ''}`; + // sdf 中默认字号为 24 + const fontScale = size / 24; + + // 1. 计算每个字符相对锚点的位置 + const shaping = shapeText(text, mapping, 24, textAnchor, 'center', spacing, textOffset); + + if (shaping) { + // 2. 尝试加入空间索引,获取碰撞检测结果 + // TODO:按照 feature 中指定字段排序,确定插入权重,保证优先级高的文本优先展示 + const { box } = collisionIndex.placeCollisionBox({ + x1: shaping.left * fontScale - padding[0], + x2: shaping.right * fontScale + padding[0], + y1: shaping.top * fontScale - padding[1], + y2: shaping.bottom * fontScale + padding[1], + // 点要素锚点就是当前点位置 + anchorPointX: coordinates[0], + anchorPointY: coordinates[1] + }, mvpMatrix); + + // 无碰撞则加入空间索引 + if (box && box.length) { + // TODO:featureIndex + collisionIndex.insertCollisionBox(box, 0); + + // 3. 计算可供渲染的文本块,其中每个字符都包含纹理坐标 + const glyphQuads = getGlyphQuads(shaping, textOffset, false); + + // 4. 构建顶点数据,四个顶点组成一个 quad + indexCounter = addAttributeForFeature(feature, attributes, glyphQuads, indexCounter); + } } }); - attributes.texture = texture; - attributes.fontAtlas = fontAtlas; return attributes; } + +function addAttributeForFeature(feature, attributes, glyphQuads, indexCounter) { + const { id, size, color, coordinates } = feature; + glyphQuads.forEach(quad => { + + attributes.pickingIds.push( + id, + id, + id, + id + ); + + attributes.colors.push( + ...color, + ...color, + ...color, + ...color + ); + + attributes.positions.push( + coordinates[0], coordinates[1], + coordinates[0], coordinates[1], + coordinates[0], coordinates[1], + coordinates[0], coordinates[1] + ); + + attributes.textUVs.push( + quad.tex.x, quad.tex.y + quad.tex.height, + quad.tex.x + quad.tex.width, quad.tex.y + quad.tex.height, + quad.tex.x + quad.tex.width, quad.tex.y, + quad.tex.x, quad.tex.y, + ); + + attributes.textOffsets.push( + quad.tl.x, quad.tl.y, + quad.tr.x, quad.tr.y, + quad.br.x, quad.br.y, + quad.bl.x, quad.bl.y + ); + + attributes.textSizes.push( + size, + size, + size, + size + ); + + attributes.index.push( + 0 + indexCounter, + 1 + indexCounter, + 2 + indexCounter, + 2 + indexCounter, + 3 + indexCounter, + 0 + indexCounter + ); + indexCounter += 4; + }); + + return indexCounter; +} diff --git a/src/geom/buffer/point/text/font-manager.js b/src/geom/buffer/point/text/font-manager.js index 0fdb8bb53f..3820fa14b3 100644 --- a/src/geom/buffer/point/text/font-manager.js +++ b/src/geom/buffer/point/text/font-manager.js @@ -10,8 +10,8 @@ export const DEFAULT_BUFFER = 3; export const DEFAULT_CUTOFF = 0.25; export const DEFAULT_RADIUS = 8; const MAX_CANVAS_WIDTH = 1024; -const BASELINE_SCALE = 0.9; -const HEIGHT_SCALE = 1.2; +const BASELINE_SCALE = 1.0; +const HEIGHT_SCALE = 1.0; const CACHE_LIMIT = 3; const cache = new LRUCache(CACHE_LIMIT); @@ -36,9 +36,9 @@ function getDefaultCharacterSet() { function setTextStyle(ctx, fontFamily, fontSize, fontWeight) { ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; - ctx.fillStyle = '#000'; - ctx.textBaseline = 'baseline'; - ctx.textAlign = 'left'; + ctx.fillStyle = 'black'; + ctx.textBaseline = 'middle'; + // ctx.textAlign = 'left'; } function getNewChars(key, characterSet) { const cachedFontAtlas = cache.get(key); @@ -146,10 +146,10 @@ export default class FontAtlasManager { _updateTexture({ data: canvas }) { this._texture = new THREE.CanvasTexture(canvas); - this._texture.wrapS = THREE.ClampToEdgeWrapping; - this._texture.wrapT = THREE.ClampToEdgeWrapping; this._texture.minFilter = THREE.LinearFilter; + this._texture.magFilter = THREE.LinearFilter; this._texture.flipY = false; + this._texture.format = THREE.AlphaFormat; this._texture.needUpdate = true; } @@ -200,7 +200,8 @@ export default class FontAtlasManager { for (const char of characterSet) { populateAlphaChannel(tinySDF.draw(char), imageData); - ctx.putImageData(imageData, mapping[char].x - buffer, mapping[char].y - buffer); + // 考虑到描边,需要保留 sdf 的 buffer,不能像 deck.gl 一样直接减去 + ctx.putImageData(imageData, mapping[char].x, mapping[char].y); } } else { for (const char of characterSet) { diff --git a/src/geom/material/textMaterial.js b/src/geom/material/textMaterial.js index 7e9d52ca1b..267ee808bd 100644 --- a/src/geom/material/textMaterial.js +++ b/src/geom/material/textMaterial.js @@ -1,23 +1,14 @@ import Material from './material'; -import { getModule } from '../../util/shaderModule'; +import { getModule, wrapUniforms } from '../../util/shaderModule'; +import merge from '@antv/util/lib/deep-mix'; -export default function TextMaterial(options) { - const { vs, fs } = getModule('text'); +export default function TextMaterial(_uniforms) { + const { vs, fs, uniforms } = getModule('text'); const material = new Material({ - uniforms: { - u_opacity: { value: options.u_opacity || 1.0 }, - u_texture: { value: options.u_texture }, - u_strokeWidth: { value: options.u_strokeWidth }, - u_stroke: { value: options.u_stroke }, - u_textTextureSize: { value: options.u_textTextureSize }, - u_scale: { value: options.u_scale }, - u_gamma: { value: options.u_gamma }, - u_buffer: { value: options.u_buffer }, - u_glSize: { value: options.u_glSize }, - u_activeId: { value: options.u_activeId || 0 }, - u_activeColor: { value: options.u_activeColor } - + defines: { + DEVICE_PIXEL_RATIO: window.devicePixelRatio }, + uniforms: wrapUniforms(merge(uniforms, _uniforms)), vertexShader: vs, fragmentShader: fs, transparent: true diff --git a/src/geom/shader/text_frag.glsl b/src/geom/shader/text_frag.glsl index 7fe59c4257..23d86a5b3b 100644 --- a/src/geom/shader/text_frag.glsl +++ b/src/geom/shader/text_frag.glsl @@ -1,31 +1,30 @@ -precision mediump float; -uniform sampler2D u_texture; -varying vec4 v_color; -uniform vec4 u_stroke; -uniform float u_strokeWidth; -uniform float u_buffer; -uniform float u_gamma; -uniform float u_opacity; -varying vec2 v_texcoord; -varying float v_size; -void main(){ - float dist=texture2D(u_texture,vec2(v_texcoord.x,v_texcoord.y)).a; - float alpha; +#define SDF_PX 8.0 +#define EDGE_GAMMA 0.105 / float(DEVICE_PIXEL_RATIO) - if(u_strokeWidth==0.){ - alpha=smoothstep(u_buffer-u_gamma,u_buffer+u_gamma,dist); - gl_FragColor=vec4(v_color.rgb,alpha*v_color.a); - }else{ - if(dist<=u_buffer-u_gamma){ - alpha=smoothstep(u_strokeWidth-u_gamma,u_strokeWidth+u_gamma,dist); - gl_FragColor=vec4(u_stroke.rgb,alpha*u_stroke.a); - }else if(dist e / 255), + u_halo_width: strokeWidth, + u_halo_blur: 0.5, + u_font_opacity: opacity, + u_sdf_map_size: [ fontAtlas.width, fontAtlas.height ], + u_viewport_size: [ width, height ], + u_activeColor: activeOption.fill + }); + const mesh = new THREE.Mesh(geometry, material); + + // 更新 viewport + window.addEventListener('resize', () => { + const { width, height } = layer.scene.getSize(); + material.uniforms.u_viewport_size.value = [ width, height ]; + material.uniforms.needsUpdate = true; + }, false); + + // 关闭视锥裁剪 + mesh.frustumCulled = false; + return mesh; +} + diff --git a/src/layer/textLayer.js b/src/layer/textLayer.js new file mode 100644 index 0000000000..1f07e7a0cd --- /dev/null +++ b/src/layer/textLayer.js @@ -0,0 +1,28 @@ +import Layer from '../core/layer'; +import { getRender } from './render/'; +import CollisionIndex from '../util/collision-index'; +export default class TextLayer extends Layer { + shape(textField, shape = 'text') { + this.textField = textField; + this.shape = shape; + + // 创建碰撞检测索引 + const { width, height } = this.scene.getSize(); + this.collisionIndex = new CollisionIndex(width, height); + + // 相机变化,需要重新构建索引,由于文本可见性的改变,也需要重新组装顶点数据 + this.scene.on('cameraloaded', () => { + this.collisionIndex = new CollisionIndex(width, height); + + this.layerMesh.geometry = getRender(this.type, this.shape)(this.layerData, this, true); + this.layerMesh.geometry.needsUpdate = true; + }); + + return this; + } + draw() { + this.init(); + this.type = 'text'; + this.add(getRender(this.type, this.shape)(this.layerData, this)); + } +} diff --git a/src/map/AMap.js b/src/map/AMap.js index a6a101e7ce..b8dcfaf708 100644 --- a/src/map/AMap.js +++ b/src/map/AMap.js @@ -82,7 +82,9 @@ export default class GaodeMap extends Base { camera.lookAt(0, 0, 0); camera.position.x += e.camera.position.x; camera.position.y += -e.camera.position.y; - this._engine.update(); + + // 相机同步成功,通知 scene 开始渲染 + this.emit('cameraloaded'); }); } diff --git a/src/util/collision-index.js b/src/util/collision-index.js new file mode 100644 index 0000000000..1ef7165d7d --- /dev/null +++ b/src/util/collision-index.js @@ -0,0 +1,81 @@ +// import GridIndex from 'grid-index'; +// @mapbox/grid-index 并没有类似 hitTest 的单纯获取碰撞检测结果的方法,query 将导致计算大量多余的包围盒结果,因此使用改良版 +import GridIndex from '../util/grid-index'; +import { Vector4 } from '../core/three'; + +// 为 viewport 加上 buffer,避免边缘处的文本无法显示 +const viewportPadding = 100; + +/** + * 基于网格实现文本避让,大幅提升包围盒碰撞检测效率 + * @see https://zhuanlan.zhihu.com/p/74373214 + */ +export default class CollisionIndex { + constructor(width, height) { + this.width = width; + this.height = height; + // 创建网格索引 + this.grid = new GridIndex(width + 2 * viewportPadding, height + 2 * viewportPadding, 25); + + this.screenRightBoundary = width + viewportPadding; + this.screenBottomBoundary = height + viewportPadding; + this.gridRightBoundary = width + 2 * viewportPadding; + this.gridBottomBoundary = height + 2 * viewportPadding; + } + + placeCollisionBox(collisionBox, mvpMatrix) { + const projectedPoint = this.project(mvpMatrix, collisionBox.anchorPointX, collisionBox.anchorPointY); + + const tlX = collisionBox.x1 + projectedPoint.x; + const tlY = collisionBox.y1 + projectedPoint.y; + const brX = collisionBox.x2 + projectedPoint.x; + const brY = collisionBox.y2 + projectedPoint.y; + + if (!this.isInsideGrid(tlX, tlY, brX, brY) || + this.grid.hitTest(tlX, tlY, brX, brY) + ) { + return { + box: [] + }; + } + + return { + box: [ tlX, tlY, brX, brY ] + }; + } + + insertCollisionBox(collisionBox, featureIndex) { + const key = { featureIndex }; + this.grid.insert(key, collisionBox[0], collisionBox[1], collisionBox[2], collisionBox[3]); + } + + /** + * 后续碰撞检测都需要投影到 viewport 坐标系 + * @param {THREE.Matrix4} mvpMatrix mvp矩阵 + * @param {number} x P20 平面坐标X + * @param {number} y P20 平面坐标Y + * @return {Point} projectedPoint + */ + project(mvpMatrix, x, y) { + const p = new Vector4(x, y, 0, 1) + .applyMatrix4(mvpMatrix); + + // GL 坐标系[-1, 1] -> viewport 坐标系[width, height] + return { + x: (((p.x / p.w + 1) / 2) * this.width) + viewportPadding, + y: (((-p.y / p.w + 1) / 2) * this.height) + viewportPadding + }; + } + + /** + * 判断包围盒是否在整个网格内,需要加上 buffer + * @param {number} x1 x1 + * @param {number} y1 y1 + * @param {number} x2 x2 + * @param {number} y2 y2 + * @return {Point} isInside + */ + isInsideGrid(x1, y1, x2, y2) { + return x2 >= 0 && x1 < this.gridRightBoundary && y2 >= 0 && y1 < this.gridBottomBoundary; + } +} diff --git a/src/util/font-util.js b/src/util/font-util.js index 42c2387cb3..db24c24e1f 100644 --- a/src/util/font-util.js +++ b/src/util/font-util.js @@ -1,3 +1,8 @@ +/** + * tiny-sdf 中每个 glyph 的宽度(加上 buffer 24 + 3 + 3 = 30) + */ +const glyphSizeInSDF = 30; + export function nextPowOfTwo(number) { return Math.pow(2, Math.ceil(Math.log2(number))); } @@ -17,18 +22,23 @@ export function buildMapping({ Array.from(characterSet).forEach((char, i) => { if (!mapping[char]) { const width = getFontWidth(char, i); - if (x + width + buffer * 2 > maxCanvasWidth) { + if (x + glyphSizeInSDF > maxCanvasWidth) { + // if (x + width + buffer * 2 > maxCanvasWidth) { x = 0; row++; } mapping[char] = { - x: x + buffer, - y: yOffset + row * (fontHeight + buffer * 2) + buffer, - width, - height: fontHeight, - mask: true + // x: x + buffer, + x, + y: yOffset + row * glyphSizeInSDF, + // y: yOffset + row * (fontHeight + buffer * 2) + buffer, + width: glyphSizeInSDF, + // height: fontHeight, + height: glyphSizeInSDF, + advance: width }; - x += width + buffer * 2; + // x += width + buffer * 2; + x += glyphSizeInSDF; } }); diff --git a/src/util/grid-index.js b/src/util/grid-index.js new file mode 100644 index 0000000000..d5ec365965 --- /dev/null +++ b/src/util/grid-index.js @@ -0,0 +1,131 @@ +/** + * 网格索引,相比 @mapbox/grid-index,在简单计算碰撞检测结果时效率更高 + * @see https://zhuanlan.zhihu.com/p/74373214 + */ +class GridIndex { + constructor(width, height, cellSize) { + const boxCells = this.boxCells = []; + + this.xCellCount = Math.ceil(width / cellSize); + this.yCellCount = Math.ceil(height / cellSize); + + for (let i = 0; i < this.xCellCount * this.yCellCount; i++) { + boxCells.push([]); + } + this.boxKeys = []; + this.bboxes = []; + + this.width = width; + this.height = height; + this.xScale = this.xCellCount / width; + this.yScale = this.yCellCount / height; + this.boxUid = 0; + } + + insert(key, x1, y1, x2, y2) { + this._forEachCell(x1, y1, x2, y2, this._insertBoxCell, this.boxUid++); + this.boxKeys.push(key); + this.bboxes.push(x1); + this.bboxes.push(y1); + this.bboxes.push(x2); + this.bboxes.push(y2); + } + + _insertBoxCell(x1, y1, x2, y2, cellIndex, uid) { + this.boxCells[cellIndex].push(uid); + } + + _query(x1, y1, x2, y2, hitTest, predicate) { + if (x2 < 0 || x1 > this.width || y2 < 0 || y1 > this.height) { + return hitTest ? false : []; + } + const result = []; + if (x1 <= 0 && y1 <= 0 && this.width <= x2 && this.height <= y2) { + // 这一步是高效的关键,后续精确碰撞检测结果在计算文本可见性时并不需要 + if (hitTest) { + return true; + } + for (let boxUid = 0; boxUid < this.boxKeys.length; boxUid++) { + result.push({ + key: this.boxKeys[boxUid], + x1: this.bboxes[boxUid * 4], + y1: this.bboxes[boxUid * 4 + 1], + x2: this.bboxes[boxUid * 4 + 2], + y2: this.bboxes[boxUid * 4 + 3] + }); + } + return predicate ? result.filter(predicate) : result; + } + + const queryArgs = { + hitTest, + seenUids: { box: {}, circle: {} } + }; + this._forEachCell(x1, y1, x2, y2, this._queryCell, result, queryArgs, predicate); + return hitTest ? result.length > 0 : result; + } + + query(x1, y1, x2, y2, predicate) { + return this._query(x1, y1, x2, y2, false, predicate); + } + + hitTest(x1, y1, x2, y2, predicate) { + return this._query(x1, y1, x2, y2, true, predicate); + } + + _queryCell(x1, y1, x2, y2, cellIndex, result, queryArgs, predicate) { + const seenUids = queryArgs.seenUids; + const boxCell = this.boxCells[cellIndex]; + if (boxCell !== null) { + const bboxes = this.bboxes; + for (const boxUid of boxCell) { + if (!seenUids.box[boxUid]) { + seenUids.box[boxUid] = true; + const offset = boxUid * 4; + if ((x1 <= bboxes[offset + 2]) && + (y1 <= bboxes[offset + 3]) && + (x2 >= bboxes[offset + 0]) && + (y2 >= bboxes[offset + 1]) && + (!predicate || predicate(this.boxKeys[boxUid]))) { + if (queryArgs.hitTest) { + result.push(true); + return true; + } + result.push({ + key: this.boxKeys[boxUid], + x1: bboxes[offset], + y1: bboxes[offset + 1], + x2: bboxes[offset + 2], + y2: bboxes[offset + 3] + }); + } + } + } + } + return false; + } + + _forEachCell(x1, y1, x2, y2, fn, arg1, arg2, predicate) { + const cx1 = this._convertToXCellCoord(x1); + const cy1 = this._convertToYCellCoord(y1); + const cx2 = this._convertToXCellCoord(x2); + const cy2 = this._convertToYCellCoord(y2); + + for (let x = cx1; x <= cx2; x++) { + for (let y = cy1; y <= cy2; y++) { + const cellIndex = this.xCellCount * y + x; + if (fn.call(this, x1, y1, x2, y2, cellIndex, arg1, arg2, predicate)) return; + } + } + } + + _convertToXCellCoord(x) { + return Math.max(0, Math.min(this.xCellCount - 1, Math.floor(x * this.xScale))); + } + + _convertToYCellCoord(y) { + return Math.max(0, Math.min(this.yCellCount - 1, Math.floor(y * this.yScale))); + } +} + +export default GridIndex; diff --git a/src/util/symbol-layout.js b/src/util/symbol-layout.js new file mode 100644 index 0000000000..5022cba51a --- /dev/null +++ b/src/util/symbol-layout.js @@ -0,0 +1,234 @@ +/** + * 返回文本相对锚点位置 + * @param {string} anchor 锚点位置 + * @return {alignment} alignment + */ +function getAnchorAlignment(anchor) { + let horizontalAlign = 0.5; + let verticalAlign = 0.5; + + switch (anchor) { + case 'right': + case 'top-right': + case 'bottom-right': + horizontalAlign = 1; + break; + case 'left': + case 'top-left': + case 'bottom-left': + horizontalAlign = 0; + break; + default: + horizontalAlign = 0.5; + } + + switch (anchor) { + case 'bottom': + case 'bottom-right': + case 'bottom-left': + verticalAlign = 1; + break; + case 'top': + case 'top-right': + case 'top-left': + verticalAlign = 0; + break; + default: + verticalAlign = 0.5; + } + + return { horizontalAlign, verticalAlign }; +} + +// justify right = 1, left = 0, center = 0.5 +function justifyLine( + positionedGlyphs, + glyphMap, + start, + end, + justify) { + if (!justify) { + return; + } + + const lastPositionedGlyph = positionedGlyphs[end]; + const glyph = lastPositionedGlyph.glyph; + if (glyph) { + const lastAdvance = glyphMap[glyph].advance * lastPositionedGlyph.scale; + const lineIndent = (positionedGlyphs[end].x + lastAdvance) * justify; + + for (let j = start; j <= end; j++) { + positionedGlyphs[j].x -= lineIndent; + } + } +} + +// justify right=1 left=0 center=0.5 +// horizontalAlign right=1 left=0 center=0.5 +// verticalAlign right=1 left=0 center=0.5 +function align( + positionedGlyphs, + justify, + horizontalAlign, + verticalAlign, + maxLineLength, + lineHeight, + lineCount +) { + const shiftX = (justify - horizontalAlign) * maxLineLength; + const shiftY = (-verticalAlign * lineCount + 0.5) * lineHeight; + + for (let j = 0; j < positionedGlyphs.length; j++) { + positionedGlyphs[j].x += shiftX; + positionedGlyphs[j].y += shiftY; + } +} + +function shapeLines( + shaping, + glyphMap, + lines, + lineHeight, + textAnchor, + textJustify, + spacing +) { + // buffer 为 4 + const yOffset = -8; + + let x = 0; + let y = yOffset; + + let maxLineLength = 0; + const positionedGlyphs = shaping.positionedGlyphs; + + const justify = + textJustify === 'right' ? 1 : + textJustify === 'left' ? 0 : 0.5; + + const lineStartIndex = positionedGlyphs.length; + lines.forEach(line => { + line.split('').forEach(char => { + const glyph = glyphMap[char]; + const baselineOffset = 0; + + if (glyph) { + positionedGlyphs.push({ + glyph: char, + x, + y: y + baselineOffset, + vertical: false, // TODO:目前只支持水平方向 + scale: 1, + metrics: glyph + }); + x += glyph.advance + spacing; + } + }); + + // 左右对齐 + if (positionedGlyphs.length !== lineStartIndex) { + const lineLength = x - spacing; + maxLineLength = Math.max(lineLength, maxLineLength); + justifyLine(positionedGlyphs, glyphMap, lineStartIndex, positionedGlyphs.length - 1, justify); + } + + x = 0; + y += lineHeight; + }); + + const { horizontalAlign, verticalAlign } = getAnchorAlignment(textAnchor); + align(positionedGlyphs, justify, horizontalAlign, verticalAlign, maxLineLength, lineHeight, lines.length); + + // 计算包围盒 + const height = y - yOffset; + + shaping.top += -verticalAlign * height; + shaping.bottom = shaping.top + height; + shaping.left += -horizontalAlign * maxLineLength; + shaping.right = shaping.left + maxLineLength; +} + +/** + * 计算文本中每个独立字符相对锚点的位置 + * + * @param {string} text 原始文本 + * @param {*} glyphs mapping + * @param {number} lineHeight 行高 + * @param {string} textAnchor 文本相对于锚点的位置 + * @param {string} textJustify 左右对齐 + * @param {number} spacing 字符间距 + * @param {[number, number]} translate 文本水平 & 垂直偏移量 + * @return {boolean|shaping} 每个字符相对于锚点的位置 + */ +export function shapeText( + text, + glyphs, + lineHeight, + textAnchor, + textJustify, + spacing, + translate +) { + + // TODO:处理换行 + const lines = text.split('\n'); + + const positionedGlyphs = []; + const shaping = { + positionedGlyphs, + top: translate[1], + bottom: translate[1], + left: translate[0], + right: translate[0], + lineCount: lines.length, + text + }; + + shapeLines(shaping, glyphs, lines, lineHeight, textAnchor, textJustify, spacing); + if (!positionedGlyphs.length) return false; + + return shaping; +} + +export function getGlyphQuads( + shaping, + textOffset, + alongLine +) { + const { positionedGlyphs } = shaping; + const quads = []; + + for (let k = 0; k < positionedGlyphs.length; k++) { + const positionedGlyph = positionedGlyphs[k]; + const rect = positionedGlyph.metrics; + + // The rects have an addditional buffer that is not included in their size. + const rectBuffer = 4; + + const halfAdvance = rect.advance * positionedGlyph.scale / 2; + + const glyphOffset = alongLine ? + [ positionedGlyph.x + halfAdvance, positionedGlyph.y ] : + [ 0, 0 ]; + + const builtInOffset = alongLine ? + [ 0, 0 ] : + [ positionedGlyph.x + halfAdvance + textOffset[0], positionedGlyph.y + textOffset[1] ]; + + const x1 = (0 - rectBuffer) * positionedGlyph.scale - halfAdvance + builtInOffset[0]; + const y1 = (0 - rectBuffer) * positionedGlyph.scale + builtInOffset[1]; + const x2 = x1 + rect.width * positionedGlyph.scale; + const y2 = y1 + rect.height * positionedGlyph.scale; + + const tl = { x: x1, y: y1 }; + const tr = { x: x2, y: y1 }; + const bl = { x: x1, y: y2 }; + const br = { x: x2, y: y2 }; + + // TODO:处理字符旋转的情况 + + quads.push({ tl, tr, bl, br, tex: rect, glyphOffset }); + } + + return quads; +}