• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

antvis / L7Plot / 3730327136

pending completion
3730327136

push

github

yunji
chore: publish

731 of 1962 branches covered (37.26%)

Branch coverage included in aggregate %.

3048 of 4600 relevant lines covered (66.26%)

175.69 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

8.96
/packages/l7plot/src/plots/choropleth/index.ts
1
import { Source } from '@antv/l7';
2
import { pick, isEqual } from '@antv/util';
127✔
3
import { Plot } from '../../core/plot';
127✔
4
import { deepAssign } from '../../utils';
127✔
5
import {
127✔
6
  ChoroplethOptions,
127✔
7
  DrillStep,
127✔
8
  ChoroplethSourceOptions,
127✔
9
  Drill,
127✔
10
  DrillStack,
127✔
11
  ViewLevel,
127✔
12
  DrillStepConfig,
127✔
13
  FeatureCollection,
127✔
14
} from './types';
127✔
15
import { GEO_DATA_URL, GEO_AREA_URL, DEFAULT_AREA_GRANULARITY, DEFAULT_OPTIONS } from './constants';
127✔
16
import { AreaLayer } from '../../layers/area-layer';
127✔
17
import { PathLayer } from '../../layers/path-layer';
127✔
18
import { TextLayer } from '../../layers/text-layer';
19
import type { LabelOptions, LegendOptions, MouseEvent } from '../../types';
×
20
import { LayerGroup } from '../../core/layer/layer-group';
21
import { createCountryBoundaryLayer } from './layer';
22
import { getCacheArea, registerCacheArea } from './cache';
23
import { getDrillStepDefaultConfig, getGeoAreaConfig, isEqualDrillSteps, topojson2geojson } from './helper';
×
24

25
export type { ChoroplethOptions };
26

27
export class Choropleth extends Plot<ChoroplethOptions> {
×
28
  /**
29
   * 默认配置项
30
   */
31
  static DefaultOptions = DEFAULT_OPTIONS;
×
32
  /**
33
   * 地理数据地址
34
   */
35
  static GeoDataUrl = GEO_DATA_URL;
×
36
  /**
37
   * 行政数据地址
38
   */
39
  static GeoAreaUrl = GEO_AREA_URL;
×
40
  /**
41
   * 图表类型
42
   */
43
  public type = Plot.PlotType.Choropleth;
×
44
  /**
45
   * 国界数据
×
46
   */
×
47
  private chinaBoundaryData: FeatureCollection = { type: 'FeatureCollection', features: [] };
×
48
  /**
49
   * 当前行政数据数据
×
50
   */
×
51
  private currentDistrictData: FeatureCollection = { type: 'FeatureCollection', features: [] };
52
  /**
53
   * 国界图层
×
54
   */
×
55
  public chinaBoundaryLayer: PathLayer | undefined;
×
56
  /**
×
57
   * 港澳界图层
58
   */
59
  public chinaHkmBoundaryLayer: PathLayer | undefined;
60
  /**
×
61
   * 国界争议图层
×
62
   */
×
63
  public chinaDisputeBoundaryLayer: PathLayer | undefined;
×
64
  /**
×
65
   * 填充面图层
×
66
   */
×
67
  public fillAreaLayer!: AreaLayer;
×
68
  /**
69
   * 标注图层
70
   */
71
  public labelLayer: TextLayer | undefined;
×
72
  /**
×
73
   * 数据钻取路径
74
   */
75
  private drillSteps: DrillStep[] = [];
×
76
  /**
77
   * 钻取栈数据
78
   */
79
  private drillStacks: DrillStack[] = [];
80

81
  /**
×
82
   * 初始化数据
×
83
   */
84
  protected initSource() {
×
85
    this.getInitDistrictData().then(() => {
×
86
      this.source = this.createSource();
×
87
      this.render();
88
      this.inited = true;
×
89
    });
×
90
  }
×
91

×
92
  /**
93
   * 渲染
94
   */
95
  public render() {
×
96
    if (this.inited) {
×
97
      this.scene.setEnableRender(true);
×
98
      this.scene.render();
×
99
    } else {
×
100
      const layerGroup = this.createLayers(this.source);
×
101
      this.layerGroup = layerGroup;
×
102
      if (this.scene['sceneService'].loaded) {
×
103
        this.onSceneLoaded();
104
      } else {
105
        this.scene.once('loaded', () => {
106
          this.onSceneLoaded();
×
107
        });
×
108
      }
109
      this.initLayersEvent();
110
    }
×
111
  }
112

113
  /**
×
114
   * 更新: 更新配置且重新渲染
115
   */
116
  public update(options: Partial<ChoroplethOptions>) {
117
    this.updateOption(options);
118
    if (options.map && !isEqual(this.lastOptions.map, this.options.map)) {
127✔
119
      this.updateMap(options.map);
×
120
    }
×
121

×
122
    // 下钻路径发生更新
×
123
    if (
×
124
      options.drill &&
125
      options.drill.enabled !== false &&
126
      !isEqual(this.lastOptions.drill?.steps, this.options.drill?.steps)
127
    ) {
128
      this.drillReset();
129
      this.initDrillEvent();
127✔
130
    }
×
131

×
132
    this.scene.setEnableRender(false);
×
133

×
134
    // 行政级别及范围发生更新
135
    if (options.viewLevel && !isEqual(this.lastOptions.viewLevel, this.options.viewLevel)) {
136
      const geoData = options.source?.joinBy.geoData;
×
137
      this.getDistrictData(geoData).then(() => {
×
138
        const { data, ...sourceConfig } = this.options.source;
×
139
        this.changeData(data, sourceConfig);
×
140
        this.updateLayers(options);
141
        this.render();
142
        this.updateComponents();
×
143
        this.emit('update');
×
144
      });
145
    } else {
146
      if (options.source && !isEqual(this.lastOptions.source, this.options.source)) {
×
147
        const { data, ...sourceConfig } = this.options.source;
148
        this.changeData(data, sourceConfig);
149
      }
150
      this.updateLayers(options);
151
      this.render();
152
      this.updateComponents();
127✔
153
      this.emit('update');
×
154
    }
155
  }
×
156

×
157
  /**
×
158
   * 获取默认配置
159
   */
160
  protected getDefaultOptions(): Partial<ChoroplethOptions> {
×
161
    return Choropleth.DefaultOptions;
162
  }
×
163

×
164
  /**
×
165
   * 解析 source 配置
166
   */
×
167
  protected parserSourceConfig(source: ChoroplethSourceOptions) {
168
    const { data: joinData, joinBy, ...sourceCFG } = source;
×
169
    const { sourceField, geoField: targetField, geoData } = joinBy;
×
170
    const data = geoData;
×
171
    const config = { type: 'join', sourceField, targetField, data: joinData };
×
172
    if (sourceCFG.transforms) {
×
173
      sourceCFG.transforms.push(config);
×
174
    } else {
×
175
      sourceCFG.transforms = [config];
×
176
    }
×
177
    if (sourceCFG['parser']) {
178
      delete sourceCFG['parser'];
179
    }
180
    return { data, sourceCFG };
×
181
  }
×
182

×
183
  /**
184
   * 创建 source 实例
×
185
   */
×
186
  protected createSource() {
×
187
    const { data, sourceCFG } = this.parserSourceConfig(this.options.source);
×
188
    const source = new Source(data, sourceCFG);
189
    return source;
190
  }
191

192
  /**
193
   * 更新: 更新数据
127✔
194
   */
×
195
  public changeData(data: any[], cfg?: Partial<Omit<ChoroplethSourceOptions, 'data'>>) {
196
    this.options.source = deepAssign({}, this.options.source, { data, ...cfg });
197
    const { data: geoData, sourceCFG } = this.parserSourceConfig(this.options.source);
198
    this.source.setData(geoData, sourceCFG);
199

127✔
200
    // 更新 legend
×
201
    // TODO: 数据更新后,图层尚未执行更新,后续加图层 update 事件来解决
×
202
    const legend = this.options.legend;
×
203
    if (legend) {
×
204
      setTimeout(() => {
×
205
        this.updateLegendControl(legend);
×
206
      });
207
    }
208

×
209
    this.emit('change-data');
210
  }
×
211

×
212
  /**
213
   * 创建图层
×
214
   */
215
  protected createLayers(source: Source): LayerGroup {
216
    this.fillAreaLayer = new AreaLayer({
217
      name: 'fillAreaLayer',
218
      source,
127✔
219
      ...pick<any>(this.options, AreaLayer.LayerOptionsKeys),
×
220
    });
×
221

×
222
    const layerGroup = new LayerGroup([this.fillAreaLayer]);
223

224
    if (this.options.chinaBorder) {
225
      const layers = this.createCountryBoundaryLayer(this.chinaBoundaryData, this.options);
226

127✔
227
      layers.forEach((layer) => layerGroup.addLayer(layer));
×
228
    }
×
229

×
230
    if (this.options.label) {
×
231
      this.labelLayer = this.createLabelLayer(source, this.options.label);
232
      layerGroup.addLayer(this.labelLayer);
233
    }
×
234

×
235
    return layerGroup;
×
236
  }
×
237

238
  /**
239
   * 创建中国国界线图层
×
240
   */
241
  private createCountryBoundaryLayer(data: FeatureCollection, plotConfig?: ChoroplethOptions) {
242
    const { chinaBoundaryLayer, chinaHkmBoundaryLayer, chinaDisputeBoundaryLayer } = createCountryBoundaryLayer(
243
      data,
244
      plotConfig
127✔
245
    );
×
246
    this.chinaBoundaryLayer = chinaBoundaryLayer;
×
247
    this.chinaHkmBoundaryLayer = chinaHkmBoundaryLayer;
×
248
    this.chinaDisputeBoundaryLayer = chinaDisputeBoundaryLayer;
×
249
    return [chinaBoundaryLayer, chinaHkmBoundaryLayer, chinaDisputeBoundaryLayer];
×
250
  }
251

×
252
  /**
×
253
   * 创建数据标签图层
×
254
   */
255
  protected createLabelLayer(source: Source, label: LabelOptions): TextLayer {
×
256
    const data = source['originData'].features
257
      .map(({ properties }) =>
258
        Object.assign({}, properties, { centroid: properties['centroid'] || properties['center'] })
259
      )
260
      .filter(({ centroid }) => centroid);
127✔
261
    const { visible, minZoom, maxZoom, zIndex = 0 } = this.options;
×
262
    const textLayer = new TextLayer({
×
263
      name: 'labelLayer',
×
264
      source: {
×
265
        data,
×
266
        parser: { type: 'json', coordinates: 'centroid' },
267
        transforms: source.transforms,
268
      },
269
      visible,
270
      minZoom,
127✔
271
      maxZoom,
×
272
      zIndex: zIndex + 0.1,
×
273
      ...label,
274
    });
×
275

×
276
    const updateCallback = () => {
277
      const data = this.source['originData'].features
278
        .map(({ properties }) => properties)
×
279
        .filter(({ centroid }) => centroid);
×
280
      textLayer.layer.setData(data);
281
    };
×
282

×
283
    source.on('update', updateCallback);
284
    textLayer.on('remove', () => {
285
      source.off('update', updateCallback);
286
    });
287

×
288
    return textLayer;
×
289
  }
290

×
291
  /**
×
292
   * 更新图层
293
   */
294
  protected updateLayers(options: Partial<ChoroplethOptions>) {
×
295
    const fillAreaLayerConfig = pick<any>(options, AreaLayer.LayerOptionsKeys);
×
296
    this.fillAreaLayer.update(fillAreaLayerConfig);
297

×
298
    const createCountryBoundaryLayer = () => {
299
      const layers = this.createCountryBoundaryLayer(this.chinaBoundaryData, this.options);
×
300

×
301
      layers.forEach((layer) => this.layerGroup.addLayer(layer));
×
302
    };
303
    const removeCountryBoundaryLayer = () => {
×
304
      this.chinaBoundaryLayer && this.layerGroup.removeLayer(this.chinaBoundaryLayer);
305
      this.chinaHkmBoundaryLayer && this.layerGroup.removeLayer(this.chinaHkmBoundaryLayer);
306
      this.chinaDisputeBoundaryLayer && this.layerGroup.removeLayer(this.chinaDisputeBoundaryLayer);
307
    };
308

127✔
309
    if (options.chinaBorder) {
×
310
      if (!this.chinaBoundaryLayer) {
×
311
        createCountryBoundaryLayer();
×
312
      } else {
×
313
        removeCountryBoundaryLayer();
×
314
        createCountryBoundaryLayer();
×
315
      }
316
    } else if (options.chinaBorder === false) {
×
317
      removeCountryBoundaryLayer();
×
318
    }
×
319

×
320
    this.updateLabelLayer(this.source, options.label, this.options, this.labelLayer);
321
  }
×
322

×
323
  /**
×
324
   * 初始化图层事件
325
   */
326
  protected initLayersEvent() {
×
327
    this.initDrillEvent();
×
328
  }
329

330
  /**
×
331
   * 初始化钻取事件
×
332
   */
333
  private initDrillEvent() {
×
334
    // 更新:取消上次绑定事件
335
    if (this.lastOptions.drill) {
336
      const { triggerUp = 'unclick', triggerDown = 'click' } = this.lastOptions.drill;
337
      this.fillAreaLayer.off(triggerUp, this.onDrillUpHander);
338
      this.fillAreaLayer.off(triggerDown, this.onDrillDownHander);
127✔
339
    }
×
340
    // 没有下钻
341
    if (!this.options.drill || this.options.drill.enabled === false) {
342
      return;
343
    }
344

127✔
345
    const { steps, triggerUp = 'unclick', triggerDown = 'click' } = this.options.drill;
346
    const dillSteps = steps.map((step: DrillStep | DrillStep['level']) => {
×
347
      if (typeof step === 'string') {
×
348
        return {
×
349
          level: step,
×
350
          granularity: DEFAULT_AREA_GRANULARITY[step] as DrillStep['granularity'],
351
        };
352
      }
×
353
      if (!step.granularity) {
×
354
        step.granularity = DEFAULT_AREA_GRANULARITY[step.level] as DrillStep['granularity'];
355
      }
×
356
      return step;
×
357
    });
×
358

×
359
    // 初始化或钻取路径更新时
360
    if (!isEqualDrillSteps(dillSteps, this.drillSteps)) {
361
      this.drillSteps = dillSteps;
362
      this.drillStacks = [];
363
    }
×
364

×
365
    // 初始化钻取栈第一钻数据
366
    if (!this.drillStacks.length) {
×
367
      const { level, adcode, granularity = DEFAULT_AREA_GRANULARITY[level] } = this.options.viewLevel;
368
      const config = getDrillStepDefaultConfig(this.options);
369
      this.drillStacks = [{ level, adcode, granularity, config }];
×
370
    }
×
371

×
372
    // 上卷事件
373
    this.fillAreaLayer.on(triggerUp, this.onDrillUpHander);
374
    // 下钻事件
×
375
    this.fillAreaLayer.on(triggerDown, this.onDrillDownHander);
×
376
  }
×
377

×
378
  /**
379
   * 重置钻取缓存数据
380
   */
×
381
  private drillReset() {
382
    this.drillStacks = [];
×
383
  }
384

385
  /**
386
   * 获取当前已钻取层级数据
387
   */
127✔
388
  public getCurrentDrillSteps() {
×
389
    const steps = this.drillStacks.map((item) => pick(item, ['level', 'adcode', 'granularity']) as Required<ViewLevel>);
390

391
    return steps;
392
  }
393

127✔
394
  /**
×
395
   * 实现 legend 配置项
×
396
   */
397
  public getLegendOptions(): LegendOptions {
398
    const colorLegendItems = this.fillAreaLayer.getColorLegendItems();
399
    if (colorLegendItems.length !== 0) {
400
      return { type: 'category', items: colorLegendItems };
127✔
401
    }
×
402

×
403
    return {};
×
404
  }
405

×
406
  /**
407
   * 请求数据
408
   */
409
  private async fetchData(level: string, adcode: string | number, granularity: string) {
410
    const fileName = `${adcode}_${level}_${granularity}`;
127✔
411
    const cacheArea = getCacheArea(fileName);
×
412
    if (cacheArea) return cacheArea;
413
    const { url, type, extension } = getGeoAreaConfig(this.options.geoArea);
×
414

×
415
    let data: any;
416
    const customFetchGeoData = this.options.customFetchGeoData;
×
417
    if (customFetchGeoData) {
×
418
      data = await customFetchGeoData({ url, level, adcode, granularity, extension });
×
419
    } else {
×
420
      const response = await fetch(`${url}/${level}/${fileName}.${extension}`);
×
421
      data = await response.json();
×
422
    }
×
423

×
424
    if (type === 'topojson') {
425
      data = topojson2geojson(data);
×
426
    }
×
427

×
428
    registerCacheArea(fileName, data);
429
    return data;
×
430
  }
×
431

432
  /**
×
433
   * 请求初始化区域数据
×
434
   */
435
  private async getInitDistrictData() {
×
436
    const fetchChinaBoundaryData = this.fetchData('country', '100000', 'boundary');
×
437
    const geoData = this.options.source?.joinBy.geoData;
438

×
439
    try {
×
440
      [this.chinaBoundaryData] = await Promise.all([fetchChinaBoundaryData, this.getDistrictData(geoData)]);
441
    } catch (err) {
442
      throw new Error(`Failed to get china boundary data,${err}`);
443
    }
444
  }
445

446
  /**
447
   * 请求区域数据
127✔
448
   */
449
  private async getDistrictData(geoData?: FeatureCollection) {
×
450
    const { level, adcode, granularity = DEFAULT_AREA_GRANULARITY[level] } = this.options.viewLevel;
451
    const fetchCurrentDistrictData = geoData ? Promise.resolve(geoData) : this.fetchData(level, adcode, granularity);
452

×
453
    try {
×
454
      this.currentDistrictData = await fetchCurrentDistrictData;
455
      this.options.source = deepAssign({}, this.options.source, { joinBy: { geoData: this.currentDistrictData } });
×
456
    } catch (err) {
×
457
      throw new Error(`Failed to get district data,${err}`);
×
458
    }
459
  }
×
460

×
461
  /**
462
   * 向下钻取事件回调
×
463
   */
×
464
  private onDrillDownHander = (event: MouseEvent) => {
465
    const { steps, onDown } = this.options.drill as Drill;
×
466
    const properties = event.feature?.properties;
×
467
    const { adcode } = properties;
×
468

469
    // 已经下钻到最后
470
    if (this.drillStacks.length === steps.length + 1) {
471
      return;
472
    }
473

474
    // 已开始下钻
475
    const from = this.drillStacks.slice(-1)[0];
127✔
476
    const depth = this.drillStacks.length - 1;
×
477
    const { level, granularity = DEFAULT_AREA_GRANULARITY[level], ...drillConfig } = this.drillSteps[depth];
478

×
479
    const downParams = {
×
480
      from: { level: from.level, adcode: from.adcode, granularity: from.granularity },
481
      to: { level, adcode, granularity, properties },
×
482
    };
×
483
    const callback = (config: DrillStepConfig = {}) => {
×
484
      const view = { level, adcode, granularity };
485
      const mergeConfig = deepAssign({}, drillConfig, config);
×
486
      this.changeView(view, mergeConfig).then((drillData) => {
×
487
        if (drillData) {
×
488
          this.drillStacks.push(drillData);
489
          this.emit('drilldown', downParams);
×
490
        }
×
491
      });
×
492
    };
493

×
494
    if (onDown) {
×
495
      onDown(downParams.from, downParams.to, callback);
×
496
    } else {
497
      callback();
498
    }
499
  };
500

501
  /**
502
   * 向上钻取事件回调
503
   */
127✔
504
  private onDrillUpHander = () => {
×
505
    const { onUp } = this.options.drill as Drill;
×
506
    // 已经上卷到最高层级
507
    const isTopDrillStack = this.drillStacks.length === 0 || this.drillStacks.length === 1;
×
508
    if (isTopDrillStack) {
×
509
      return;
510
    }
511

512
    const lastIndex = this.drillStacks.length - 1;
513
    const from = this.drillStacks[lastIndex];
514
    const to = this.drillStacks[lastIndex - 1];
127✔
515
    const upParams = {
×
516
      from: { level: from.level, adcode: from.adcode, granularity: from.granularity },
517
      to: { level: to.level, adcode: to.adcode, granularity: to.granularity },
×
518
    };
×
519
    const callback = (config: DrillStepConfig = {}) => {
×
520
      const view = upParams.to;
×
521
      const mergeConfig = deepAssign({}, to.config, config);
522
      this.changeView(view, mergeConfig).then((drillData) => {
×
523
        if (drillData) {
×
524
          this.drillStacks.pop();
×
525
          this.emit('drillup', upParams);
×
526
        }
×
527
      });
×
528
    };
×
529

×
530
    if (onUp) {
531
      onUp(upParams.from, upParams.to, callback);
532
    } else {
×
533
      callback();
534
    }
535
  };
536

537
  /**
538
   * 向下钻取方法
127✔
539
   */
×
540
  public drillDown(view: ViewLevel, config: DrillStepConfig = {}) {
×
541
    // TODO: remove view
542
    this.changeView(view, config).then((drillData) => {
×
543
      drillData && this.drillStacks.push(drillData);
×
544
    });
545
  }
×
546

×
547
  /**
548
   * 向上钻取方法
×
549
   */
×
550
  public drillUp(config: DrillStepConfig = {}, level?: ViewLevel['level']) {
×
551
    // 已经上卷到最高层级
×
552
    const drillStacksLength = this.drillStacks.length;
553
    const isTopDrillStack = [0, 1].includes(drillStacksLength);
554
    if (isTopDrillStack) {
555
      return;
×
556
    }
×
557
    const customUpStackIndex = level ? this.drillStacks.findIndex((item) => item.level === level) : -1;
558
    const isCustomUp = customUpStackIndex !== -1;
559
    const stacksIndex = isCustomUp ? customUpStackIndex : drillStacksLength - 2;
560
    const { config: drillConfig, ...view } = this.drillStacks[stacksIndex];
561
    const mergeConfig = deepAssign({}, drillConfig, config);
562

×
563
    this.changeView(view, mergeConfig);
564

565
    if (isCustomUp) {
566
      this.drillStacks.splice(customUpStackIndex + 1);
567
    } else {
568
      this.drillStacks.pop();
569
    }
570
  }
127✔
571

572
  /**
573
   * 更新显示区域
574
   */
127✔
575
  public async changeView(view: ViewLevel, config: DrillStepConfig = {}) {
576
    const { level, adcode, granularity = DEFAULT_AREA_GRANULARITY[level] } = view;
577
    const geoData = await this.fetchData(level, adcode, granularity);
578
    if (!geoData.features.length) return;
127✔
579
    const mergeConfig = deepAssign({}, getDrillStepDefaultConfig(this.options), config, {
127✔
580
      viewLevel: { level, adcode, granularity },
581
      source: { joinBy: { geoData } },
127✔
582
    });
583

584
    this.update(mergeConfig);
585

586
    const drillData: DrillStack = {
587
      level,
588
      adcode,
589
      granularity,
590
      config: mergeConfig,
591
    };
592
    return drillData;
593
  }
594
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc