Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"three": "0.182.0"
},
"devDependencies": {
"@antv/async-hook": "^2.2.9",
"@antv/l7-draw": "^3.1.5",
"@antv/translator": "^1.0.1",
"@changesets/changelog-github": "^0.5.0",
Expand Down
1 change: 1 addition & 0 deletions packages/layers/src/core/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ export interface IPointLayerStyleOptions extends IBaseLayerStyleOptions {
fontWeight?: string;
fontFamily?: string;
textAllowOverlap?: boolean;
allowOverlap?: boolean; // image 类型图标压盖控制,false 时开启碰撞避让
// cylinder
pickLight?: boolean;

Expand Down
7 changes: 7 additions & 0 deletions packages/layers/src/core/triangulation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,13 @@ export function PointExtrudeTriangulation(feature: IEncodeFeature) {
* @param feature 映射feature
*/
export function PointImageTriangulation(feature: IEncodeFeature) {
// @ts-ignore
const that = this as { imageFilterMap?: { [id: number]: boolean } };
Comment on lines +135 to +136

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

使用 @ts-ignorethat 变量来为 this 提供类型不是最佳实践。TypeScript 支持在函数的第一个参数位置声明 this 的类型,这样做可以更清晰地表达函数对 this 上下文的依赖,并且能获得更好的类型检查。

建议将函数签名修改为 export function PointImageTriangulation(this: { imageFilterMap?: { [id: number]: boolean } }, feature: IEncodeFeature),然后就可以在函数体内直接安全地使用 this,无需 that 变量和类型断言。

const id = feature.id as number;
// 当 imageFilterMap 存在且该 feature 未通过碰撞检测时,返回空几何(等效隐藏)
if (that?.imageFilterMap && !that.imageFilterMap[id]) {
return { vertices: [], indices: [], size: 3 };
}
const coordinates = calculateCentroid(feature.coordinates);
return {
vertices: [...coordinates],
Expand Down
13 changes: 12 additions & 1 deletion packages/layers/src/point/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { IEncodeFeature } from '@antv/l7-core';
import type { IEncodeFeature, ILayer, ILayerConfig } from '@antv/l7-core';
import BaseLayer from '../core/BaseLayer';
import type { IPointLayerStyleOptions } from '../core/interface';
import type { PointType } from './models/index';
Expand Down Expand Up @@ -31,6 +31,17 @@ export default class PointLayer extends BaseLayer<IPointLayerStyleOptions> {
await this.buildModels();
}

public style(options: Partial<IPointLayerStyleOptions> & Partial<ILayerConfig>): ILayer {
super.style(options);
// allowOverlap 依赖 needUpdate() 感知变化并重建 model,而 needUpdate() 仅在渲染帧的
// beforeRender 钩子中被调用。style() 本身不触发渲染,因此在 allowOverlap 出现时
// 主动触发一次 reRender,确保静止地图下也能即时生效。
if ('allowOverlap' in options) {
this.reRender();
}
return this;
}

/**
* 在未传入数据的时候判断点图层的 shape 类型
* @returns
Expand Down
125 changes: 119 additions & 6 deletions packages/layers/src/point/models/image.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import type { IEncodeFeature, IModel, IModelUniform, ITexture2D } from '@antv/l7-core';
import { AttributeType, gl } from '@antv/l7-core';
import { boundsContains, calculateCentroid, padBounds } from '@antv/l7-utils';
import BaseModel from '../../core/BaseModel';
import type { IPointLayerStyleOptions } from '../../core/interface';
import { PointImageTriangulation } from '../../core/triangulation';
import CollisionIndex from '../../utils/collision-index';
import pointImageFrag from '../shaders/image/image_frag.glsl';
import pointImageVert from '../shaders/image/image_vert.glsl';

export default class ImageModel extends BaseModel {
protected get attributeLocation() {
return Object.assign(super.attributeLocation, {
Expand All @@ -16,6 +19,12 @@ export default class ImageModel extends BaseModel {

private texture: ITexture2D;

// 通过碰撞检测的 feature id 集合,由 filterImages() 填充;null 表示不启用过滤(allowOverlap: true)
public imageFilterMap: { [key: number]: boolean } | null = null;
private currentZoom: number = -1;
private extent: [[number, number], [number, number]];
private preAllowOverlap: boolean = true;

public getUninforms(): IModelUniform {
// ThreeJS 图层兼容
if (this.rendererService.getDirty()) {
Expand Down Expand Up @@ -53,23 +62,32 @@ export default class ImageModel extends BaseModel {
}

public async initModels(): Promise<IModel[]> {
const { allowOverlap = true } = this.layer.getLayerConfig() as IPointLayerStyleOptions;
this.extent = this.imageExtent();
this.preAllowOverlap = allowOverlap;
this.iconService.on('imageUpdate', this.updateTexture);
this.updateTexture();
return this.buildModels();
}

public clearModels() {
this.texture?.destroy();
this.iconService.off('imageUpdate', this.updateTexture);
}

public async buildModels(): Promise<IModel[]> {
const { allowOverlap = true } = this.layer.getLayerConfig() as IPointLayerStyleOptions;

this.initUniformsBuffer();

if (!allowOverlap) {
this.filterImages();
} else {
// 允许压盖时置 null,PointImageTriangulation 检测到 null 则跳过过滤
this.imageFilterMap = null;
}

const model = await this.layer.buildLayerModel({
moduleName: 'pointImage',
vertexShader: pointImageVert,
fragmentShader: pointImageFrag,
triangulation: PointImageTriangulation,
// 绑定 this,让 PointImageTriangulation 能读取 imageFilterMap
triangulation: PointImageTriangulation.bind(this),
defines: this.getDefines(),
inject: this.getInject(),
depth: { enable: false },
Expand All @@ -78,6 +96,38 @@ export default class ImageModel extends BaseModel {
return [model];
}

public async needUpdate(): Promise<boolean> {
const { allowOverlap = true } = this.layer.getLayerConfig() as IPointLayerStyleOptions;

// allowOverlap 开关发生变化时,强制重建
if (allowOverlap !== this.preAllowOverlap) {
this.preAllowOverlap = allowOverlap;
await this.reBuildModel();
return true;
}

// 允许压盖时无需每帧检测
if (allowOverlap) {
return false;
}

// 不允许压盖时,zoom 变化 >0.5 或视野超出上次范围则重算碰撞
const zoom = this.mapService.getZoom();
const extent = this.mapService.getBounds();
const flag = boundsContains(this.extent, extent);
if (Math.abs(this.currentZoom - zoom) > 0.5 || !flag) {
await this.reBuildModel();
return true;
}

return false;
}

public clearModels() {
this.texture?.destroy();
this.iconService.off('imageUpdate', this.updateTexture);
}

protected registerBuiltinAttributes() {
// 注册 Position 属性 64 位地位部分,经纬度数据开启双精度,避免大于 20层级以上出现数据偏移
this.registerPosition64LowAttribute();
Expand Down Expand Up @@ -127,6 +177,69 @@ export default class ImageModel extends BaseModel {
});
}

/**
* 图标碰撞避让:遍历所有 feature,在屏幕空间做 AABB 碰撞检测,
* 将通过检测的 feature id 写入 imageFilterMap。
* PointImageTriangulation 绑定 this 后,会跳过不在 map 中的 feature。
*/
private filterImages() {
const { padding = [0, 0] } = this.layer.getLayerConfig() as IPointLayerStyleOptions;

this.imageFilterMap = {};
this.currentZoom = this.mapService.getZoom();
this.extent = this.imageExtent();

const { width, height } = this.rendererService.getViewportSize();
const collisionIndex = new CollisionIndex(width, height);
const data = this.layer.getEncodedData();

data.forEach((feature: IEncodeFeature) => {
const { id = 0, size = 5 } = feature;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

此处为 id 设置默认值 0 可能会导致问题。如果多个 feature 的 id 都是 undefined,它们都会被赋予 id = 0。这会导致在 imageFilterMapcollisionIndex 中互相覆盖,使得只有一个 id0 的 feature 能够被正确处理,而其他没有 id 的 feature 会被错误地隐藏或显示。

建议修改此行,并在下一行添加对 id 是否为 undefined 的检查,如果是则跳过当前 feature:

if (id === undefined) {
  // console.warn('Feature without id is skipped in collision detection.');
  return;
}
Suggested change
const { id = 0, size = 5 } = feature;
const { id, size = 5 } = feature;

const iconSize = Array.isArray(size) ? size[0] : (size as number);
const centroid = calculateCentroid(feature.coordinates) as [number, number];
const pixels = this.mapService.lngLatToContainer(centroid);

const { box } = collisionIndex.placeCollisionBox({
x1: -iconSize - padding[0],
x2: iconSize + padding[0],
y1: -iconSize - padding[1],
y2: iconSize + padding[1],
anchorPointX: pixels.x,
anchorPointY: pixels.y,
});

if (box && box.length) {
collisionIndex.insertCollisionBox(box, id as number);
this.imageFilterMap![id as number] = true;
}
});
}

private imageExtent(): [[number, number], [number, number]] {
const bounds = this.mapService.getBounds();
return padBounds(bounds, 0.5);
}

private async reBuildModel() {
const { allowOverlap = true } = this.layer.getLayerConfig() as IPointLayerStyleOptions;
if (!allowOverlap) {
this.filterImages();
} else {
this.imageFilterMap = null; // 允许压盖:清空过滤表,全量渲染
}
const model = await this.layer.buildLayerModel({
moduleName: 'pointImage',
vertexShader: pointImageVert,
fragmentShader: pointImageFrag,
triangulation: PointImageTriangulation.bind(this),
defines: this.getDefines(),
inject: this.getInject(),
depth: { enable: false },
primitive: gl.POINTS,
});
this.layer.models = [model];
}
Comment on lines +223 to +241

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

此方法 reBuildModelbuildModels 方法存在大量重复代码。为了提高代码的可维护性,建议提取一个私有的辅助方法来封装公共的模型创建逻辑,以减少重复并使代码更清晰。

例如,可以创建一个 _buildModel 方法:

private async _buildModel(): Promise<IModel> {
  const { allowOverlap = true } = this.layer.getLayerConfig() as IPointLayerStyleOptions;
  if (!allowOverlap) {
    this.filterImages();
  } else {
    this.imageFilterMap = null;
  }
  return await this.layer.buildLayerModel({
    moduleName: 'pointImage',
    vertexShader: pointImageVert,
    fragmentShader: pointImageFrag,
    triangulation: PointImageTriangulation.bind(this),
    defines: this.getDefines(),
    inject: this.getInject(),
    depth: { enable: false },
    primitive: gl.POINTS,
  });
}

然后 buildModelsreBuildModel 可以简化为:

public async buildModels(): Promise<IModel[]> {
  this.initUniformsBuffer();
  const model = await this._buildModel();
  return [model];
}

private async reBuildModel() {
  const model = await this._buildModel();
  this.layer.models = [model];
}


private updateTexture = () => {
const { createTexture2D } = this.rendererService;
if (this.texture) {
Expand Down
29 changes: 23 additions & 6 deletions site/docs/api/point_layer/style.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,14 @@ const font2 = 'Times New Roman';

- [icon](/api/point_layer/shape#shapeiconname-string)

| style | type | describe | data mapping | default value |
| ------------- | ------------------ | ------------------------------------------ | ------------ | ------------- |
| offsets | `[number, number]` | point offset | no | `[0, 0]` |
| raisingHeight | `number` | Lifting height | no | `0` |
| heightfixed | `boolean` | Does the lifting height vary?`zoom`Variety | no | `false` |
| rotation | `number` | Rotation angle | yes | `0` |
| style | type | describe | data mapping | default value |
| ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------- | ------------ | ------------- |
| offsets | `[number, number]` | point offset | no | `[0, 0]` |
| raisingHeight | `number` | Lifting height | no | `0` |
| heightfixed | `boolean` | Does the lifting height vary with `zoom` | no | `false` |
| rotation | `number` | Rotation angle | yes | `0` |
| allowOverlap | `boolean` | Whether icons are allowed to overlap. When `false`, collision detection is enabled and overlapping icons are automatically hidden | no | `true` |
| padding | `[number, number]` | Extra margin `[horizontal, vertical]` applied to the collision bounding box. Only takes effect when `allowOverlap: false` | no | `[0, 0]` |

#### rotation

Expand All @@ -196,6 +198,21 @@ const imageLayer = new PointLayer({ layerType: 'fillImage' })
});
```

#### allowOverlap

Controls whether icons on screen are allowed to overlap each other. When set to `false`, L7 runs collision detection across all icons after every map zoom or pan, and automatically hides any icon that would overlap one already placed. The behavior mirrors `textAllowOverlap` on text layers.

```js
const imageLayer = new PointLayer()
.source(data, { parser: { type: 'json', x: 'lng', y: 'lat' } })
.shape('icon')
.size(15)
.style({
allowOverlap: false, // enable collision avoidance
padding: [4, 4], // keep a 4px gap between icons
});
```

### radar

`shape`is a radar chart.
Expand Down
29 changes: 23 additions & 6 deletions site/docs/api/point_layer/style.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,12 +170,14 @@ const font2 = 'Times New Roman';

- [icon](/api/point_layer/shape#shapeiconname-string)

| style | 类型 | 描述 | 数据映射 | 默认值 |
| ------------- | ------------------ | -------------------------- | -------- | -------- |
| offsets | `[number, number]` | 点偏移 | 否 | `[0, 0]` |
| raisingHeight | `number` | 抬升高度 | 否 | `0` |
| heightfixed | `boolean` | 抬升高度是否随 `zoom` 变化 | 否 | `false` |
| rotation | `number` | 旋转角度 | 是 | `0` |
| style | 类型 | 描述 | 数据映射 | 默认值 |
| ------------- | ------------------ | -------------------------------------------------------------------------- | -------- | -------- |
| offsets | `[number, number]` | 点偏移 | 否 | `[0, 0]` |
| raisingHeight | `number` | 抬升高度 | 否 | `0` |
| heightfixed | `boolean` | 抬升高度是否随 `zoom` 变化 | 否 | `false` |
| rotation | `number` | 旋转角度 | 是 | `0` |
| allowOverlap | `boolean` | 图标是否允许压盖,`false` 时开启碰撞避让自动隐藏重叠图标 | 否 | `true` |
| padding | `[number, number]` | 碰撞检测包围盒的额外边距 `[水平, 垂直]`,仅在 `allowOverlap: false` 时生效 | 否 | `[0, 0]` |

#### rotation

Expand All @@ -196,6 +198,21 @@ const imageLayer = new PointLayer({ layerType: 'fillImage' })
});
```

#### allowOverlap

控制图标在屏幕上是否允许相互压盖。设置为 `false` 时,L7 会在每次地图缩放或平移后对所有图标执行碰撞检测,自动隐藏与已放置图标重叠的图标,效果与 `textAllowOverlap` 一致。

```js
const imageLayer = new PointLayer()
.source(data, { parser: { type: 'json', x: 'lng', y: 'lat' } })
.shape('icon')
.size(15)
.style({
allowOverlap: false, // 开启碰撞避让
padding: [4, 4], // 图标之间保留 4px 间距
});
```

### radar

`shape` 为雷达图。
Expand Down
74 changes: 74 additions & 0 deletions site/examples/point/image/demo/allowOverlap.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { PointLayer, Scene } from '@antv/l7';
import { GaodeMap } from '@antv/l7-maps';

const scene = new Scene({
id: 'map',
map: new GaodeMap({
style: 'light',
center: [104.0, 36.0],
zoom: 4,
}),
});

scene.addImage(
'marker',
'https://gw.alipayobjects.com/zos/basement_prod/604b5e7f-309e-40db-b95b-4fac746c5153.svg',
);

// 在中国范围内生成密集随机点,使压盖效果明显
function generateData(count) {
const points = [];
for (let i = 0; i < count; i++) {
points.push({
lng: 99 + Math.random() * 20,
lat: 25 + Math.random() * 18,
id: i,
});
}
return points;
}

scene.on('loaded', () => {
const data = generateData(300);

const layer = new PointLayer()
.source(data, {
parser: { type: 'json', x: 'lng', y: 'lat' },
})
.shape('marker')
.size(15)
.style({
allowOverlap: false, // 默认关闭压盖,开启碰撞避让
});

scene.addLayer(layer);

// 控制面板
const panel = document.createElement('div');
panel.style.cssText =
'position:absolute;top:10px;left:10px;z-index:100;background:#fff;padding:12px 16px;border-radius:4px;box-shadow:0 2px 8px rgba(0,0,0,.15);font-size:13px;';

panel.innerHTML = `
<div style="margin-bottom:10px;font-weight:600;">Image allowOverlap</div>
<label style="display:flex;align-items:center;gap:8px;cursor:pointer;">
<input type="checkbox" id="overlap-toggle" />
<span id="overlap-label">allowOverlap: false(碰撞避让开启)</span>
</label>
<div style="margin-top:8px;color:#888;font-size:12px;">
点数量:300 &nbsp;|&nbsp; 缩放地图可触发重新计算
</div>
`;

document.getElementById('map').appendChild(panel);

const checkbox = document.getElementById('overlap-toggle');
const label = document.getElementById('overlap-label');

checkbox.addEventListener('change', () => {
const allowOverlap = checkbox.checked;
label.textContent = allowOverlap
? 'allowOverlap: true(允许压盖,全部显示)'
: 'allowOverlap: false(碰撞避让开启)';
layer.style({ allowOverlap });
});
});
Loading
Loading