HarmonyOS鸿蒙Next中使用Canvas绑定中国省份地图实现点亮效果

HarmonyOS鸿蒙Next中使用Canvas绑定中国省份地图实现点亮效果 在开发旅行足迹应用时,需要实现"点亮中国地图"功能:

  1. 在页面上显示完整的中国省份地图(含南海诸岛)
  2. 用户到访过的省份显示为高亮颜色(点亮效果)
  3. 点击省份能触发交互(显示省份详情弹窗)
  4. 地图需要自适应容器尺寸,保持正确的宽高比
3 回复

1. 获取省份边界数据(GeoJSON)

使用阿里云 DataV 提供的中国省份边界数据:

// utils/GeoDataLoader.ets
import http from '@ohos.net.http'

// 省份 GeoJSON 数据 URL
const PROVINCE_GEOJSON_URL = 'https://geo.datav.aliyun.com/areas_v3/bound/100000_full.json'

// 省份属性接口
export interface ProvinceProperties {
  adcode: number
  name: string
  center: number[]  // 省份中心点 [经度, 纬度]
}

// 省份几何接口
export interface ProvinceGeometry {
  type: 'Polygon' | 'MultiPolygon'
  coordinates: ESObject
}

// 省份 Feature 接口
export interface ProvinceFeature {
  type: string
  properties: ProvinceProperties
  geometry: ProvinceGeometry
}

// GeoJSON 数据接口
export interface ProvinceGeoJSON {
  type: string
  features: ProvinceFeature[]
}

// 缓存数据
let cachedGeoData: ProvinceGeoJSON | null = null

/**
 * 加载省份 GeoJSON 数据
 */
export async function loadProvinceGeoJSON(): Promise<ProvinceGeoJSON | null> {
  if (cachedGeoData) {
    return cachedGeoData
  }

  try {
    const httpRequest = http.createHttp()
    const response = await httpRequest.request(PROVINCE_GEOJSON_URL, {
      method: http.RequestMethod.GET,
      connectTimeout: 30000,
      readTimeout: 30000
    })
    httpRequest.destroy()

    if (response.responseCode !== http.ResponseCode.OK) {
      throw new Error(`HTTP 请求失败: ${response.responseCode}`)
    }

    const jsonStr = response.result as string
    cachedGeoData = JSON.parse(jsonStr) as ProvinceGeoJSON
    
    console.info(`[GeoDataLoader] ✅ 加载成功,共 ${cachedGeoData.features.length} 个省份`)
    return cachedGeoData
  } catch (error) {
    console.error('[GeoDataLoader] ❌ 加载失败:', error)
    return null
  }
}

2. 创建中国地图 Canvas 组件

// components/ChinaProvinceMap.ets

interface Point {
  x: number
  y: number
}

export interface ChinaProvinceMapProps {
  visitedProvinces: string[]  // 已访问省份列表
  onProvinceClick?: (provinceName: string, clickX: number, clickY: number) => void
}

@Component
export struct ChinaProvinceMap {
  @Prop @Watch('onVisitedProvincesChange') visitedProvinces: string[] = []
  onProvinceClick: (provinceName: string, clickX: number, clickY: number) => void = () => {}
  
  @State private isLoading: boolean = false
  @State private canvasWidth: number = 300
  @State private canvasHeight: number = 200
  
  private settings: RenderingContextSettings = new RenderingContextSettings(true)
  private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings)
  private geoData: ProvinceGeoJSON | null = null
  
  // 中国地图经纬度范围(包含南海诸岛)
  private readonly minLng: number = 73
  private readonly maxLng: number = 135
  private readonly minLat: number = 2   // 包含曾母暗沙
  private readonly maxLat: number = 54

  build() {
    Column() {
      if (this.isLoading) {
        Row() {
          LoadingProgress().width(40).height(40)
          Text('加载地图数据...').fontSize(14).margin({ left: 12 })
        }
        .width('100%')
        .justifyContent(FlexAlign.Center)
      } else {
        Canvas(this.canvasContext)
          .width(this.canvasWidth)
          .height(this.canvasHeight)
          .backgroundColor('#F5F2EF')
          .onReady(() => this.loadAndDrawMap())
          .onClick((event: ClickEvent) => this.handleCanvasClick(event))
      }
    }
    .width('100%')
    .onAreaChange((oldValue: Area, newValue: Area) => {
      this.handleAreaChange(newValue)
    })
  }
}

3. 经纬度转 Canvas 像素坐标

/**
 * 将地理坐标转换为 Canvas 像素坐标
 * @param lng 经度
 * @param lat 纬度
 * @returns 像素坐标 {x, y}
 */
private geoToPixel(lng: number, lat: number): Point {
  // 计算地图绘制区域(留5%边距)
  const padding = 0.05
  const paddingX = this.canvasWidth * padding
  const paddingY = this.canvasHeight * padding
  const drawWidth = this.canvasWidth - paddingX * 2
  const drawHeight = this.canvasHeight - paddingY * 2
  
  // 计算经纬度范围
  const lngRange = this.maxLng - this.minLng  // 62度
  const latRange = this.maxLat - this.minLat  // 52度
  
  // 计算缩放比例,保持地图比例正确
  const scaleX = drawWidth / lngRange
  const scaleY = drawHeight / latRange
  const scale = Math.min(scaleX, scaleY) * 0.95
  
  // 计算居中偏移
  const actualWidth = lngRange * scale
  const actualHeight = latRange * scale
  const offsetX = paddingX + (drawWidth - actualWidth) / 2
  const offsetY = paddingY + (drawHeight - actualHeight) / 2
  
  // 转换坐标(注意:Canvas Y轴向下,纬度向上)
  const x = offsetX + (lng - this.minLng) * scale
  const y = offsetY + (this.maxLat - lat) * scale
  
  return { x, y }
}

/**
 * 将像素坐标转换为地理坐标(用于点击检测)
 */
private pixelToGeo(x: number, y: number): Point {
  // 逆向计算...(与上面相反)
  const padding = 0.05
  const paddingX = this.canvasWidth * padding
  const paddingY = this.canvasHeight * padding
  const drawWidth = this.canvasWidth - paddingX * 2
  const drawHeight = this.canvasHeight - paddingY * 2
  
  const lngRange = this.maxLng - this.minLng
  const latRange = this.maxLat - this.minLat
  const scaleX = drawWidth / lngRange
  const scaleY = drawHeight / latRange
  const scale = Math.min(scaleX, scaleY) * 0.95
  
  const actualWidth = lngRange * scale
  const actualHeight = latRange * scale
  const offsetX = paddingX + (drawWidth - actualWidth) / 2
  const offsetY = paddingY + (drawHeight - actualHeight) / 2
  
  const lng = (x - offsetX) / scale + this.minLng
  const lat = this.maxLat - (y - offsetY) / scale
  
  return { x: lng, y: lat }
}

4. 绘制省份多边形

/**
 * 绘制完整地图
 */
private drawMap(): void {
  if (!this.geoData) return

  // 清空画布
  this.canvasContext.clearRect(0, 0, this.canvasWidth, this.canvasHeight)
  
  // 设置背景色
  this.canvasContext.fillStyle = '#F5F2EF'
  this.canvasContext.fillRect(0, 0, this.canvasWidth, this.canvasHeight)

  // 创建已访问省份集合
  const visitedSet = new Set<string>(this.visitedProvinces)

  // 绘制所有省份
  this.geoData.features.forEach((feature: ProvinceFeature) => {
    const provinceName = feature.properties.name
    const isVisited = visitedSet.has(provinceName)
    this.drawProvince(feature, isVisited)
  })
}

/**
 * 绘制单个省份
 */
private drawProvince(feature: ProvinceFeature, isVisited: boolean): void {
  // 点亮效果:已访问=驼色,未访问=浅灰色
  const fillColor = isVisited ? '#A9846A' : '#E0E0E0'
  const strokeColor = '#654933'  // 深棕色边界

  this.canvasContext.fillStyle = fillColor
  this.canvasContext.strokeStyle = strokeColor
  this.canvasContext.lineWidth = 1

  const geometry = feature.geometry
  const coords: ESObject = geometry.coordinates

  if (geometry.type === 'MultiPolygon') {
    this.drawMultiPolygon(coords)
  } else if (geometry.type === 'Polygon') {
    this.drawPolygon(coords)
  }
}

/**
 * 绘制 Polygon
 */
private drawPolygon(coords: ESObject): void {
  const ringsLength: number = coords['length'] as number
  if (ringsLength === 0) return

  // 只绘制外环(第一个 ring)
  const outerRing: ESObject = coords[0] as ESObject
  const pointsLength: number = outerRing['length'] as number

  this.canvasContext.beginPath()

  for (let i = 0; i < pointsLength; i++) {
    const coord: ESObject = outerRing[i] as ESObject
    const lng: number = coord[0] as number
    const lat: number = coord[1] as number
    const point = this.geoToPixel(lng, lat)

    if (i === 0) {
      this.canvasContext.moveTo(point.x, point.y)
    } else {
      this.canvasContext.lineTo(point.x, point.y)
    }
  }

  this.canvasContext.closePath()
  this.canvasContext.fill()
  this.canvasContext.stroke()
}

/**
 * 绘制 MultiPolygon(多个不相连的多边形,如海南省)
 */
private drawMultiPolygon(coords: ESObject): void {
  const polygonsLength: number = coords['length'] as number

  for (let i = 0; i < polygonsLength; i++) {
    const polygonCoords: ESObject = coords[i] as ESObject
    this.drawPolygon(polygonCoords)
  }
}

5. 点击检测(射线法)

判断点击位置属于哪个省份,使用经典的射线法算法:

/**
 * 处理 Canvas 点击事件
 */
private handleCanvasClick(event: ClickEvent): void {
  if (!this.geoData) return

  const clickX = event.x
  const clickY = event.y
  const clickGeo = this.pixelToGeo(clickX, clickY)

  // 遍历所有省份,找到点击的省份
  for (const feature of this.geoData.features) {
    const provinceName = feature.properties.name
    
    if (this.isPointInProvince(clickGeo.x, clickGeo.y, feature)) {
      console.info(`[ChinaMap] 点击省份: ${provinceName}`)
      this.onProvinceClick?.(provinceName, clickX, clickY)
      return
    }
  }
}

/**
 * 判断点是否在省份内
 */
private isPointInProvince(lng: number, lat: number, feature: ProvinceFeature): boolean {
  const geometry = feature.geometry
  const coords: ESObject = geometry.coordinates

  if (geometry.type === 'MultiPolygon') {
    return this.isPointInMultiPolygon(lng, lat, coords)
  } else if (geometry.type === 'Polygon') {
    return this.isPointInPolygonCoords(lng, lat, coords)
  }

  return false
}

/**
 * 射线法判断点是否在多边形内
 * 原理:从点向右发射水平射线,统计与多边形边界的交点数
 * 交点数为奇数 → 点在内部;偶数 → 点在外部
 */
private pointInPolygon(lng: number, lat: number, ring: ESObject): boolean {
  const pointsLength: number = ring['length'] as number
  if (pointsLength < 3) return false

  let inside: boolean = false

  for (let i = 0, j = pointsLength - 1; i < pointsLength; j = i++) {
    const coordI: ESObject = ring[i] as ESObject
    const coordJ: ESObject = ring[j] as ESObject
    
    const lngI: number = coordI[0] as number
    const latI: number = coordI[1] as number
    const lngJ: number = coordJ[0] as number
    const latJ: number = coordJ[1] as number

    // 判断射线是否与边相交
    const intersect = ((latI > lat) !== (latJ > lat)) &&
      (lng < (lngJ - lngI) * (lat - latI) / (latJ - latI) + lngI)

    if (intersect) {
      inside = !inside
    }
  }

  return inside
}

6. 响应式尺寸处理

/**
 * 处理容器尺寸变化
 */
private handleAreaChange(newValue: Area): void {
  const newWidth = newValue.width as number
  const newHeight = newValue.height as number
  
  if (newWidth > 0 && newHeight > 0) {
    // 计算地图经纬度比例
    const lngRange = this.maxLng - this.minLng  // 62度
    const latRange = this.maxLat - this.minLat  // 52度
    const geoAspectRatio = lngRange / latRange  // 约 1.19
    
    // 按照地图比例调整
    let targetWidth = newWidth * 0.95
    let targetHeight = newHeight * 0.95
    
    if (targetWidth / targetHeight > geoAspectRatio) {
      targetWidth = targetHeight * geoAspectRatio
    } else {
      targetHeight = targetWidth / geoAspectRatio
    }
    
    this.canvasWidth = Math.floor(targetWidth)
    this.canvasHeight = Math.floor(targetHeight)

    // 重绘地图
    if (this.geoData) {
      setTimeout(() => this.drawMap(), 50)
    }
  }
}

/**
 * 监听已访问省份变化,自动重绘
 */
private onVisitedProvincesChange(): void {
  if (this.geoData) {
    this.drawMap()
  }
}

7. 在页面中使用

// pages/FootprintMapPage.ets

@Entry
@Component
struct FootprintMapPage {
  @State visitedProvinces: string[] = ['北京市', '上海市', '广东省', '四川省']
  @State selectedProvince: string = ''
  @State showPopup: boolean = false
  
  build() {
    Stack() {
      Column() {
        Text('我的足迹地图')
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .margin({ bottom: 16 })
        
        Text(`已点亮 ${this.visitedProvinces.length} 个省份`)
          .fontSize(14)
          .fontColor('#666666')
        
        // 中国省份地图
        ChinaProvinceMap({
          visitedProvinces: this.visitedProvinces,
          onProvinceClick: (name, x, y) => {
            this.selectedProvince = name
            this.showPopup = true
          }
        })
        .width('100%')
        .height(300)
        .margin({ top: 16 })
      }
      .width('100%')
      .padding(20)
      
      // 省份详情弹窗
      if (this.showPopup) {
        Column() {
          Text(this.selectedProvince)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
          Text(this.visitedProvinces.includes(this.selectedProvince) ? '✅ 已点亮' : '⬜ 未点亮')
            .fontSize(14)
            .margin({ top: 8 })
        }
        .backgroundColor('#FFFFFF')
        .padding(16)
        .borderRadius(8)
        .shadow({ radius: 8, color: '#00000020' })
        .onClick(() => this.showPopup = false)
      }
    }
  }
}

效果

控制台日志:

[GeoDataLoader] ✅ 加载成功,共 34 个省份
[GeoDataLoader] 数据大小: 2156.3 KB
[ChinaMap] 地图绑制完成
[ChinaMap] 点击位置: Canvas(150.2, 89.3), Geo(116.41, 39.91)
[ChinaMap] 点击省份: 北京市

视觉效果:

状态 颜色
已访问省份 驼色 #A9846A
未访问省份 浅灰色 #E0E0E0
省份边界 深棕色 #654933

关键点总结

要点 说明
数据源 阿里云 DataV GeoJSON,包含 34 个省级行政区
坐标转换 经纬度 ↔ Canvas 像素坐标,保持地图比例
点击检测 射线法(Ray Casting)判断点在多边形内
响应式 onAreaChange 监听容器尺寸变化,自适应重绘
性能优化 GeoJSON 数据缓存,避免重复加载

更多关于HarmonyOS鸿蒙Next中使用Canvas绑定中国省份地图实现点亮效果的实战系列教程也可以访问 https://www.itying.com/category-93-b0.html


在HarmonyOS Next中,通过Canvas绘制中国省份地图并实现点亮效果,主要涉及以下步骤:

  1. 使用Canvas组件作为绘制容器。
  2. 准备省份的边界路径数据(通常为SVG路径字符串)。
  3. 在Canvas的onReady回调中,使用RenderingContext2D的路径绘制方法(如moveTo、lineTo)来描绘每个省份的轮廓。
  4. 为每个省份路径绑定触摸事件(如onTouch),在事件回调中,通过RenderingContext2D的fill方法重新填充该省份路径,并设置不同的颜色(如从默认色改为高亮色),以实现“点亮”的视觉效果。

关键点在于利用Canvas的路径绘制与填充能力,结合触摸事件交互来动态改变特定区域的显示状态。

在HarmonyOS Next中,使用Canvas实现中国省份地图的点亮效果,核心在于矢量路径数据的处理与Canvas的交互绘制。以下是关键步骤和代码要点:

1. 获取并处理地图数据

  • 使用GeoJSON格式的中国省份边界数据(需包含南海诸岛)。
  • 将GeoJSON中的经纬度坐标转换为Canvas的像素坐标(需实现墨卡托投影或简单比例缩放)。
  • 将每个省份的路径数据存储为对象,包含path2D对象和省份信息(如名称、ID)。

2. Canvas绘制与自适应

  • CanvasRenderingContext2D中,根据容器尺寸计算缩放比例和偏移量,使地图居中并适应容器。
  • 遍历所有省份路径数据,使用context.stroke()绘制边框,context.fill()填充基础颜色。
  • 通过window.onresize或容器尺寸变化事件触发重绘,实现自适应。

3. 点亮效果实现

  • 维护一个已访问省份的ID列表(如visitedProvinces: string[])。
  • 在绘制循环中,判断当前绘制省份是否在visitedProvinces中,是则使用高亮颜色(如#ff6b6b)填充,否则使用默认颜色。
  • 可通过context.globalAlpha设置透明度实现渐变点亮动画。

4. 点击交互实现

  • 在Canvas上绑定click事件,通过event.offsetXoffsetY获取点击坐标。
  • 使用context.isPointInPath(path2D, x, y)判断点击坐标落在哪个省份的路径内。
  • 命中后触发该省份的详情弹窗(使用CustomDialogController@CustomDialog装饰器)。

代码结构示例

// 地图绘制组件
@Component
struct ChinaMap {
  private settings: RenderingContextSettings = new RenderingContextSettings(true);
  private context: CanvasRenderingContext2D = new CanvasRenderingContext2D(this.settings);
  private visitedProvinces: string[] = ['北京', '上海']; // 示例数据
  private provincePaths: Map<string, Path2D> = new Map(); // 存储省份Path2D对象

  aboutToAppear() {
    // 初始化:加载GeoJSON数据,转换为Path2D,存储到provincePaths
  }

  // 绘制地图
  private drawMap() {
    this.context.clearRect(0, 0, this.canvasWidth, this.canvasHeight);
    this.provincePaths.forEach((path, provinceName) => {
      this.context.fillStyle = this.visitedProvinces.includes(provinceName) ? '#ff6b6b' : '#e0e0e0';
      this.context.fill(path);
      this.context.stroke(path);
    });
  }

  // 点击事件处理
  private handleClick(event: ClickEvent) {
    const rect = event.target.getBoundingClientRect();
    const x = event.offsetX - rect.left;
    const y = event.offsetY - rect.top;
    
    this.provincePaths.forEach((path, provinceName) => {
      if (this.context.isPointInPath(path, x, y)) {
        // 触发省份详情弹窗
        showProvinceDialog(provinceName);
      }
    });
  }

  build() {
    Column() {
      Canvas(this.context)
        .width('100%')
        .height('100%')
        .onClick(this.handleClick.bind(this))
    }
  }
}

注意事项

  • 性能优化:对于复杂的省份路径,建议使用Path2D缓存路径对象,避免每次重绘时重新解析。
  • 南海诸岛处理:确保GeoJSON数据包含南海诸岛,并在坐标转换时将其放置在地图右下角适当位置。
  • 触摸交互:在触屏设备上,可增加onTouch事件支持,并考虑使用isPointInStroke检测边界点击。

此方案完全基于HarmonyOS Next的Canvas API实现,无需第三方库,可灵活定制样式和交互。

回到顶部