3D 房地产地图 Web 应用

发布于:2025-09-08 ⋅ 阅读:(15) ⋅ 点赞:(0)

我们将从零开始,一步步使用 Vite、React 和 ArcGIS Maps SDK for JavaScript 构建一个专业的 3D 房地产地图 Web 应用。

这个教程将引导您完成以下核心步骤:

  1. 项目初始化:使用 Vite 创建一个 React 项目。

  2. 环境配置:安装并配置 ArcGIS JS SDK。

  3. 创建 3D 地图容器:构建一个 React 组件来承载 3D SceneView

  4. 加载 3D 建筑数据:添加一个 FeatureLayer 来显示城市建筑,并使用其属性进行 3D 挤出。

  5. 实现商业板块高亮:使用 UniqueValueRenderer 根据建筑属性来区分和高亮显示特定地产。

  6. 添加交互功能:创建侧边栏 UI,通过按钮动态切换高亮效果,并为建筑添加信息弹窗。

在开始之前,请确保您的电脑已安装 Node.js (LTS 版本即可)。

第 1 步:获取 ArcGIS API Key

所有使用 ArcGIS JS SDK 的应用都需要一个 API Key。

  1. 访问 ArcGIS for Developers 网站

  2. 登录或创建一个免费账户。

  3. 进入您的开发者仪表盘 (Dashboard)

  4. 点击 + New API Key 按钮创建一个新的 API Key。

  5. 复制并保存好这个 Key,我们稍后会在代码中使用它。

第 2 步:使用 Vite 创建 React 项目

打开您的终端 (Terminal 或 Command Prompt),然后执行以下命令。

  1. 创建 Vite 项目

    Bash
    npm create vite@latest real-estate-3d-map -- --template react
    

    这个命令会创建一个名为 real-estate-3d-map 的文件夹,并内置一个基础的 React 项目。

  2. 进入项目目录并安装依赖

    Bash
    cd real-estate-3d-map
    npm install
    
  3. 安装 ArcGIS JS SDK: 这是我们的核心地图库。

    Bash
    npm install @arcgis/core
    

第 3 步:项目结构与代码实现

现在,我们来编写应用的核心代码。请按照以下步骤替换或创建相应的文件。

3.1) 配置 ArcGIS 静态资源

为了让 SDK 正确加载其样式和其他资源,我们需要进行一些配置。

  1. 安装 vite-plugin-static-copy:这个 Vite 插件可以帮助我们把 ArcGIS 的资源文件复制到最终的构建输出目录中。

    Bash
    npm install --save-dev vite-plugin-static-copy
    
  2. 修改 vite.config.js: 将以下内容粘贴到项目根目录的 vite.config.js 文件中。

    JavaScript
    import { defineConfig } from 'vite';
    import react from '@vitejs/plugin-react';
    import { viteStaticCopy } from 'vite-plugin-static-copy';
    
    export default defineConfig({
      plugins: [
        react(),
        // viteStaticCopy 插件配置
        viteStaticCopy({
          targets: [
            {
              src: 'node_modules/@arcgis/core/assets',
              dest: 'assets'
            }
          ]
        })
      ],
    });
    
3.2) 添加全局样式

我们需要引入 ArcGIS 的主题样式,并为我们的应用添加一些布局样式。

  1. 清空 src/index.css 并替换为以下内容:

    CSS
    /* 全局样式重置 */
    html, body, #root {
      padding: 0;
      margin: 0;
      width: 100%;
      height: 100%;
      overflow: hidden; /* 防止滚动条出现 */
      font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
        "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
        sans-serif;
    }
    
  2. 清空 src/App.css 并替换为以下内容,用于主应用布局:

    CSS
    /* 主应用容器 */
    .app-container {
      display: flex;
      height: 100vh;
      width: 100vw;
    }
    
    /* 地图容器 */
    .map-container {
      flex-grow: 1; /* 占据剩余所有空间 */
      height: 100%;
    }
    
    /* 侧边栏 */
    .sidebar {
      width: 300px;
      padding: 20px;
      background-color: #f8f9fa;
      box-shadow: 2px 0 5px rgba(0,0,0,0.1);
      z-index: 10;
      display: flex;
      flex-direction: column;
      gap: 15px; /* 元素间距 */
    }
    
    .sidebar h2 {
      margin-top: 0;
      font-size: 1.5em;
      border-bottom: 2px solid #007ac2;
      padding-bottom: 10px;
    }
    
    .sidebar-description {
      font-size: 0.9em;
      color: #555;
    }
    
    .highlight-button {
      padding: 10px 15px;
      font-size: 1em;
      background-color: #007ac2;
      color: white;
      border: none;
      border-radius: 4px;
      cursor: pointer;
      transition: background-color 0.2s;
    }
    
    .highlight-button:hover {
      background-color: #005a8e;
    }
    
    .highlight-button.active {
      background-color: #28a745; /* 激活状态为绿色 */
    }
    
    .highlight-button.active:hover {
      background-color: #218838;
    }
    
3.3) 创建核心组件

我们将应用分为 App (主应用)、Sidebar (侧边栏) 和 MapContainer (地图容器)。

  1. 创建 src/components 文件夹

    Bash
    mkdir src/components
    
    1. 创建 src/components/MapContainer.jsx: 这是最重要的组件,负责初始化和渲染 3D 地图。我们将使用纽约市的建筑数据作为示例。

      JavaScript
      import React, { useRef, useEffect } from 'react';
      import Map from '@arcgis/core/Map';
      import SceneView from '@arcgis/core/views/SceneView';
      import FeatureLayer from '@arcgis/core/layers/FeatureLayer';
      import Basemap from '@arcgis/core/Basemap';
      import TileLayer from '@arcgis/core/layers/TileLayer';
      import config from '@arcgis/core/config';
      
      // 渲染器模块
      import UniqueValueRenderer from "@arcgis/core/renderers/UniqueValueRenderer";
      import {
        PolygonSymbol3D,
        ExtrudeSymbol3DLayer
      } from "@arcgis/core/symbols";
      
      // 弹窗模块
      import PopupTemplate from "@arcgis/core/PopupTemplate";
      
      const MapContainer = ({ isHighlighted }) => {
        const mapDiv = useRef(null);
        // 将 view 和 layer 存储在 ref 中,以便在 effect 之间共享而不触发重渲染
        const viewRef = useRef(null);
        const layerRef = useRef(null);
      
        // 1. 初始化地图和视图
        useEffect(() => {
          if (mapDiv.current) {
            // --- 配置 API Key ---
            // 重要:在这里替换为您自己的 API Key
            config.apiKey = "YOUR_API_KEY";
      
            // 创建底图
            const basemap = new Basemap({
              baseLayers: [
                new TileLayer({
                  url: "https://tiles.arcgis.com/tiles/nSZVuSZjHpEZZbRo/arcgis/rest/services/Canvas_Dark_GCS/MapServer"
                })
              ]
            });
      
            // 创建地图实例
            const map = new Map({
              basemap: basemap,
              ground: "world-elevation" // 关键:开启全球高程,实现真实地形
            });
      
            // 创建 3D 视图
            const view = new SceneView({
              container: mapDiv.current,
              map: map,
              camera: {
                position: {
                  x: -74.005, // 经度
                  y: 40.71,   // 纬度
                  z: 1500     // 高度 (米)
                },
                heading: 0,
                tilt: 65 // 倾斜角度,以显示 3D 效果
              }
            });
      
            viewRef.current = view;
      
            // --- 定义建筑物的弹窗 ---
            const popupTemplate = new PopupTemplate({
              title: "建筑信息",
              content: `
                <b>类型:</b> {BIN}<br>
                <b>高度:</b> {HEIGHTROOF:NumberFormat} 米<br>
                <b>建造年份:</b> {CNSTRCT_YR}
              `
            });
      
            // --- 创建要渲染的建筑图层 ---
            const buildingsLayer = new FeatureLayer({
              // 数据源:纽约市建筑 footprint
              url: "https://services.arcgis.com/V6ZHFr6zdgNZuVG0/arcgis/rest/services/NYC_Building_Footprints/FeatureServer/0",
              popupTemplate: popupTemplate,
              // 性能优化:只请求需要的字段
              outFields: ["BIN", "HEIGHTROOF", "CNSTRCT_YR"],
            });
      
            layerRef.current = buildingsLayer;
            map.add(buildingsLayer);
      
            // 清理函数:当组件卸载时销毁视图
            return () => {
              if (view) {
                view.destroy();
              }
            };
          }
        }, []); // 空依赖数组确保此 effect 只运行一次
      
      
        // 2. 处理高亮状态变化的 effect
        useEffect(() => {
          if (!layerRef.current) return;
      
          // 根据 isHighlighted 状态动态切换渲染器
          if (isHighlighted) {
            // --- 高亮状态的渲染器 ---
            // 使用 UniqueValueRenderer 来区分我们的地产和其他地产
            const highlightRenderer = new UniqueValueRenderer({
              // 字段用于区分:这里我们用“建造年份”来模拟
              // 假设 2010 年后建成的都是我们的“商业板块”
              field: "CNSTRCT_YR",
              defaultSymbol: { // 其他建筑的默认符号 (灰色半透明)
                type: "polygon-3d",
                symbolLayers: [new ExtrudeSymbol3DLayer({
                  size: 1, // 挤出高度由 visualVariables 控制
                  material: {
                    color: [180, 180, 180, 0.5]
                  }
                })]
              },
              // 定义需要高亮的特定值
              uniqueValueInfos: [{
                value: 2014, // 假如这是我们的一个商业板块
                symbol: { // 高亮符号 (亮蓝色)
                  type: "polygon-3d",
                  symbolLayers: [new ExtrudeSymbol3DLayer({
                    size: 1,
                    material: {
                      color: "#00C5FF"
                    },
                    edges: { // 添加轮廓线以突出
                      type: "solid",
                      color: [50, 50, 50, 0.7],
                      size: 1.5
                    }
                  })]
                },
                label: "Our Property Block A"
              }, {
                value: 2015, // 这是另一个商业板块
                symbol: { // 高亮符号 (亮橙色)
                  type: "polygon-3d",
                  symbolLayers: [new ExtrudeSymbol3DLayer({
                    size: 1,
                    material: {
                      color: "#FFA500"
                    }
                  })]
                },
                label: "Our Property Block B"
              }]
            });
            // 将建筑高度映射到挤出效果
            highlightRenderer.visualVariables = [{
              type: "size",
              field: "HEIGHTROOF",
              valueUnit: "meters"
            }];
            layerRef.current.renderer = highlightRenderer;
      
          } else {
            // --- 默认状态的渲染器 (所有建筑同一样式) ---
            const defaultRenderer = {
              type: "simple",
              symbol: {
                type: "polygon-3d",
                symbolLayers: [{
                  type: "extrude",
                  size: 1, // 高度由 visualVariables 决定
                  material: { color: [220, 220, 220, 0.9] },
                  edges: {
                    type: "solid",
                    color: [50, 50, 50, 0.5],
                    size: 1
                  }
                }]
              },
              visualVariables: [{
                type: "size",
                field: "HEIGHTROOF", // 使用屋顶高度字段进行挤出
                valueUnit: "meters" // 单位是米
              }]
            };
            layerRef.current.renderer = defaultRenderer;
          }
      
        }, [isHighlighted]); // 当 isHighlighted 变化时触发此 effect
      
        return <div className="map-container" ref={mapDiv}></div>;
      };
      
      export default MapContainer;
      
  2. 创建 src/components/Sidebar.jsx: 这个组件提供用户交互界面。

    JavaScript
    import React, { useRef, useEffect } from "react";
    import Map from "@arcgis/core/Map";
    import SceneView from "@arcgis/core/views/SceneView";
    import SceneLayer from "@arcgis/core/layers/SceneLayer";
    import config from "@arcgis/core/config";
    import UniqueValueRenderer from "@arcgis/core/renderers/UniqueValueRenderer"; // 用于按字段值区分渲染
    import SimpleRenderer from "@arcgis/core/renderers/SimpleRenderer";
    import MeshSymbol3D from "@arcgis/core/symbols/MeshSymbol3D";
    import FillSymbol3DLayer from "@arcgis/core/symbols/FillSymbol3DLayer";
    import PopupTemplate from "@arcgis/core/PopupTemplate";
    import { watch } from "@arcgis/core/core/reactiveUtils";
    
    // 配置API Key
    config.apiKey ="your api key";
    
    const MapContainer = ({ isHighlighted }) => {
      const mapDiv = useRef(null);
      const viewRef = useRef(null);
      const layerRef = useRef(null);
      const mapRef = useRef(null);
      const watchHandles = useRef([]);
    
      // 初始化地图和视图
      useEffect(() => {
        if (!mapDiv.current) return;
    
        const map = new Map({
          basemap: "arcgis-topographic",
          ground: "world-elevation",
        });
        mapRef.current = map;
    
        const view = new SceneView({
          container: mapDiv.current,
          map: map,
          camera: {
            position: { x: -74.005, y: 40.71, z: 1500 }, // 纽约区域
            heading: 0,
            tilt: 65,
          },
          constraints: { altitude: { min: 500, max: 5000 } },
        });
        viewRef.current = view;
    
        view
          .when(() => {
            console.log("✅ 3D 视图初始化成功");
    
            // 监听底图状态
            const basemapWatch = watch(
              () => map.basemap?.loadStatus,
              (status) => {
                console.log("📊 底图加载状态:", status);
                if (status === "failed") {
                  console.error("❌ 切换到备用底图 'osm'");
                  map.basemap = "osm";
                }
              }
            );
            watchHandles.current.push(basemapWatch);
    
            // 纽约3D建筑图层
            const buildingsLayer = new SceneLayer({
              url: "https://tiles.arcgis.com/tiles/P3ePLMYs2RVChkJx/arcgis/rest/services/Buildings_NewYork_17/SceneServer",
              popupTemplate: new PopupTemplate({
                title: "Building Information",
                content: `
                <b>Name:</b> {NAME}<br>
                <b>Construction Year:</b> {CNSTRCT_YR}<br>
                <b>Height:</b> {HEIGHTROOF:NumberFormat} ft
              `,
              }),
            });
            layerRef.current = buildingsLayer;
    
            // 监听图层加载状态
            const layerWatch = watch(
              () => buildingsLayer.loadStatus,
              (status) => {
                console.log("🏗️ 建筑图层状态:", status);
                if (status === "failed") {
                  console.error(
                    "❌ 图层加载失败:",
                    buildingsLayer.loadError?.message
                  );
                } else if (status === "loaded") {
                  console.log("✅ 建筑图层加载成功,应用初始渲染器");
                  // 图层加载完成后立即应用默认渲染器
                  applyDefaultRenderer(buildingsLayer);
                }
              }
            );
            watchHandles.current.push(layerWatch);
    
            map.add(buildingsLayer);
          })
          .catch((error) => {
            console.error("❌ 视图初始化失败:", error);
          });
    
        // 组件卸载清理
        return () => {
          watchHandles.current.forEach((handle) => handle.remove());
          if (viewRef.current) viewRef.current.destroy();
        };
      }, []);
    
      // 处理高亮状态变化
      useEffect(() => {
        const layer = layerRef.current;
        if (!layer || layer.loadStatus !== "loaded") return;
    
        if (isHighlighted) {
          console.log("🎨 应用高亮渲染器(2014和2015年建筑)");
          applyHighlightRenderer(layer);
        } else {
          console.log("🎨 恢复默认渲染器");
          applyDefaultRenderer(layer);
        }
      }, [isHighlighted]);
    
      // 默认渲染器:所有建筑显示为灰色
      const applyDefaultRenderer = (layer) => {
        layer.renderer = new SimpleRenderer({
          symbol: new MeshSymbol3D({
            symbolLayers: [
              new FillSymbol3DLayer({
                material: { color: [220, 220, 220, 0.9] }, // 默认灰色
                edges: {
                  type: "solid",
                  color: [50, 50, 50, 0.5],
                  size: 1,
                },
              }),
            ],
          }),
        });
      };
    
      // 高亮渲染器:2014年蓝色,2015年橙色,其他灰色
      const applyHighlightRenderer = (layer) => {
        layer.renderer = new UniqueValueRenderer({
          field: "CNSTRCT_YR", // 按建造年份字段区分
          defaultSymbol: new MeshSymbol3D({
            // 非目标年份建筑:灰色
            symbolLayers: [
              new FillSymbol3DLayer({
                material: { color: [180, 180, 180, 0.6] },
              }),
            ],
          }),
          // 目标年份的特殊样式
          uniqueValueInfos: [
            {
              value: 2014, // 2014年建筑
              symbol: new MeshSymbol3D({
                symbolLayers: [
                  new FillSymbol3DLayer({
                    material: { color: "#00C5FF" }, // 亮蓝色
                    edges: {
                      type: "solid",
                      color: [0, 0, 0, 0.8],
                      size: 1.5, // 加粗边框突出显示
                    },
                  }),
                ],
              }),
              label: "2014 Commercial Block", // 图例标签
            },
            {
              value: 2015, // 2015年建筑
              symbol: new MeshSymbol3D({
                symbolLayers: [
                  new FillSymbol3DLayer({
                    material: { color: "#FFA500" }, // 橙色
                    edges: {
                      type: "solid",
                      color: [0, 0, 0, 0.8],
                      size: 1.5,
                    },
                  }),
                ],
              }),
              label: "2015 Commercial Block",
            },
          ],
        });
      };
    
      return (
        <div
          ref={mapDiv}
          style={{
            width: "100vw",
            height: "100vh",
            margin: 0,
            padding: 0,
            overflow: "hidden",
          }}
        ></div>
      );
    };
    
    export default MapContainer;
    
  3. 修改 src/App.jsx: 这是主应用组件,负责整合 SidebarMapContainer,并管理高亮状态。

    JavaScript

    import React, { useState } from 'react';
    import MapContainer from './components/MapContainer';
    import Sidebar from './components/Sidebar';
    import './App.css';
    import '@arcgis/core/assets/esri/themes/dark/main.css';
    
    function App() {
      // 使用 React State 来管理高亮状态
      const [isHighlighted, setIsHighlighted] = useState(false);
    
      // 按钮点击事件处理函数
      const handleToggleHighlight = () => {
        setIsHighlighted(prevState => !prevState);
      };
    
      return (
        <div className="app-container">
          <Sidebar
            isHighlighted={isHighlighted}
            onToggleHighlight={handleToggleHighlight}
          />
          <MapContainer isHighlighted={isHighlighted} />
        </div>
      );
    }
    
    export default App;
    
  4. 修改 src/main.jsx: 确保 ArcGIS JS SDK 的 CSS 在应用加载时被正确引入。我们将它移到 App.jsx 中以确保顺序正确,所以 main.jsx 保持 Vite 的默认设置即可。

    JavaScript

    import React from 'react'
    import ReactDOM from 'react-dom/client'
    import App from './App.jsx'
    import './index.css'
    
    ReactDOM.createRoot(document.getElementById('root')).render(
      <React.StrictMode>
        <App />
      </React.StrictMode>,
    )
    

第 4 步:运行您的 3D 地图应用

  1. 重要:替换 API Key 打开 src/components/MapContainer.jsx 文件,找到下面这行代码,并将 "YOUR_API_KEY" 替换为您在第一步中获取的真实 API Key。

    JavaScript

    config.apiKey = "YOUR_API_KEY";
    
  2. 启动开发服务器 在您的终端中,确保您仍在 real-estate-3d-map 目录下,然后运行:

    Bash

    npm run dev
    
  3. 查看结果 Vite 会启动一个本地开发服务器。在浏览器中打开它提供的 URL (通常是 http://localhost:5173)。

    您现在应该能看到一个带有纽约市 3D 建筑的交互式地图。

    • 默认视图:所有建筑都以灰色 3D 形式挤出。

    • 点击建筑:会弹出一个信息框,显示其属性。

    • 点击“高亮商业板块”按钮:地图上的建筑会重新渲染,建造于 2014 年和 2015 年的建筑将分别以亮蓝色和亮橙色高亮显示,而其他建筑则变为半透明灰色。再次点击按钮可恢复原状。

方案总结与扩展

这个从0到1的实现完全遵循了您提出的设计方案:

  • 架构:使用了 React 和 Vite,通过 @arcgis/core 包集成地图,并通过 React Hooks (useRef, useEffect, useState) 管理地图生命周期和应用状态。

  • 核心组件:成功创建了 MapContainerSidebar

  • 3D 渲染与高亮:加载了 SceneView,通过 FeatureLayerrenderer 属性和 visualVariables 实现了建筑物的 3D 挤出。Highlighter 逻辑通过在 useEffect 中动态切换 UniqueValueRenderer 来实现。

  • 交互性:实现了点击弹出信息 (PopupTemplate) 和通过侧边栏按钮控制地图状态的功能。

下一步扩展建议

  • 数据导入:您可以添加文件上传功能(如使用 papaparse@loaders.gl/shapefile),让用户上传自己的 GeoJSON 或 Shapefile,并将其转换为 GraphicsLayerFeatureLayer 添加到地图上。

  • 高级状态管理:如果应用变得更复杂(例如,有多个图层、多种过滤器),可以引入 Redux Toolkit 或 Zustand 来管理全局状态。

  • UI 优化:引入 Esri 的 Calcite Design System for React,以获得一套专业且与 GIS 应用风格统一的 UI 组件。

  • 性能优化:对于超大规模的数据集,可以研究 FeatureLayer 的量化(quantization)参数或考虑使用自定义的 WebGL 渲染(如 @deck.gl/arcgis)。


网站公告

今日签到

点亮在社区的每一天
去签到