Skip to content

热力图

功能说明

  1. 热力图参数控制
  2. 数据生成与管理
  3. 要素选择功能
  4. 要素删除功能(单个删除、全部删除)
  5. 显示当前绘制状态和要素信息
展开代码
vue
<template>
  <div class="heatmap-container">
    <div ref="mapContainer" class="map"></div>

    <div class="control-panel">
      <div class="panel-section">
        <h3>热力图控制</h3>

        <div class="control-group">
          <div class="control-item">
            <label for="radius">点半径: {{ pointRadius }}</label>
            <input
              type="range"
              id="radius"
              min="1"
              max="30"
              v-model="pointRadius"
              @input="updateHeatmapStyle"
            />
          </div>

          <div class="control-item">
            <label for="blur">模糊度: {{ blurSize }}</label>
            <input
              type="range"
              id="blur"
              min="1"
              max="30"
              v-model="blurSize"
              @input="updateHeatmapStyle"
            />
          </div>
        </div>

        <div class="control-group">
          <div class="control-item">
            <label for="gradient">颜色渐变</label>
            <select
              id="gradient"
              v-model="selectedGradient"
              @change="updateHeatmapStyle"
            >
              <option
                v-for="(grad, name) in gradients"
                :key="name"
                :value="name"
              >
                {{ name }}
              </option>
            </select>
          </div>

          <div class="control-item">
            <label for="opacity">不透明度: {{ opacity }}</label>
            <input
              type="range"
              id="opacity"
              min="0"
              max="1"
              step="0.1"
              v-model="opacity"
              @input="updateHeatmapStyle"
            />
          </div>
        </div>
      </div>

      <div class="panel-section">
        <h3>数据生成</h3>

        <div class="data-controls">
          <button
            v-for="btn in dataButtons"
            :key="btn.count"
            class="data-btn"
            @click="generateData(btn.count)"
          >
            {{ btn.label }}
          </button>

          <button class="clear-btn" @click="clearData">
            <i class="fas fa-trash-alt"></i> 清除数据
          </button>
        </div>

        <div class="data-info">
          <div class="info-item">
            <span class="label">数据点数:</span>
            <span class="value">{{ pointCount }}</span>
          </div>
          <div class="info-item">
            <span class="label">最大权重:</span>
            <span class="value">{{ maxWeight }}</span>
          </div>
        </div>
      </div>

      <div class="panel-section">
        <h3>热力分布说明</h3>
        <div class="gradient-preview" :style="gradientStyle"></div>
        <div class="legend">
          <span>低密度</span>
          <span>中密度</span>
          <span>高密度</span>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, onUnmounted, computed } from "vue";
import Map from "ol/Map";
import View from "ol/View";
import { Tile as TileLayer } from "ol/layer";
import { XYZ } from "ol/source";
import { Heatmap as HeatmapLayer, Vector as VectorLayer } from "ol/layer";
import { Vector as VectorSource } from "ol/source";
import { Point } from "ol/geom";
import Feature from "ol/Feature";
import { fromLonLat } from "ol/proj";
import { defaults as defaultControls, FullScreen } from "ol/control";
import "ol/ol.css";

// 热力图参数
const pointRadius = ref(15);
const blurSize = ref(15);
const opacity = ref(0.7);
const selectedGradient = ref("Jet");

// 数据统计
const pointCount = ref(0);
const maxWeight = ref(0);
const intensity = ref(0);
const hotspots = ref([]);

// 地图实例
const map = ref(null);
const mapContainer = ref(null);
const heatmapSource = ref(null);

// 颜色渐变方案
const gradients = ref({
  Jet: ["#0000FF", "#00FFFF", "#00FF00", "#FFFF00", "#FF0000"],
  Hot: ["#000000", "#800000", "#FF0000", "#FF8000", "#FFFF00"],
  Cool: ["#00FFFF", "#00BFFF", "#0000FF", "#8A2BE2", "#FF00FF"],
  Earth: ["#000000", "#006400", "#00FF00", "#FFFF00", "#FFA500", "#FF0000"],
  Viridis: ["#440154", "#3B528B", "#21908C", "#5DC963", "#FDE725"],
});

// 数据生成按钮
const dataButtons = ref([
  { count: 500, label: "生成500点" },
  { count: 1000, label: "生成1000点" },
  { count: 2000, label: "生成2000点" },
  { count: 5000, label: "生成5000点" },
]);

// 计算渐变样式
const gradientStyle = computed(() => {
  const colors = gradients.value[selectedGradient.value];
  return {
    background: `linear-gradient(to right, ${colors.join(", ")})`,
  };
});

// 初始化地图
onMounted(() => {
  // 创建热力图数据源
  heatmapSource.value = new VectorSource();

  // 创建热力图层
  const heatmapLayer = new HeatmapLayer({
    source: heatmapSource.value,
    blur: parseInt(blurSize.value),
    radius: parseInt(pointRadius.value),
    opacity: parseFloat(opacity.value),
    gradient: gradients.value[selectedGradient.value],
  });

  // 创建高德地图图层
  const baseLayer = new TileLayer({
    source: new XYZ({
      url: "https://webrd04.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=1&style=7&x={x}&y={y}&z={z}",
    }),
  });

  // 创建地图
  map.value = new Map({
    target: mapContainer.value,
    layers: [baseLayer, heatmapLayer],
    view: new View({
      center: fromLonLat([116.4, 39.9]),
      zoom: 10,
    }),
    controls: defaultControls().extend([new FullScreen()]),
  });

  // 初始生成数据
  generateData(1000);
});

// 更新热力图样式
function updateHeatmapStyle() {
  const layers = map.value.getLayers();
  const heatmapLayer = layers.item(1);

  heatmapLayer.setBlur(parseInt(blurSize.value));
  heatmapLayer.setRadius(parseInt(pointRadius.value));
  heatmapLayer.setOpacity(parseFloat(opacity.value));
  heatmapLayer.setGradient(gradients.value[selectedGradient.value]);
}

// 生成随机数据点
function generateData(count) {
  heatmapSource.value.clear();
  pointCount.value = count;
  maxWeight.value = 0;

  // 创建几个热点区域
  const view = map.value.getView();
  const extent = view.calculateExtent(map.value.getSize());
  const width = extent[2] - extent[0];
  const height = extent[3] - extent[1];

  // 生成3-5个热点区域
  const hotspotCount = Math.floor(Math.random() * 3) + 3;
  for (let i = 0; i < hotspotCount; i++) {
    hotspots.value.push({
      x: extent[0] + Math.random() * width,
      y: extent[1] + Math.random() * height,
      radius: 10000 + Math.random() * 20000,
      intensity: 0.2 + Math.random() * 0.8,
    });
  }

  // 生成数据点
  let totalWeight = 0;
  let points = [];
  for (let i = 0; i < count; i++) {
    // 决定点在哪个热点区域
    const hotspotIndex =
      Math.random() < 0.7 ? Math.floor(Math.random() * hotspotCount) : -1;
    let x, y, weight;

    if (hotspotIndex >= 0) {
      // 在热点区域内生成点
      const hotspot = hotspots.value[hotspotIndex];
      const angle = Math.random() * Math.PI * 2;
      const distance = Math.random() * hotspot.radius;

      x = hotspot.x + Math.cos(angle) * distance;
      y = hotspot.y + Math.sin(angle) * distance;
      weight = hotspot.intensity * (1 - distance / hotspot.radius);
    } else {
      // 在区域内随机生成点
      x = extent[0] + Math.random() * width;
      y = extent[1] + Math.random() * height;
      weight = Math.random() * 0.3; // 低权重
    }

    // 创建点要素
    const point = new Feature({
      geometry: new Point([x, y]),
    });

    points.push(point);

    // 设置权重 (0~1)
    point.set("weight", weight);

    totalWeight += weight;
    if (weight > maxWeight.value) maxWeight.value = weight;
  }
  heatmapSource.value.addFeatures(points);

  // 计算平均密度
  intensity.value = totalWeight / count;
}

// 清除数据
function clearData() {
  heatmapSource.value.clear();
  pointCount.value = 0;
  maxWeight.value = 0;
  intensity.value = 0;
  hotspots.value = [];
}

// 组件卸载时清理
onUnmounted(() => {
  if (map.value) {
    map.value.dispose();
  }
});
</script>

<style scoped>
.heatmap-container {
  position: relative;
  width: 100vw;
  height: 100vh;
  overflow: hidden;
  font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
  background: linear-gradient(135deg, #1a237e, #4a148c);
}

.map {
  width: 100%;
  height: 100%;
  background: #0d47a1;
}

.control-panel {
  position: absolute;
  top: 20px;
  right: 20px;
  width: 320px;
  background: rgba(255, 255, 255, 0.95);
  border-radius: 12px;
  padding: 20px;
  box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
  backdrop-filter: blur(5px);
  border: 1px solid rgba(255, 255, 255, 0.3);
  z-index: 1;
}

.panel-section {
  padding-bottom: 10px;
  border-bottom: 1px solid #eee;
}

.panel-section:last-child {
  margin-bottom: 0;
  padding-bottom: 0;
  border-bottom: none;
}

.panel-section h3 {
  margin-top: 0;
  margin-bottom: 15px;
  color: #2c3e50;
  font-size: 1.3rem;
  padding-bottom: 10px;
  border-bottom: 2px solid #3498db;
}

.control-group {
  display: flex;
  gap: 20px;
  margin-bottom: 20px;
}

.control-item {
  flex: 1;
}

.control-item label {
  display: block;
  margin-bottom: 8px;
  font-weight: 600;
  color: #2c3e50;
}

.control-item input[type="range"] {
  width: 100%;
  height: 8px;
  border-radius: 4px;
  background: #e0e0e0;
  outline: none;
  -webkit-appearance: none;
}

.control-item input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: #3498db;
  cursor: pointer;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}

.control-item select {
  width: 100%;
  padding: 10px;
  border-radius: 8px;
  border: 1px solid #ddd;
  color: #2c3e50;
  background: #f8f9fa;
  font-weight: 500;
  cursor: pointer;
}

.data-controls {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 10px;
  margin-bottom: 20px;
}

.data-btn {
  padding: 10px;
  background: #3498db;
  color: white;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
}

.data-btn:hover {
  background: #2980b9;
  transform: translateY(-2px);
}

.clear-btn {
  grid-column: span 2;
  padding: 10px;
  background: #e74c3c;
  color: white;
  border: none;
  border-radius: 8px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.2s ease;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
}

.clear-btn:hover {
  background: #c0392b;
  transform: translateY(-2px);
}

.data-info {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 15px;
  background: #f8f9fa;
  padding: 15px;
  border-radius: 8px;
}

.info-item {
  display: flex;
  flex-direction: column;
}

.info-item .label {
  font-size: 0.9rem;
  color: #7f8c8d;
  margin-bottom: 4px;
}

.info-item .value {
  font-weight: 700;
  color: #2c3e50;
  font-size: 1.2rem;
}

.gradient-preview {
  height: 25px;
  border-radius: 5px;
  margin-bottom: 10px;
  border: 1px solid #ddd;
}

.legend {
  display: flex;
  justify-content: space-between;
  font-size: 0.85rem;
  color: #7f8c8d;
}
</style>