Merge pull request #13 from antvis/dev-map-labeling

新增文本标注
增加source更新事件
This commit is contained in:
@thinkinggis 2019-08-19 10:34:06 +08:00 committed by GitHub
commit 094fe4e925
28 changed files with 1013 additions and 556 deletions

View File

@ -20,7 +20,7 @@
<script>
var scene = new L7.Scene({
id: 'map',
mapStyle: 'light', // 样式URL
mapStyle: 'dark', // 样式URL
center: [120.19382669582967, 30.258134],
pitch: 0,
minZoom:5,
@ -47,6 +47,33 @@
opacity: 0.9
}).render();
scene.TextLayer({
zIndex: 5
})
.source(data)
.shape('name', 'text')
.active(true)
.filter('value', function(v) {
return v * 1 > 10000;
})
.size(20)
.color('#FFF')
.style({
// fontFamily: 'Monaco, monospace', // 字体
fontWeight: 500,
textAnchor: 'center', // 文本相对锚点的位置 center|left|right|top|bottom|top-left
textOffset: [ 0, 0 ], // 文本相对锚点的偏移量 [水平, 垂直]
spacing: 2, // 字符间距
padding: [ 4, 4 ], // 文本包围盒 padding [水平,垂直],影响碰撞检测结果,避免相邻文本靠的太近
strokeColor: 'white', // 描边颜色
strokeWidth: 1, // 描边宽度
opacity: 1.0
})
.render();
const popup = new L7.Popup({anchor:'left'}).setText('hello world')
const marker = new L7.Marker({color:'blue'})
.setLnglat( [120.19382669582967, 30.258134])

View File

@ -53,23 +53,16 @@ const scene = new L7.Scene({
window.scene = scene;
scene.on('loaded', () => {
$.get('https://gw.alipayobjects.com/os/rmsportal/epnZEheZeDgsiSjSPcCv.json', data => {
console.log(data);
const circleLayer = scene.PointLayer({
zIndex: 0,
})
.source(data,{
isCluster:true
})
// .shape('circle')
.shape('point_count', [ 'circle', 'triangle', 'hexagon' ])
// .shape('triangle')
// .shape('square')
// .shape('hexagon')
// .shape('octogon')
// .shape('hexagram')
// .shape('pentagon')
.shape('circle')
.size('point_count', [ 5, 40]) // default 1
//.size('value', [ 10, 300]) // default 1
.active(true)
.active(false)
.color('point_count',["#002466","#105CB3","#2894E0","#CFF6FF","#FFF5B8","#FFAB5C","#F27049","#730D1C"])
.style({
stroke: 'rgb(255,255,255)',
@ -77,30 +70,30 @@ scene.on('loaded', () => {
opacity: 1
})
.render();
window.circleLayer = circleLayer;
const layerText = scene.PointLayer({
zIndex: 3
scene.TextLayer({
zIndex: 5
})
.source(circleLayer.layerSource)
.shape('point_count', 'text')
.active(false)
.filter('point_count',(p)=>{
return p > 50
})
.size('point_count', [ 5, 20]) // default 1
.color('#fff')
.active(true)
.size('point_count', [ 10, 20, 24 ])
.color('#FFF')
.style({
stroke: '#999',
strokeWidth: 1,
// fontFamily: 'Monaco, monospace', // 字体
fontWeight: 200,
textAnchor: 'center', // 文本相对锚点的位置 center|left|right|top|bottom|top-left
textOffset: [ 0, 0 ], // 文本相对锚点的偏移量 [水平, 垂直]
spacing: 2, // 字符间距
padding: [ 4, 4 ], // 文本包围盒 padding [水平,垂直],影响碰撞检测结果,避免相邻文本靠的太近
strokeColor: 'white', // 描边颜色
strokeWidth: 1, // 描边宽度
opacity: 1.0
})
.render();
console.log(layerText);
});
});
});
</script>
</body>
</html>

View File

@ -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);

View File

@ -6,7 +6,7 @@
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="geometry" content="diagram">
<link rel="stylesheet" href="./assets/common.css">
<title>point_circle</title>
<title>text layer</title>
<style>
#map { position:absolute; top:0; bottom:0; width:100%; }
</style>
@ -29,18 +29,39 @@ 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();

86
demos/text.html Normal file
View File

@ -0,0 +1,86 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<meta name="geometry" content="diagram">
<link rel="stylesheet" href="./assets/common.css">
<title>text layer</title>
<style>
#map { position:absolute; top:0; bottom:0; width:100%; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://webapi.amap.com/maps?v=1.4.8&key=15cd8a57710d40c9b7c0e3cc120f1200&plugin=Map3D"></script>
<script src="./assets/jquery-3.2.1.min.js"></script>
<script src="./assets/dat.gui.min.js"></script>
<script src="../build/L7.js"></script>
<script>
const scene = new L7.Scene({
id: 'map',
mapStyle: 'dark', // 样式URL
center: [ 120.19382669582967, 30.258134 ],
pitch: 0,
zoom: 10
});
window.scene = scene;
scene.on('loaded', () => {
$.get('https://gw.alipayobjects.com/os/rmsportal/oVTMqfzuuRFKiDwhPSFL.json', data => {
scene.PointLayer({
zIndex: 3
})
.source(data.list, {
parser:{
type: 'json',
x: 'j',
y: 'w',
}
})
.shape('circle')
.active(true)
.size(3)
.color('#CFF6FF')
.style({
stroke: '#999',
strokeWidth: 1,
opacity: 1.0
})
.render();
scene.TextLayer({
zIndex: 5
})
.source(data.list, {
parser:{
type: 'json',
x: 'j',
y: 'w',
}
})
.shape('m', 'text')
.active(true)
.size('w', [ 10, 20, 24 ])
.color('#F27049')
.style({
// fontFamily: 'Monaco, monospace', // 字体
fontWeight: 200,
textAnchor: 'center', // 文本相对锚点的位置 center|left|right|top|bottom|top-left
textOffset: [ 0, 0 ], // 文本相对锚点的偏移量 [水平, 垂直]
spacing: 2, // 字符间距
padding: [ 4, 4 ], // 文本包围盒 padding [水平,垂直],影响碰撞检测结果,避免相邻文本靠的太近
strokeColor: 'white', // 描边颜色
strokeWidth: 1, // 描边宽度
opacity: 1.0
})
.render();
});
});
</script>
</body>
</html>

View File

@ -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",

View File

@ -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) {

View File

@ -86,6 +86,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];
@ -106,15 +111,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;
}

View File

@ -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;
@ -564,13 +566,15 @@ export default class Layer extends Base {
let offset = 0;
if (this.type === 'point') {
offset = 5;
this.shapeType = 'text' && (offset = 10);
} 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;

View File

@ -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

View File

@ -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) {

View File

@ -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;
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) {
// TODOfeatureIndex
collisionIndex.insertCollisionBox(box, 0);
// 3. 计算可供渲染的文本块,其中每个字符都包含纹理坐标
const glyphQuads = getGlyphQuads(shaping, textOffset, false);
// 4. 构建顶点数据,四个顶点组成一个 quad
indexCounter = addAttributeForFeature(feature, attributes, glyphQuads, indexCounter);
}
}
});
return attributes;
}
function addAttributeForFeature(feature, attributes, glyphQuads, indexCounter) {
const { id, size, color, coordinates } = feature;
glyphQuads.forEach(quad => {
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
id,
id,
id,
id
);
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
attributes.positions.push(
coordinates[0], coordinates[1],
coordinates[0], coordinates[1],
coordinates[0], coordinates[1],
coordinates[0], coordinates[1]
);
pen.x = pen.x + size;
}
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;
});
attributes.texture = texture;
attributes.fontAtlas = fontAtlas;
return attributes;
return indexCounter;
}

View File

@ -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}`;
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -1,32 +1,31 @@
precision mediump float;
uniform sampler2D u_texture;
#define SDF_PX 8.0
#define EDGE_GAMMA 0.105 / float(DEVICE_PIXEL_RATIO)
uniform sampler2D u_sdf_map;
uniform float u_gamma_scale : 0.5;
uniform float u_font_size : 24;
uniform float u_font_opacity : 1.0;
uniform vec4 u_halo_color : [0, 0, 0, 1];
uniform float u_halo_width : 2.0;
uniform float u_halo_blur : 0.5;
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;
varying vec2 v_uv;
varying float v_gamma_scale;
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<u_buffer){
alpha=smoothstep(u_buffer-u_gamma,u_buffer+u_gamma,dist);
gl_FragColor=vec4(alpha*v_color.rgb+(1.-alpha)*u_stroke.rgb,1.*v_color.a*alpha+(1.-alpha)*u_stroke.a);
}else{
alpha=1.;
gl_FragColor=vec4(v_color.rgb,alpha*v_color.a);
}
void main() {
// get sdf from atlas
float dist = texture2D(u_sdf_map, v_uv).a;
}
float fontScale = u_font_size / 24.0;
lowp float buff = (6.0 - u_halo_width / fontScale) / SDF_PX;
highp float gamma = (u_halo_blur * 1.19 / SDF_PX + EDGE_GAMMA) / (fontScale * u_gamma_scale);
highp float gamma_scaled = gamma * v_gamma_scale;
highp float alpha = smoothstep(buff - gamma_scaled, buff + gamma_scaled, dist);
gl_FragColor = mix(v_color * u_font_opacity, u_halo_color, smoothstep(0., .5, 1. - dist)) * alpha;
#pragma include "pick"
}

View File

@ -1,26 +1,37 @@
precision mediump float;
attribute vec2 a_txtsize;
attribute vec2 a_txtOffsets;
uniform float u_opacity;
attribute vec2 a_pos;
attribute vec2 a_tex;
attribute vec2 a_offset;
attribute vec4 a_color;
uniform vec2 u_textTextureSize;// 纹理大小
uniform vec2 u_glSize;
varying vec2 v_texcoord;
varying vec4 v_color;
varying float v_size;
uniform float u_activeId;
uniform vec4 u_activeColor;
attribute float a_size;
void main(){
mat4 matModelViewProjection=projectionMatrix*modelViewMatrix;
vec4 cur_position=matModelViewProjection*vec4(position.xy,0,1);
v_size = 12. / u_glSize.x;
gl_Position=cur_position/cur_position.w+vec4((a_txtOffsets+a_txtsize)/u_glSize*2.,0.,0.);
v_color=vec4(a_color.rgb,a_color.a*u_opacity);
if(pickingId==u_activeId){
v_color=u_activeColor;
uniform vec2 u_sdf_map_size;
uniform vec2 u_viewport_size;
uniform float u_activeId : 0;
uniform vec4 u_activeColor : [1.0, 0.0, 0.0, 1.0];
varying vec2 v_uv;
varying float v_gamma_scale;
varying vec4 v_color;
void main() {
v_color = a_color;
v_uv = a_tex / u_sdf_map_size;
// 文本缩放比例
float fontScale = a_size / 24.;
// 投影到屏幕空间 + 偏移量
vec4 projected_position = projectionMatrix * modelViewMatrix * vec4(a_pos, 0., 1.);
gl_Position = vec4(projected_position.xy / projected_position.w
+ a_offset * fontScale / u_viewport_size * 2., 0.0, 1.0);
v_gamma_scale = gl_Position.w;
if (pickingId == u_activeId) {
v_color = u_activeColor;
}
v_texcoord=uv/u_textTextureSize;
#ifdef PICK
worldId = id_toPickColor(pickingId);
#endif

View File

@ -9,8 +9,10 @@ export default class Active extends Interaction {
}
process(ev) {
this.layer._addActiveFeature(ev);
this.layer.scene._engine.update();
}
reset() {
this.layer._resetStyle();
this.layer.scene._engine.update();
}
}

View File

@ -8,5 +8,6 @@ export default class Select extends Interaction {
}
process(ev) {
this.layer._addActiveFeature(ev);
this.layer.scene._engine.update();
}
}

View File

@ -8,6 +8,7 @@ import HeatmapLayer from './heatmap_layer';
import TileLayer from './tile/tile_layer';
import ImageTileLayer from './tile/image_tile_layer';
import VectorTileLayer from './tile/vector_tile_layer';
import TextLayer from './text_layer';
registerLayer('PolygonLayer', PolygonLayer);
registerLayer('PointLayer', PointLayer);
@ -18,6 +19,7 @@ registerLayer('HeatmapLayer', HeatmapLayer);
registerLayer('TileLayer', TileLayer);
registerLayer('ImageTileLayer', ImageTileLayer);
registerLayer('VectorTileLayer', VectorTileLayer);
registerLayer('TextLayer', TextLayer);
export { LAYER_MAP, getLayer } from './factory';
export { registerLayer };

View File

@ -53,4 +53,10 @@ import DrawImage from './image/drawImage';
registerRender('image', 'image', DrawImage);
// image
import DrawText from './text/drawText';
registerRender('text', 'text', DrawText);
export { getRender };

View File

@ -0,0 +1,102 @@
import * as THREE from '../../../core/three';
import TextMaterial from '../../../geom/material/textMaterial';
import TextBuffer from '../../../geom/buffer/point/text';
import ColorUtil from '../../../attr/color-util';
export default function DrawText(layerData, layer, updateGeometry = false) {
const style = layer.get('styleOptions');
const activeOption = layer.get('activedOptions');
const { strokeWidth, strokeColor, opacity,
fontFamily, fontWeight, spacing, textAnchor, textOffset, padding } = style;
const { width, height } = layer.scene.getSize();
const { _camera: { projectionMatrix, matrixWorldInverse } } = layer.scene._engine;
// 计算 MVP 矩阵
const mvpMatrix = new THREE.Matrix4()
.copy(projectionMatrix)
.multiply(matrixWorldInverse);
const {
index,
positions,
pickingIds,
texture,
colors,
textUVs,
textOffsets,
textSizes,
fontAtlas
} = new TextBuffer(
layerData,
layerData,
{
fontFamily,
fontWeight,
spacing,
textAnchor,
textOffset,
padding
},
layer.scene.fontAtlasManager,
layer.collisionIndex,
mvpMatrix
);
const geometry = new THREE.BufferGeometry();
geometry.setIndex(index);
geometry.addAttribute(
'a_pos',
new THREE.Float32BufferAttribute(positions, 2)
);
geometry.addAttribute(
'pickingId',
new THREE.Float32BufferAttribute(pickingIds, 1)
);
geometry.addAttribute(
'a_color',
new THREE.Float32BufferAttribute(colors, 4)
);
geometry.addAttribute(
'a_tex',
new THREE.Float32BufferAttribute(textUVs, 2)
);
geometry.addAttribute(
'a_offset',
new THREE.Float32BufferAttribute(textOffsets, 2)
);
geometry.addAttribute(
'a_size',
new THREE.Float32BufferAttribute(textSizes, 1)
);
// 只需要更新顶点数据
if (updateGeometry) {
return geometry;
}
const material = new TextMaterial({
name: layer.layerId,
u_sdf_map: texture,
u_halo_color: ColorUtil.toRGB(strokeColor).map(e => 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;
}

27
src/layer/text_layer.js Normal file
View File

@ -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));
}
}

View File

@ -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', () => {

View File

@ -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;
}
}

View File

@ -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;
}
});

131
src/util/grid-index.js Normal file
View File

@ -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;

234
src/util/symbol-layout.js Normal file
View File

@ -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;
}