第24篇 民族地图页——地图组件与民族分布可视化

在这里插入图片描述

一、页面设计思路

地图页的核心使命是:用可视化方式展示 56 个民族的地理分布。地图是民族文化的重要载体——傣族在云南、蒙古族在内蒙古、维吾尔族在新疆,地理环境塑造了每个民族独特的文化。

然而,真实地图组件(如 Map Kit)在纯 ArkTS 学习场景下有一定门槛,且很多开发者是在模拟器上运行,没有地图 SDK。因此我们采用了**“双视图策略”**:

  • 列表视图(默认):省份卡片 + 民族标签,数据驱动,兼容性好
  • 地图视图(可选):真实地图组件,按需加载,渐进增强

这种设计既保证了功能的完整性,又为后续接入真实地图留下了扩展空间。

二、数据结构设计

2.1 核心接口

地图页的数据模型围绕"省份-民族"映射展开:

interface ProvinceEthnicItem {
  province: string;       // 省份名称
  region: string;         // 所属地区(西南/北方/东北等)
  ethnics: EthnicGroup[]; // 该省份的民族列表
  ethnicCount: number;    // 民族数量(用于排序)
}

interface RegionOption {
  key: string;       // 地区标识
  zhLabel: string;   // 中文标签
  enLabel: string;   // 英文标签
}

为什么需要 ethnicCount 而不是直接用 ethnics.length?因为:

  1. 排序时频繁访问 .length 有微小性能开销
  2. 模板中直接使用预计算字段更清晰
  3. 后续如果要做缓存,字段独立更方便

2.2 地区划分策略

我们将全国省份划分为 5 大区域,符合中国地理文化分区常识:

private provinceRegionMap: Record<string, string> = {
  // 西南:云南、四川、贵州、重庆、西藏
  '云南': 'southwest', '四川': 'southwest', '贵州': 'southwest',
  '重庆': 'southwest', '西藏': 'southwest',
  // 北方:内蒙古、甘肃、宁夏、青海、河北、河南、北京、山西、陕西、天津
  '内蒙古': 'north', '甘肃': 'north', '宁夏': 'north',
  '青海': 'north', '河北': 'north', '河南': 'north',
  '北京': 'north', '山西': 'north', '陕西': 'north', '天津': 'north',
  // 东北:辽宁、吉林、黑龙江
  '辽宁': 'northeast', '吉林': 'northeast', '黑龙江': 'northeast',
  // 东南:福建、浙江、广东、广西、湖南、湖北、江西、安徽、海南、台湾、上海、江苏
  '福建': 'southeast', '浙江': 'southeast', '广东': 'southeast',
  '广西': 'southeast', '湖南': 'southeast', '湖北': 'southeast',
  '江西': 'southeast', '安徽': 'southeast', '海南': 'southeast',
  '台湾': 'southeast', '上海': 'southeast', '江苏': 'southeast',
  // 西北:新疆
  '新疆': 'northwest'
};

💡 设计思考:地区划分看似简单,实则是用户体验的关键。5 个分区不多不少——太多用户记不住,太少筛选意义不大。

三、数据构建算法

3.1 倒排索引构建

地图页的核心数据处理是:从"民族 → 省份"的正向关系,构建"省份 → 民族列表"的倒排索引

private buildProvinceMap(): void {
  const map: Record<string, Set<EthnicGroup>> = {};

  // 第一步:遍历所有民族,构建倒排索引
  for (const ethnic of ETHNIC_GROUPS) {
    for (const province of ethnic.provinces) {
      if (!map[province]) {
        map[province] = new Set<EthnicGroup>();
      }
      map[province].add(ethnic);
    }
  }

  // 第二步:转换为数组并按民族数量排序
  this.provinceEthnicMap = Object.entries(map)
    .map((entry: [string, Set<EthnicGroup>]): ProvinceEthnicItem => {
      return {
        province: entry[0],
        region: this.provinceRegionMap[entry[0]] || 'other',
        ethnics: Array.from(entry[1]).sort(
          (a, b) => a.populationRank - b.populationRank
        ),
        ethnicCount: entry[1].size
      };
    })
    .sort((a, b) => b.ethnicCount - a.ethnicCount);

  // 第三步:同步更新统计数据
  this.statsTotalProvinces = this.provinceEthnicMap.length;
  let max = 0;
  for (let i = 0; i < this.provinceEthnicMap.length; i++) {
    if (this.provinceEthnicMap[i].ethnicCount > max) {
      max = this.provinceEthnicMap[i].ethnicCount;
    }
  }
  this.statsMaxEthnics = max;
}

3.2 算法解析

步骤数据结构时间复杂度说明
倒排构建Record<string, Set>O(N × M)N=民族数,M=平均省份数
转换排序Array<ProvinceEthnicItem>O(K log K)K=省份数
统计计算遍历数组O(K)找最大值

为什么用 Set 而不是 Array?

  1. 自动去重:虽然理论上不会重复,但 Set 天然保证唯一性
  2. 语义明确:集合就是"不重复的元素",代码更自文档化
  3. 添加性能:Set 的 add 操作是 O(1) 均摊

排序策略的选择

  • 省份按民族数量降序排列——云南省(26 个民族)排第一,符合用户预期
  • 每个省份内的民族按人口排名排列——人口多的民族在前,信息层级合理

四、统计概览组件

页面顶部的"数据概览"是地图页的信息仪表盘:

@Builder
buildStatsOverview(): void {
  Row({ space: 16 }) {
    // 总省份数
    Column({ space: 4 }) {
      Text(String(this.statsTotalProvinces))
        .fontSize($r('app.float.font_size_xl'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.primary_color'))
      Text($r('app.string.map_provinces_label'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
    }

    // 最多元省份(民族数量)
    Column({ space: 4 }) {
      Text(String(this.statsMaxEthnics))
        .fontSize($r('app.float.font_size_xl'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.accent_color') || '#E74C3C')
      Text($r('app.string.map_max_groups'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
    }

    // 总民族数
    Column({ space: 4 }) {
      Text(String(ETHNIC_GROUPS.length))
        .fontSize($r('app.float.font_size_xl'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.legendary_color') || '#F39C12')
      Text($r('app.string.map_groups_label'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.text_hint'))
    }
  }
  .width('100%')
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .padding({ top: $r('app.float.spacing_md'), bottom: $r('app.float.spacing_sm') })
}

设计要点

  1. 三栏等分布局:用 Row({ space: 16 }) + 自然宽度,简洁明了
  2. 颜色语义化
    • 蓝色(primary):省份数——基础信息
    • 红色(accent):最多少数民族数——强调多样性
    • 金色(legendary):总民族数——标志性数据
  3. 数据层级:数字大、标签小,信息主次分明

五、搜索与筛选

5.1 地区筛选器

横向滚动的地区标签,和列表页的筛选器模式一致:

@Builder
buildRegionFilter(): void {
  Scroll() {
    Row({ space: $r('app.float.spacing_sm') }) {
      ForEach(this.regionOptions as Array<RegionOption>, (option: RegionOption) => {
        Text(this.getLocalizedText(option.zhLabel, option.enLabel))
          .fontSize($r('app.float.font_size_xs'))
          .fontColor(this.selectedRegion === option.key
            ? $r('app.color.text_on_primary')
            : $r('app.color.text_secondary'))
          .padding({ left: 12, right: 12, top: 6, bottom: 6 })
          .borderRadius(14)
          .backgroundColor(this.selectedRegion === option.key
            ? $r('app.color.primary_color')
            : $r('app.color.card_background'))
          .onClick(() => {
            this.selectedRegion = option.key;
          })
      })
    }
  }
  .scrollBar(BarState.Off)
  .width('100%')
  .height(36)
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .margin({ top: $r('app.float.spacing_sm') })
}

5.2 搜索过滤逻辑

搜索支持两种维度:省份名称民族名称

private getFilteredList(): Array<ProvinceEthnicItem> {
  let result = this.provinceEthnicMap;

  // 地区筛选
  if (this.selectedRegion !== 'all') {
    result = result.filter(item => item.region === this.selectedRegion);
  }

  // 搜索过滤(省份名 + 民族名/英文名)
  if (this.searchText.trim().length > 0) {
    const query = this.searchText.trim().toLowerCase();
    result = result.filter(item =>
      item.province.includes(query) ||
      item.ethnics.some(e =>
        e.name.toLowerCase().includes(query) ||
        e.nameEn.toLowerCase().includes(query)
      )
    );
  }

  return result;
}

搜索逻辑的设计考量

搜索维度场景示例实现方式
省份名输入"云南"item.province.includes(query)
民族中文名输入"傣族"e.name.includes(query)
民族英文名输入"Dai"e.nameEn.includes(query)

注意使用 Array.some() 而非 Array.every()——只要有一个民族匹配,整个省份卡片就应该显示。这是搜索体验的细节:用户搜"傣族",应该看到云南省卡片(里面有傣族),而不是什么都看不到。

六、省份卡片设计

6.1 卡片结构

每个省份卡片是一个"标题 + 标签云"的组合:

@Builder
buildProvinceCard(item: ProvinceEthnicItem): void {
  Column({ space: $r('app.float.spacing_sm') }) {
    // 省份标题行
    Row() {
      Text('\uD83C\uDFD9\u{FE0F}')  // 🛐 地图定位图标
        .fontSize($r('app.float.icon_size_sm'))
      Text(item.province)
        .fontSize($r('app.float.font_size_md'))
        .fontWeight(FontWeight.Bold)
        .fontColor($r('app.color.text_primary'))
      Blank()
      // 民族数量徽章
      Text(String(item.ethnicCount) + this.getLocalizedText('个民族', ' groups'))
        .fontSize($r('app.float.font_size_xs'))
        .fontColor($r('app.color.primary_color'))
        .padding({ left: 8, right: 8, top: 2, bottom: 2 })
        .borderRadius(10)
        .backgroundColor('#E8F0FE')
    }
    .width('100%')

    // 民族标签网格
    Flex({ wrap: FlexWrap.Wrap }) {
      ForEach(item.ethnics as Array<EthnicGroup>, (ethnic: EthnicGroup) => {
        Row({ space: 4 }) {
          // 首字圆形头像
          Text(ethnic.name.charAt(0))
            .fontSize($r('app.float.font_size_xs'))
            .fontWeight(FontWeight.Bold)
            .fontColor('#FFFFFF')
            .width(18)
            .height(18)
            .borderRadius(9)
            .backgroundColor(ethnic.emblemColor)
            .textAlign(TextAlign.Center)
          Text(this.getLocalizedText(ethnic.name, ethnic.nameEn))
            .fontSize($r('app.float.font_size_xs'))
            .fontColor($r('app.color.text_secondary'))
        }
        .padding({ left: 8, right: 8, top: 5, bottom: 5 })
        .margin({ top: 4, right: 6 })
        .borderRadius(14)
        .backgroundColor($r('app.color.background_color'))
        .onClick(() => {
          router.pushUrl({
            url: 'pages/EthnicDetailPage',
            params: { ethnicId: ethnic.id }
          });
        })
      })
    }
    .width('100%')
  }
  .width('90%')
  .padding($r('app.float.spacing_md'))
  .backgroundColor($r('app.color.card_background'))
  .borderRadius($r('app.float.radius_lg'))
  .alignItems(HorizontalAlign.Start)
}

6.2 标签云的视觉设计

民族标签采用了"小头像 + 名称"的胶囊样式:

  • 圆形头像:取民族名称首字,背景用民族主题色(emblemColor
  • 文字标签:灰色背景,次级文字颜色
  • 整体造型:圆角 14vp 的胶囊形状

这种设计比纯文字标签更有辨识度——每个民族的主题色就是它的视觉标识,用户扫一眼标签云就能感受到"多彩"的氛围。

6.3 点击交互

每个民族标签都是可点击的,点击后跳转到对应民族的详情页。这是地图页 → 详情页的信息流转路径。

注意 router.pushUrl 的错误处理:

router.pushUrl({ ... }).catch((err: Error) => {
  console.error('[MapPage] navigate to detail failed:', JSON.stringify(err));
});

即使是简单的跳转,也需要捕获异常。在低内存设备上,页面跳转可能失败,优雅降级是必备素养。

七、视图切换设计

7.1 双视图架构

@State useRealMap: boolean = false;  // 真实地图/列表视图切换

// build 方法中的核心逻辑
if (this.useRealMap) {
  // 真实地图视图
  EthnicMapComponent({
    onProvinceClick: (provinceName: string, ethnics: EthnicGroup[]) => {
      hilog.info(0x0001, 'MapPage', `Map province: ${provinceName}`);
    }
  })
    .layoutWeight(1)
    .width('100%')
} else {
  // 省份列表视图
  Scroll() {
    Column({ space: $r('app.float.spacing_md') }) {
      ForEach(this.getFilteredList() as Array<ProvinceEthnicItem>, (item: ProvinceEthnicItem) => {
        this.buildProvinceCard(item)
      })
    }
  }
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.Spring)
  .layoutWeight(1)
  .width('100%')
}

7.2 切换按钮

@Builder
buildViewToggle(): void {
  Row({ space: 8 }) {
    // 列表模式按钮
    Text('\u{1F4CA}')  // 📊 图表图标
      .fontSize(16)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(14)
      .backgroundColor(!this.useRealMap ? '#E8F0FE' : '#F5F5F5')
      .fontColor(!this.useRealMap ? '#1677FF' : '#888888')
      .onClick(() => { this.useRealMap = false; })

    // 地图模式按钮
    Text('\u{1F5FA}')  // 🗺️ 地图图标
      .fontSize(16)
      .padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(14)
      .backgroundColor(this.useRealMap ? '#E8F0FE' : '#F5F5F5')
      .fontColor(this.useRealMap ? '#1677FF' : '#888888')
      .onClick(() => { this.useRealMap = true; })

    Blank()

    Text(this.useRealMap
      ? $r('app.string.map_view_mode')
      : $r('app.string.list_view_mode'))
      .fontSize(12)
      .fontColor('#888888')
  }
  .width('100%')
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .margin({ top: $r('app.float.spacing_sm') })
}

设计哲学

  1. 渐进增强:列表视图是基础功能,地图视图是增强体验
  2. 条件渲染:用 if/else 而非 visibility——地图组件不渲染就不占资源
  3. 状态隔离useRealMap 只是 UI 状态,不影响数据层,切换零成本

🎯 架构启示:复杂功能永远做"降级设计"。先确保基础体验可用,再逐步增强。这样即使高级功能出问题,核心流程依然通畅。


八、地图SDK集成的完整流程

前面讲的是列表视图,现在我们来深入讲一讲真实地图视图的实现。

「民族图鉴」使用的是鸿蒙系统的地图能力(Map Kit)。接入地图 SDK 的完整流程如下:

8.1 地图SDK接入前的准备

在写代码之前,需要先做几件事:

步骤说明去哪弄
注册开发者账号华为开发者账号华为开发者联盟官网
创建应用在AppGallery Connect创建应用AppGallery Connect
开通地图服务开通Map Kit服务AppGallery Connect → 项目设置 → 开通服务
配置签名配置应用签名证书指纹项目设置 → 常规 → SHA256指纹
集成SDK在项目中引入地图SDK依赖build.gradle / oh-package.json5

这些是接入任何第三方SDK的标准流程。具体步骤可以参考华为官方文档,这里就不展开了。

8.2 地图组件的基础使用

鸿蒙地图组件的基本用法很简单——直接在页面里放一个 Map 组件:

// 引入地图模块
import map from '@ohos.map';

@Entry
@Component
struct MapPage {
  private mapController: map.MapController = new map.MapController();

  build() {
    Column() {
      Map({
        controller: this.mapController,
        // 初始地图状态:北京天安门附近
        initialCameraPosition: {
          target: { latitude: 39.9087, longitude: 116.3975 },
          zoom: 5  // 缩放级别:5可以看到全国
        }
      })
        .width('100%')
        .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
  }
}

就这么几行,你就能看到一张地图了。

关键概念:

  • MapController:地图控制器,通过它来操作地图(移动、缩放、添加Marker等)
  • CameraPosition:地图相机位置,决定了地图显示哪里、放大多少
  • Zoom 缩放级别:数字越大,地图越详细。1=世界,5=国家,10=城市,15=街道

8.3 地图的生命周期

地图组件也有生命周期,重要的几个回调:

Map({
  controller: this.mapController,
  onMapReady: () => {
    // 地图准备就绪,可以添加Marker、设置样式了
    console.log('map is ready');
    this.addEthnicMarkers();  // 添加民族分布标记
  },
  onMapClick: (point: map.MapPoint) => {
    // 点击地图空白处
    console.log('map clicked:', point.latitude, point.longitude);
  },
  onCameraMove: (position: map.CameraPosition) => {
    // 地图视角移动中
  },
  onCameraMoveEnd: (position: map.CameraPosition) => {
    // 地图视角移动结束
    // 可以在这里做"视野内的Marker加载"优化
  }
})

onMapReady 是最重要的——地图没准备好之前,很多操作都不能做。


九、地图的基本操作

地图的基本操作包括:缩放、平移、旋转、倾斜。用户可以用手势操作,我们也可以通过代码控制。

9.1 缩放操作

// 放大一级
this.mapController.zoomIn();

// 缩小一级
this.mapController.zoomOut();

// 缩放到指定级别
this.mapController.setZoom(10);

// 获取当前缩放级别
const currentZoom = this.mapController.getZoom();

缩放级别的参考值:

级别能看到什么场景
1-4世界地图、大洲全球视野
5-6全国范围「民族图鉴」默认视图
7-9省份、城市群省一级分布
10-12城市城市级分布
13-15街道、建筑精确位置

「民族图鉴」用 5-6 级比较合适——能看到中国全境,同时各省份的分布也看得清。

9.2 平移(移动视角)

// 移动到指定位置(带动画)
this.mapController.moveCamera({
  target: { latitude: 25.0389, longitude: 102.7183 },  // 昆明
  zoom: 7,
  duration: 500  // 动画时长,毫秒
});

// 获取当前相机位置
const position = this.mapController.getCameraPosition();
console.log('center:', position.target.latitude, position.target.longitude);

9.3 旋转与倾斜

// 设置旋转角度(0-360度,0是正北朝上)
this.mapController.setBearing(45);

// 设置倾斜角度(0-45度,0是垂直俯视)
this.mapController.setTilt(30);

对于「民族图鉴」来说,旋转和倾斜用得不多——默认的2D俯视图就挺好。但如果要做3D建筑展示,倾斜就有用了。

9.4 常用的地图设置

Map({
  controller: this.mapController,
  // 是否显示缩放按钮
  zoomControlsEnabled: true,
  // 是否显示指南针
  compassEnabled: true,
  // 是否允许缩放手势
  zoomGesturesEnabled: true,
  // 是否允许拖动手势
  scrollGesturesEnabled: true,
  // 是否允许旋转手势
  rotateGesturesEnabled: false,  // 「民族图鉴」禁用旋转,保持正北方
  // 是否允许倾斜手势
  tiltGesturesEnabled: false,    // 「民族图鉴」禁用倾斜,保持2D视图
  // 最小缩放级别
  minZoom: 4,
  // 最大缩放级别
  maxZoom: 10,
  // 地图类型:普通地图、卫星地图、夜景地图
  mapType: map.MapType.NORMAL
})

「民族图鉴」禁用旋转和倾斜是有原因的——用户看民族分布图,正北方朝上最直观,转来转去反而容易晕。

💡 什么时候开启旋转/倾斜?

  • 导航类应用:必须开,跟着车头方向转
  • 3D展示类:开倾斜,有立体感
  • 地图浏览类:建议关,保持稳定的视觉参考

功能不是越多越好,适合你的场景才是最好的。


十、Marker标记点的自定义与批量添加

地图上光有地图不行,还得有标记点(Marker)——告诉用户"这个地方有什么"。

10.1 Marker的基本添加

// 定义民族分布点的数据
interface EthnicMarker {
  ethnicId: string;
  ethnicName: string;
  latitude: number;
  longitude: number;
  emblemColor: string;
}

// 批量添加Marker
private addEthnicMarkers(markers: EthnicMarker[]): void {
  markers.forEach(marker => {
    const markerOptions = new map.MarkerOptions()
      .position({ latitude: marker.latitude, longitude: marker.longitude })
      .title(marker.ethnicName)       // 标题(点击显示)
      .snippet('点击查看详情')         // 副标题
      .draggable(false);              // 是否可拖拽

    // 自定义Marker图标
    // markerOptions.icon(BitmapDescriptorFactory.fromResource($r('app.media.ic_marker')));

    this.mapController.addMarker(markerOptions);
  });
}

10.2 自定义Marker样式

默认的 Marker 是红色图钉,不太好看。我们可以自定义:

// 自定义Marker:用民族主题色的圆形标记
private createCustomMarker(marker: EthnicMarker): map.MarkerOptions {
  // 方法1:用自定义图片
  // const icon = map.BitmapDescriptorFactory.fromResource($r('app.media.custom_marker'));
  
  // 方法2:用Canvas绘制自定义Marker(更灵活)
  // 这里用文字描述设计思路:
  // - 圆形背景:民族主题色
  // - 中间文字:民族首字
  // - 外圈白色描边
  
  const markerOptions = new map.MarkerOptions()
    .position({ latitude: marker.latitude, longitude: marker.longitude })
    .title(marker.ethnicName)
    .snippet(`${marker.ethnicName}主要分布区`)
    .icon(this.createMarkerIcon(marker))
    .anchor(0.5, 1.0);  // 锚点:底部中心点

  return markerOptions;
}

// 创建Marker图标(示意,实际用Canvas或图片资源)
private createMarkerIcon(marker: EthnicMarker): map.BitmapDescriptor {
  // 实际项目中可以用Canvas绘制自定义图标
  // 这里简化处理,用默认图标
  return map.BitmapDescriptorFactory.defaultMarker();
}

Marker设计的要点:

  1. 辨识度高:一眼就能看出是Marker
  2. 配色协调:和地图底色有对比,又不刺眼
  3. 大小合适:不能太大挡地图,也不能太小看不到
  4. 状态清晰:选中/未选中要有区别

10.3 批量添加的性能优化

56个民族,每个民族可能分布在多个省份,加起来可能有几百个Marker。一次性全加上会不会卡?

优化策略:

  1. 按需加载:只加当前视野内的Marker
// 地图移动结束后,更新视野内的Marker
onCameraMoveEnd: (position: map.CameraPosition) => {
  this.updateVisibleMarkers(position.target);
}

private updateVisibleMarkers(center: map.LatLng): void {
  // 1. 计算当前视野范围(简化:中心点周围一定经纬度范围内)
  const latRange = 10;  // 纬度范围
  const lngRange = 10;  // 经度范围
  
  // 2. 筛选出视野内的民族分布点
  const visibleMarkers = this.allMarkers.filter(m => 
    Math.abs(m.latitude - center.latitude) < latRange &&
    Math.abs(m.longitude - center.longitude) < lngRange
  );
  
  // 3. 移除不在视野内的,添加新进入视野的
  // ... 具体实现
}
  1. Marker复用:Marker对象池,避免频繁创建销毁

  2. 聚合(Cluster):Marker太多的时候,聚合成一个"数字气泡"

缩放级别低 → 很多Marker聚在一起 → 显示聚合点(显示数量)
缩放级别高 → Marker散开 → 显示单个Marker

Marker聚合是地图性能优化的利器,后面会专门讲。


十一、信息窗口(InfoWindow)的自定义

用户点Marker,会弹出一个信息窗口(InfoWindow),显示这个Marker的详细信息。

11.1 默认InfoWindow

默认的InfoWindow很简单——标题 + 副标题,两行文字。够用,但不好看。

11.2 自定义InfoWindow

我们可以自定义InfoWindow的样式,让它更符合「民族图鉴」的风格:

// 自定义InfoWindow组件
@Component
struct CustomInfoWindow {
  @Prop ethnicName: string = '';
  @Prop ethnicId: string = '';
  @Prop emblemColor: string = '';
  @Prop population: string = '';

  build() {
    Column({ space: 8 }) {
      // 顶部:民族名称
      Row() {
        Circle({ width: 12, height: 12 })
          .fill(this.emblemColor)
        Text(this.ethnicName)
          .fontSize(16)
          .fontWeight(FontWeight.Bold)
          .fontColor('#333333')
          .margin({ left: 6 })
        Blank()
        Text('详情 →')
          .fontSize(12)
          .fontColor('#1677FF')
      }
      .width('100%')

      // 底部:人口信息
      Text(`人口:约${this.population}万人`)
        .fontSize(12)
        .fontColor('#666666')
        .width('100%')
    }
    .width(200)
    .padding(12)
    .backgroundColor('#FFFFFF')
    .borderRadius(8)
    .shadow({ radius: 8, color: '#1A000000', offsetX: 0, offsetY: 2 })
  }
}

然后设置Marker的自定义InfoWindow:

// 设置自定义信息窗口适配器
this.mapController.setInfoWindowAdapter({
  getInfoWindow: (marker: map.Marker): any => {
    // 根据Marker获取民族数据
    const ethnicData = this.getEthnicDataByMarker(marker);
    
    // 返回自定义的InfoWindow组件
    return CustomInfoWindow({
      ethnicName: ethnicData.name,
      ethnicId: ethnicData.id,
      emblemColor: ethnicData.emblemColor,
      population: ethnicData.population
    });
  },
  
  onInfoWindowClick: (marker: map.Marker) => {
    // 点击InfoWindow,跳转到详情页
    const ethnicData = this.getEthnicDataByMarker(marker);
    router.pushUrl({
      url: 'pages/EthnicDetailPage',
      params: { ethnicId: ethnicData.id }
    });
  }
});

自定义InfoWindow的设计要点:

  1. 内容精炼:空间有限,只放最关键的信息
  2. 可点击:让用户知道"点一下能看更多"
  3. 样式统一:和App整体风格一致
  4. 有指向:底部有个小三角指向Marker(地图SDK一般会自动加)

十二、民族分布热力图/区域着色

Marker是"点"的展示,但民族分布其实是"面"的概念——傣族分布在云南全省,不是只在昆明有一个点。

更高级的可视化方式是区域着色(Choropleth Map)——每个省份根据民族数量,填充不同深浅的颜色。

12.1 区域着色的设计思路

民族数量颜色深浅说明
20+ 个最深民族多样性最高
10-19 个较深民族多样性较高
5-9 个中等民族多样性一般
1-4 个较浅民族多样性较低

颜色从浅到深渐变,用户扫一眼就知道"哪里民族多,哪里民族少"。

12.2 技术实现方式

区域着色的核心是绘制多边形填充

// 绘制省份区域(示意)
private drawProvincePolygon(provinceName: string, color: string): void {
  // 1. 获取省份的边界坐标(GeoJSON格式的数据)
  const coordinates = this.getProvinceBoundary(provinceName);
  
  // 2. 创建多边形选项
  const polygonOptions = new map.PolygonOptions()
    .addPoints(coordinates)      // 边界坐标点数组
    .fillColor(color)            // 填充颜色(带透明度)
    .strokeColor('#FFFFFF')      // 边框颜色
    .strokeWidth(2)              // 边框宽度
    .clickable(true);            // 是否可点击

  // 3. 添加到地图
  this.mapController.addPolygon(polygonOptions);
}

// 根据民族数量计算颜色
private getProvinceColor(ethnicCount: number, maxCount: number): string {
  const ratio = ethnicCount / maxCount;
  // 从浅到深的渐变色
  // 实际项目用颜色插值算法
  if (ratio > 0.8) return '#E74C3C80';   // 深红,半透明
  if (ratio > 0.5) return '#F39C1280';   // 橙色
  if (ratio > 0.2) return '#F1C40F80';   // 黄色
  return '#2ECC7180';                      // 绿色
}

12.3 热力图 vs 区域着色

还有一种可视化方式叫热力图(Heatmap)——用颜色渐变表示密度高低。

方式特点适用场景
区域着色按行政区域填色,边界清晰省级、市级统计数据
热力图连续的颜色渐变,没有明确边界点密度、人口分布

「民族图鉴」用区域着色更合适,因为:

  • 民族分布数据是按省份统计的
  • 行政边界清晰,用户更容易理解
  • 和"省份卡片"的列表视图对应得上

12.4 数据从哪来?

区域着色需要两个关键数据:

  1. 省份边界坐标(GeoJSON):网上可以找到公开的中国行政区划边界数据
  2. 各省民族数量:我们的倒排索引已经算好了

把这两个数据结合起来,就能画出漂亮的区域着色图了。

💡 可视化的"度"
地图可视化很容易"用力过猛"——颜色太多、Marker太密、动画太炫……
记住,地图是背景,数据才是主角
好的可视化,是让用户一眼看到数据规律,而不是被花哨的效果晃晕。
「民族图鉴」的原则:克制、清晰、信息优先。


十三、地图与列表的联动

地图视图和列表视图,不是割裂的——它们是同一份数据的两种展示方式。用户在地图上点了某个省,列表也应该定位到那个省;用户在列表里点了某个省,地图也应该飞到那个省。

这就是地图与列表的联动

13.1 列表 → 地图

用户点击列表中的某个省份,地图飞到那个省:

// 点击省份卡片,地图定位到该省
private onProvinceCardClick(province: ProvinceEthnicItem): void {
  if (!this.useRealMap) {
    return;  // 列表视图不用管
  }

  // 获取该省的中心坐标
  const center = this.getProvinceCenter(province.province);
  if (!center) return;

  // 地图移动过去
  this.mapController.moveCamera({
    target: center,
    zoom: 7,
    duration: 500
  });

  // 可选:切换到地图视图
  this.useRealMap = true;
}

13.2 地图 → 列表

用户点击地图上的Marker,列表滚动到对应的省份:

// 点击Marker,列表滚动到对应省份
private onMarkerClick(marker: map.Marker): void {
  // 从Marker获取省份信息
  const provinceName = this.getProvinceByMarker(marker);
  if (!provinceName) return;

  // 找到这个省在列表中的索引
  const list = this.getFilteredList();
  const index = list.findIndex(item => item.province === provinceName);
  if (index < 0) return;

  // 列表滚动到指定位置(Scroll组件的scrollTo方法)
  this.listScroll.scrollTo({ index: index, smooth: true });
  
  // 可选:高亮这个省份卡片
  this.highlightedProvince = provinceName;
}

13.3 筛选的联动

地区筛选、搜索过滤,地图和列表也要同步:

// 筛选条件变化时,同时更新地图和列表
private onFilterChange(): void {
  const filtered = this.getFilteredList();
  
  // 1. 更新列表(自动,因为用了@State)
  
  // 2. 更新地图上的Marker:只显示筛选后的省份
  this.updateMapMarkers(filtered);
}

private updateMapMarkers(filteredList: ProvinceEthnicItem[]): void {
  // 1. 移除所有旧Marker
  this.mapController.clear();
  
  // 2. 添加筛选后的Marker
  filteredList.forEach(province => {
    this.addProvinceMarkers(province);
  });
}

联动的核心思想: 数据是唯一真相来源(Single Source of Truth)。

  • 数据变了 → 地图和列表一起变
  • 用户在地图上操作 → 改数据 → 列表自动更新
  • 用户在列表上操作 → 改数据 → 地图自动更新

这样就不会出现"地图显示A,列表显示B"的尴尬情况。


十四、地图的性能优化

地图是个"吃性能"的组件。Marker加多了、多边形画多了,都可能卡顿。我们来聊聊怎么优化。

14.1 Marker聚合(Marker Cluster)

Marker太多的时候,密密麻麻一堆,既不好看又卡。这时候需要聚合——离得近的Marker合成一个"簇",显示数量,放大了再散开。

缩小时:  🧩 (12)  🧩 (8)  🧩 (15)    ← 3个聚合点,共35个Marker
放大后:  📍📍📍  📍📍  📍📍📍📍      ← 散开成单个Marker

聚合的实现思路:

  1. 按网格聚合:把地图分成一个个格子,每个格子里的Marker聚合成一个点
  2. 按距离聚合:两个Marker距离小于阈值就聚合
  3. 层级控制:缩放级别越高,聚合越少;越低,聚合越多

鸿蒙地图SDK一般自带聚合能力,或者有第三方库可以用。

14.2 层级控制(LOD)

不同缩放级别,显示不同详细程度的内容。这叫细节层次(Level of Detail,LOD)。

缩放级别显示内容
4-5级(全国)只显示省份区域着色,不显示Marker
6-7级(大区)显示省份的聚合Marker
8-9级(省份)显示每个民族的分布Marker
10级以上(城市)显示更详细的点位信息

为什么要做层级控制?

  • 减少同时渲染的Marker数量 → 更流畅
  • 避免低级别下Marker太密看不清 → 更清晰
  • 逐级加载,符合用户探索习惯 → 更自然

14.3 其他优化技巧

  1. 图片优化

    • Marker图标用合适尺寸,不要太大
    • 复用Bitmap,避免重复创建
    • 小图标可以用Canvas绘制,不用图片
  2. 减少重绘

    • 批量添加Marker,不要一个个加(减少地图重绘次数)
    • addMarkers() 批量添加,不用循环 addMarker()
  3. 及时清理

    • 页面销毁时销毁地图控制器
    • 切换视图时移除不需要的图层
    • 避免内存泄漏
  4. 按需加载

    • 只加载当前视野内的数据
    • 滑动停止后再加载(防抖)
    • 不要在 onCameraMove 里做重操作

💡 性能优化的二八定律
80% 的性能问题,来自 20% 的代码。
地图优化也是一样——Marker数量、渲染频率,这两个是大头。
先优化这两个,大部分性能问题就解决了。
不要一上来就抠细枝末节,先抓主要矛盾。


十五、导航栏设计

地图页的导航栏右侧有个小细节——动态显示当前过滤结果的数量

@Builder
buildNavBar(): void {
  Row() {
    Text('<')
      .fontSize($r('app.float.font_size_xxl'))
      .fontColor($r('app.color.text_primary'))
      .onClick(() => { router.back(); })

    Text($r('app.string.map_title'))
      .fontSize($r('app.float.font_size_lg'))
      .fontWeight(FontWeight.Bold)
      .fontColor($r('app.color.text_primary'))
      .layoutWeight(1)
      .textAlign(TextAlign.Center)

    Text(String(this.getFilteredList().length))
      .fontSize($r('app.float.font_size_sm'))
      .fontColor($r('app.color.text_hint'))
      .margin({ right: $r('app.float.spacing_sm') })
  }
  .width('100%')
  .height(48)
  .padding({ left: $r('app.float.spacing_lg'), right: $r('app.float.spacing_lg') })
  .alignItems(VerticalAlign.Center)
}

用户搜索或筛选时,右侧的数字实时变化,反馈"有多少个省份符合条件"。这是个微交互,但能有效降低用户的"不确定感"——我输入的关键词到底有没有结果?

九、性能优化要点

9.1 数据预处理

buildProvinceMap() 只在 aboutToAppear 时执行一次,数据计算和 UI 渲染分离:

  • ❌ 不好的做法:在 build 方法里实时计算映射
  • ✅ 好的做法:aboutToAppear 预处理,build 只负责渲染

9.2 过滤结果复用

getFilteredList() 在 build 中会被调用多次(导航栏数字 + ForEach)。当前实现每次都重新过滤,如果数据量很大可以考虑缓存:

// 优化方向:使用计算属性缓存
@State filteredList: Array<ProvinceEthnicItem> = [];

// searchText 或 selectedRegion 变化时重新计算
private updateFilteredList(): void {
  // ... 过滤逻辑
  this.filteredList = result;
}

对于 30 多个省份的规模,实时过滤性能足够。但如果数据量增长到几百条,就需要做缓存优化了。

9.3 Scroll 的弹性效果

Scroll() { ... }
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.Spring)

EdgeEffect.Spring 提供了 iOS 风格的弹性滚动效果,手感更好。ArkUI 还支持:

效果说明
EdgeEffect.Spring弹性回弹,iOS 风格
EdgeEffect.Fade渐变阴影,Android 风格
EdgeEffect.None无边界效果

十、架构思考

10.1 数据驱动视图

地图页是"数据驱动视图"的典型案例:

  1. 原始数据:ETHNIC_GROUPS(民族 → 省份)
  2. 数据转换:buildProvinceMap()(省份 → 民族列表)
  3. 数据过滤:getFilteredList()(按地区/搜索词筛选)
  4. 数据渲染:ForEach + buildProvinceCard

每一步都是纯数据操作,UI 只是数据的可视化呈现。这种思维方式是声明式 UI 的核心。

10.2 可扩展性

当前的列表视图是"保底方案",但架构上预留了地图组件的接入位置:

  • EthnicMapComponent 是独立组件,可单独开发和测试
  • useRealMap 状态控制两种视图的切换
  • 点击回调 onProvinceClick 统一了交互接口

未来接入真实地图时,只需要完善 EthnicMapComponent,页面其他代码完全不用动。这就是面向接口编程的威力。

十一、小结

技术点关键方法/组件难度
倒排索引构建buildProvinceMap() + Set + Record⭐⭐⭐
多维度搜索过滤getFilteredList() + Array.some()⭐⭐
标签云布局Flex + FlexWrap.Wrap⭐⭐
双视图切换if/else 条件渲染 + @State⭐⭐
统计概览Row + Column 组合布局
数据预处理aboutToAppear 生命周期⭐⭐

地图页虽然没有使用真正的地图组件,但它演示了数据可视化的本质:将结构化数据以用户易于理解的方式呈现出来。省份卡片 + 标签云就是一种"轻量级可视化",它用最简单的布局组件实现了地理分布的直观表达。

下一篇我们将进入测验页的开发,看看如何用状态机思想实现一个完整的答题流程。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值