diff --git a/demos/01_circle.html b/demos/01_circle.html index 45526361b8..cb43185a1b 100644 --- a/demos/01_circle.html +++ b/demos/01_circle.html @@ -20,7 +20,7 @@ diff --git a/demos/03_choropleths_polygon.html b/demos/03_choropleths_polygon.html index e141d1476c..e1d7994bce 100644 --- a/demos/03_choropleths_polygon.html +++ b/demos/03_choropleths_polygon.html @@ -64,7 +64,6 @@ scene.on('loaded', () => { $.getJSON('https://gw.alipayobjects.com/os/rmsportal/JToMOWvicvJOISZFCkEI.json', city => { const citylayer = scene.PolygonLayer() .source(city) - //.color('pm2_5_24h',["#FFF5B8","#FFDC7D","#FFAB5C","#F27049","#D42F31","#730D1C"]) .color('pm2_5_24h',(p)=>{ if(p>120){ return colors[5]; @@ -94,8 +93,7 @@ scene.on('loaded', () => { .style({ opacity: 1.0 }) - //.render(); - + .render(); citylayer.on('mouseleave',(e)=>{ console.log(e); diff --git a/demos/06_text.html b/demos/06_text.html index 45708e187f..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('https://gw.alipayobjects.com/os/basement_prod/abcfe339-b8bc-46ce-8ff4-c96185b6235f.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/demos/text.html b/demos/text.html new file mode 100644 index 0000000000..4995e32e20 --- /dev/null +++ b/demos/text.html @@ -0,0 +1,86 @@ + + + + + + + + + text layer + + + + +
+ + + + + + + + diff --git a/package.json b/package.json index 16540a6840..4bfd95b7ed 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@antv/l7", - "version": "1.2.3", + "version": "1.3.0", "description": "Large-scale WebGL-powered Geospatial Data Visualization", "main": "build/L7.js", "browser": "build/L7-min.js", diff --git a/src/core/atlas/font-manager.js b/src/core/atlas/font-manager.js index b51ffe17e3..fda65a5a70 100644 --- a/src/core/atlas/font-manager.js +++ b/src/core/atlas/font-manager.js @@ -1,6 +1,6 @@ import TinySDF from '@mapbox/tiny-sdf'; import { buildMapping } from '../../util/font-util'; -import * as THREE from '../../core/three'; +import * as THREE from '../three'; import LRUCache from '../../util/lru-cache'; export const DEFAULT_CHAR_SET = getDefaultCharacterSet(); export const DEFAULT_FONT_FAMILY = 'sans-serif'; @@ -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/core/controller/mapping.js b/src/core/controller/mapping.js index 3ca6a32f9f..7b0d4e6495 100644 --- a/src/core/controller/mapping.js +++ b/src/core/controller/mapping.js @@ -87,6 +87,11 @@ export default class Mapping { const record = data[i]; const newRecord = {}; newRecord.id = data[i]._id; + if (attrs.hasOwnProperty('filter')) { + const attr = attrs.filter; + const values = this._getAttrValues(attr, record); + if (!values[0]) continue; + } for (const k in attrs) { if (attrs.hasOwnProperty(k)) { const attr = attrs[k]; @@ -107,15 +112,6 @@ export default class Mapping { newRecord.coordinates = record.coordinates; mappedData.push(newRecord); } - // 通过透明度过滤数据 - if (attrs.hasOwnProperty('filter')) { - mappedData.forEach(item => { - if (item.filter === false) { - (item.color[3] = 0); - item.id = -item.id; - } - }); - } this.mesh.layerData = mappedData; } diff --git a/src/core/layer.js b/src/core/layer.js index 72ac241715..e76c2434a9 100644 --- a/src/core/layer.js +++ b/src/core/layer.js @@ -77,7 +77,6 @@ export default class Layer extends Base { world.add(this._object3D); this.layerMesh = null; this.layerLineMesh = null; - // this._initEvents(); } /** * 将图层添加加到 Object @@ -145,6 +144,9 @@ export default class Layer extends Base { if (data instanceof source) { this.layerSource = data; + this.layerSource.on('SourceUpdate', () => { + this.repaint(); + }); return this; } cfg.data = data; @@ -563,14 +565,16 @@ export default class Layer extends Base { // z-fighting let offset = 0; if (this.type === 'point') { - offset = 3; - this.shapeType = 'text' && (offset = 10); + offset = 5; } else if (this.type === 'polyline' || this.type === 'line') { offset = 2; } else if (this.type === 'polygon') { offset = 1; } + if (this.type === 'text') { + offset = 10; + } this._object3D.position && (this._object3D.position.z = offset * Math.pow(2, 20 - zoom)); if (zoom < minZoom || zoom >= maxZoom) { this._object3D.visible = false; diff --git a/src/core/scene.js b/src/core/scene.js index a94034fd1a..ca6d0f7a73 100644 --- a/src/core/scene.js +++ b/src/core/scene.js @@ -2,7 +2,7 @@ import Engine from './engine'; import { LAYER_MAP } from '../layer'; import Base from './base'; import LoadImage from './image'; -import FontAtlasManager from '../geom/buffer/point/text/font-manager'; +import FontAtlasManager from './atlas/font-manager'; // import { MapProvider } from '../map/AMap'; import { getMap } from '../map/index'; import Global from '../global'; @@ -24,12 +24,13 @@ export default class Scene extends Base { this.fontAtlasManager = new FontAtlasManager(); this._layers = []; this.animateCount = 0; + this.inited = false; } _initEngine(mapContainer) { this._engine = new Engine(mapContainer, this); - this.registerMapEvent(); - this._engine.run(); + this.registerMapEvent(); // 和高德地图同步状态 + // this._engine.run(); compileBuiltinModules(); } _initContoller() { @@ -53,13 +54,13 @@ export default class Scene extends Base { const Map = new MapProvider(this._attrs); Map.mixMap(this); this._container = Map.container; - this._markerContainier = Map.l7_marker_Container; Map.on('mapLoad', () => { this.map = Map.map; + this._markerContainier = Map.l7_marker_Container; this._initEngine(Map.renderDom); Map.asyncCamera(this._engine); this.initLayer(); - // this._registEvents(); + this._registEvents(); const hash = this.get('hash'); if (hash) { const Ctor = getInteraction('hash'); @@ -174,14 +175,14 @@ export default class Scene extends Base { // 地图状态变化时更新可视化渲染 registerMapEvent() { this._updateRender = () => this._engine.update(); - this.map.on('mousemove', this._updateRender); - // this.map.on('mapmove', this._updateRender); + // this.map.on('mousemove', this._updateRender); + this.map.on('mapmove', this._updateRender); this.map.on('camerachange', this._updateRender); } unRegsterMapEvent() { - this.map.off('mousemove', this._updateRender); - // this.map.off('mapmove', this._updateRender); + // this.map.off('mousemove', this._updateRender); + this.map.off('mapmove', this._updateRender); this.map.off('camerachange', this._updateRender); } // control diff --git a/src/core/source.js b/src/core/source.js index dae12d9c07..af4291252a 100644 --- a/src/core/source.js +++ b/src/core/source.js @@ -8,11 +8,8 @@ export default class Source extends Base { getDefaultCfg() { return { data: null, - defs: {}, parser: {}, transforms: [], - scaledefs: {}, - scales: {}, options: {} }; } @@ -99,6 +96,7 @@ export default class Source extends Base { clusterCfg.bbox = bbox; this.set('cluster', clusterCfg); this._projectCoords(); + this.emit('SourceUpdate'); } _projectCoords() { if (this.data === null) { diff --git a/src/geom/buffer/point/text.js b/src/geom/buffer/point/text.js index 9b2f5f50b8..64a00d104e 100644 --- a/src/geom/buffer/point/text.js +++ b/src/geom/buffer/point/text.js @@ -1,130 +1,163 @@ -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 { + fontWeight, + fontFamily + } = options; const characterSet = []; - layerData.forEach(element => { + sourceData.forEach(element => { + // shape 存储了 text-field let text = element.shape || ''; 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, + { + 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 = `${layerData[i].shape || ''}`; + // 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 deleted file mode 100644 index 0fdb8bb53f..0000000000 --- a/src/geom/buffer/point/text/font-manager.js +++ /dev/null @@ -1,227 +0,0 @@ -import TinySDF from '@mapbox/tiny-sdf'; -import { buildMapping } from '../../../../util/font-util'; -import * as THREE from '../../../../core/three'; -import LRUCache from './lru-cache'; -export const DEFAULT_CHAR_SET = getDefaultCharacterSet(); -export const DEFAULT_FONT_FAMILY = 'sans-serif'; -export const DEFAULT_FONT_WEIGHT = 'normal'; -export const DEFAULT_FONT_SIZE = 24; -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 CACHE_LIMIT = 3; -const cache = new LRUCache(CACHE_LIMIT); - -const VALID_PROPS = [ - 'fontFamily', - 'fontWeight', - 'characterSet', - 'fontSize', - 'sdf', - 'buffer', - 'cutoff', - 'radius' -]; - -function getDefaultCharacterSet() { - const charSet = []; - for (let i = 32; i < 128; i++) { - charSet.push(String.fromCharCode(i)); - } - return charSet; -} - -function setTextStyle(ctx, fontFamily, fontSize, fontWeight) { - ctx.font = `${fontWeight} ${fontSize}px ${fontFamily}`; - ctx.fillStyle = '#000'; - ctx.textBaseline = 'baseline'; - ctx.textAlign = 'left'; -} -function getNewChars(key, characterSet) { - const cachedFontAtlas = cache.get(key); - if (!cachedFontAtlas) { - return characterSet; - } - - const newChars = []; - const cachedMapping = cachedFontAtlas.mapping; - let cachedCharSet = Object.keys(cachedMapping); - cachedCharSet = new Set(cachedCharSet); - - let charSet = characterSet; - if (charSet instanceof Array) { - charSet = new Set(charSet); - } - - charSet.forEach(char => { - if (!cachedCharSet.has(char)) { - newChars.push(char); - } - }); - - return newChars; -} - -function populateAlphaChannel(alphaChannel, imageData) { - // populate distance value from tinySDF to image alpha channel - for (let i = 0; i < alphaChannel.length; i++) { - imageData.data[4 * i + 3] = alphaChannel[i]; - } -} - -export default class FontAtlasManager { - constructor() { - - // font settings - this.props = { - fontFamily: DEFAULT_FONT_FAMILY, - fontWeight: DEFAULT_FONT_WEIGHT, - characterSet: DEFAULT_CHAR_SET, - fontSize: DEFAULT_FONT_SIZE, - buffer: DEFAULT_BUFFER, - // sdf only props - // https://github.com/mapbox/tiny-sdf - sdf: true, - cutoff: DEFAULT_CUTOFF, - radius: DEFAULT_RADIUS - }; - - // key is used for caching generated fontAtlas - this._key = null; - this._texture = new THREE.Texture(); - } - - get texture() { - return this._texture; - } - - get mapping() { - const data = cache.get(this._key); - return data && data.mapping; - } - - get scale() { - return HEIGHT_SCALE; - } - - get fontAtlas() { - return this._fontAtlas; - } - - setProps(props = {}) { - VALID_PROPS.forEach(prop => { - if (prop in props) { - this.props[prop] = props[prop]; - } - }); - - // update cache key - const oldKey = this._key; - this._key = this._getKey(); - - const charSet = getNewChars(this._key, this.props.characterSet); - const cachedFontAtlas = cache.get(this._key); - - // if a fontAtlas associated with the new settings is cached and - // there are no new chars - if (cachedFontAtlas && charSet.length === 0) { - // update texture with cached fontAtlas - if (this._key !== oldKey) { - this._updateTexture(cachedFontAtlas); - } - return; - } - - // update fontAtlas with new settings - const fontAtlas = this._generateFontAtlas(this._key, charSet, cachedFontAtlas); - this._fontAtlas = fontAtlas; - this._updateTexture(fontAtlas); - - // update cache - cache.set(this._key, fontAtlas); - } - - _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.flipY = false; - this._texture.needUpdate = true; - } - - _generateFontAtlas(key, characterSet, cachedFontAtlas) { - const { fontFamily, fontWeight, fontSize, buffer, sdf, radius, cutoff } = this.props; - let canvas = cachedFontAtlas && cachedFontAtlas.data; - if (!canvas) { - canvas = document.createElement('canvas'); - canvas.width = MAX_CANVAS_WIDTH; - } - const ctx = canvas.getContext('2d'); - - setTextStyle(ctx, fontFamily, fontSize, fontWeight); - - // 1. build mapping - const { mapping, canvasHeight, xOffset, yOffset } = buildMapping( - Object.assign( - { - getFontWidth: char => ctx.measureText(char).width, - fontHeight: fontSize * HEIGHT_SCALE, - buffer, - characterSet, - maxCanvasWidth: MAX_CANVAS_WIDTH - }, - cachedFontAtlas && { - mapping: cachedFontAtlas.mapping, - xOffset: cachedFontAtlas.xOffset, - yOffset: cachedFontAtlas.yOffset - } - ) - ); - - // 2. update canvas - // copy old canvas data to new canvas only when height changed - if (canvas.height !== canvasHeight) { - const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); - canvas.height = canvasHeight; - ctx.putImageData(imageData, 0, 0); - } - setTextStyle(ctx, fontFamily, fontSize, fontWeight); - - // 3. layout characters - if (sdf) { - const tinySDF = new TinySDF(fontSize, buffer, radius, cutoff, fontFamily, fontWeight); - // used to store distance values from tinySDF - // tinySDF.size equals `fontSize + buffer * 2` - const imageData = ctx.getImageData(0, 0, tinySDF.size, tinySDF.size); - - for (const char of characterSet) { - populateAlphaChannel(tinySDF.draw(char), imageData); - ctx.putImageData(imageData, mapping[char].x - buffer, mapping[char].y - buffer); - } - } else { - for (const char of characterSet) { - ctx.fillText(char, mapping[char].x, mapping[char].y + fontSize * BASELINE_SCALE); - } - } - return { - xOffset, - yOffset, - mapping, - data: canvas, - width: canvas.width, - height: canvas.height - }; - } - - _getKey() { - const { fontFamily, fontWeight, fontSize, buffer, sdf, radius, cutoff } = this.props; - if (sdf) { - return `${fontFamily} ${fontWeight} ${fontSize} ${buffer} ${radius} ${cutoff}`; - } - return `${fontFamily} ${fontWeight} ${fontSize} ${buffer}`; - } -} diff --git a/src/geom/buffer/point/text/lru-cache.js b/src/geom/buffer/point/text/lru-cache.js deleted file mode 100644 index 8b417e53e2..0000000000 --- a/src/geom/buffer/point/text/lru-cache.js +++ /dev/null @@ -1,71 +0,0 @@ -/** - * LRU Cache class with limit - * - * Update order for each get/set operation - * Delete oldest when reach given limit - */ - -export default class LRUCache { - constructor(limit = 5) { - this.limit = limit; - - this.clear(); - } - - clear() { - this._cache = {}; - // access/update order, first item is oldest, last item is newest - this._order = []; - } - - get(key) { - const value = this._cache[key]; - if (value) { - // update order - this._deleteOrder(key); - this._appendOrder(key); - } - return value; - } - - set(key, value) { - if (!this._cache[key]) { - // if reach limit, delete the oldest - if (Object.keys(this._cache).length === this.limit) { - this.delete(this._order[0]); - } - - this._cache[key] = value; - this._appendOrder(key); - } else { - // if found in cache, delete the old one, insert new one to the first of list - this.delete(key); - - this._cache[key] = value; - this._appendOrder(key); - } - } - - delete(key) { - const value = this._cache[key]; - if (value) { - this._deleteCache(key); - this._deleteOrder(key); - } - } - - _deleteCache(key) { - delete this._cache[key]; - } - - _deleteOrder(key) { - const index = this._order.findIndex(o => o === key); - if (index >= 0) { - this._order.splice(index, 1); - } - } - - _appendOrder(key) { - this._order.push(key); - } -} 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 bbbb18548e..a09523f5cd 100644 --- a/src/geom/shader/text_frag.glsl +++ b/src/geom/shader/text_frag.glsl @@ -1,32 +1,31 @@ -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/text_layer.js b/src/layer/text_layer.js new file mode 100644 index 0000000000..b14343ce38 --- /dev/null +++ b/src/layer/text_layer.js @@ -0,0 +1,27 @@ +import Layer from '../core/layer'; +import { getRender } from './render'; +import CollisionIndex from '../util/collision-index'; +export default class TextLayer extends Layer { + shape(field, values) { + super.shape(field, values); + this.shape = 'text'; + + // 创建碰撞检测索引 + const { width, height } = this.scene.getSize(); + this.collisionIndex = new CollisionIndex(width, height); + + // 相机变化,需要重新构建索引,由于文本可见性的改变,也需要重新组装顶点数据 + this.scene.on('camerachange', () => { + 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.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 8e0fc255c9..0e6f4d7880 100644 --- a/src/map/AMap.js +++ b/src/map/AMap.js @@ -53,7 +53,7 @@ export default class GaodeMap extends Base { this.container = map.getContainer(); this.get('mapStyle') && this.map.setMapStyle(this.get('mapStyle')); this.addOverLayer(); - this.emit('mapLoad'); + setTimeout(() => { this.emit('mapLoad'); }, 50); } else { this.map = new AMap.Map(this.container, this._attrs); this.map.on('complete', () => { 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; +}