HarmonyOS鸿蒙Next中使用Canvas绑定中国省份地图实现点亮效果
HarmonyOS鸿蒙Next中使用Canvas绑定中国省份地图实现点亮效果 在开发旅行足迹应用时,需要实现"点亮中国地图"功能:
- 在页面上显示完整的中国省份地图(含南海诸岛)
- 用户到访过的省份显示为高亮颜色(点亮效果)
- 点击省份能触发交互(显示省份详情弹窗)
- 地图需要自适应容器尺寸,保持正确的宽高比
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绘制中国省份地图并实现点亮效果,主要涉及以下步骤:
- 使用Canvas组件作为绘制容器。
- 准备省份的边界路径数据(通常为SVG路径字符串)。
- 在Canvas的onReady回调中,使用RenderingContext2D的路径绘制方法(如moveTo、lineTo)来描绘每个省份的轮廓。
- 为每个省份路径绑定触摸事件(如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.offsetX和offsetY获取点击坐标。 - 使用
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实现,无需第三方库,可灵活定制样式和交互。

