我们将从零开始,一步步使用 Vite、React 和 ArcGIS Maps SDK for JavaScript 构建一个专业的 3D 房地产地图 Web 应用。
这个教程将引导您完成以下核心步骤:
项目初始化:使用 Vite 创建一个 React 项目。
环境配置:安装并配置 ArcGIS JS SDK。
创建 3D 地图容器:构建一个 React 组件来承载 3D
SceneView
。加载 3D 建筑数据:添加一个
FeatureLayer
来显示城市建筑,并使用其属性进行 3D 挤出。实现商业板块高亮:使用
UniqueValueRenderer
根据建筑属性来区分和高亮显示特定地产。添加交互功能:创建侧边栏 UI,通过按钮动态切换高亮效果,并为建筑添加信息弹窗。
在开始之前,请确保您的电脑已安装 Node.js (LTS 版本即可)。
第 1 步:获取 ArcGIS API Key
所有使用 ArcGIS JS SDK 的应用都需要一个 API Key。
登录或创建一个免费账户。
进入您的开发者仪表盘 (Dashboard)。
点击 + New API Key 按钮创建一个新的 API Key。
复制并保存好这个 Key,我们稍后会在代码中使用它。
第 2 步:使用 Vite 创建 React 项目
打开您的终端 (Terminal 或 Command Prompt),然后执行以下命令。
创建 Vite 项目:
Bashnpm create vite@latest real-estate-3d-map -- --template react
这个命令会创建一个名为
real-estate-3d-map
的文件夹,并内置一个基础的 React 项目。进入项目目录并安装依赖:
Bashcd real-estate-3d-map npm install
安装 ArcGIS JS SDK: 这是我们的核心地图库。
Bashnpm install @arcgis/core
第 3 步:项目结构与代码实现
现在,我们来编写应用的核心代码。请按照以下步骤替换或创建相应的文件。
3.1) 配置 ArcGIS 静态资源
为了让 SDK 正确加载其样式和其他资源,我们需要进行一些配置。
安装
Bashvite-plugin-static-copy
:这个 Vite 插件可以帮助我们把 ArcGIS 的资源文件复制到最终的构建输出目录中。npm install --save-dev vite-plugin-static-copy
修改
JavaScriptvite.config.js
: 将以下内容粘贴到项目根目录的vite.config.js
文件中。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 的主题样式,并为我们的应用添加一些布局样式。
清空
CSSsrc/index.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; }
清空
CSSsrc/App.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
(地图容器)。
创建
Bashsrc/components
文件夹:mkdir src/components
创建
JavaScriptsrc/components/MapContainer.jsx
: 这是最重要的组件,负责初始化和渲染 3D 地图。我们将使用纽约市的建筑数据作为示例。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;
创建
JavaScriptsrc/components/Sidebar.jsx
: 这个组件提供用户交互界面。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;
修改
JavaScriptsrc/App.jsx
: 这是主应用组件,负责整合Sidebar
和MapContainer
,并管理高亮状态。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;
修改
JavaScriptsrc/main.jsx
: 确保 ArcGIS JS SDK 的 CSS 在应用加载时被正确引入。我们将它移到App.jsx
中以确保顺序正确,所以main.jsx
保持 Vite 的默认设置即可。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 地图应用
重要:替换 API Key 打开
JavaScriptsrc/components/MapContainer.jsx
文件,找到下面这行代码,并将"YOUR_API_KEY"
替换为您在第一步中获取的真实 API Key。config.apiKey = "YOUR_API_KEY";
启动开发服务器 在您的终端中,确保您仍在
Bashreal-estate-3d-map
目录下,然后运行:npm run dev
查看结果 Vite 会启动一个本地开发服务器。在浏览器中打开它提供的 URL (通常是
http://localhost:5173
)。您现在应该能看到一个带有纽约市 3D 建筑的交互式地图。
默认视图:所有建筑都以灰色 3D 形式挤出。
点击建筑:会弹出一个信息框,显示其属性。
点击“高亮商业板块”按钮:地图上的建筑会重新渲染,建造于 2014 年和 2015 年的建筑将分别以亮蓝色和亮橙色高亮显示,而其他建筑则变为半透明灰色。再次点击按钮可恢复原状。
方案总结与扩展
这个从0到1的实现完全遵循了您提出的设计方案:
架构:使用了 React 和 Vite,通过
@arcgis/core
包集成地图,并通过 React Hooks (useRef
,useEffect
,useState
) 管理地图生命周期和应用状态。核心组件:成功创建了
MapContainer
和Sidebar
。3D 渲染与高亮:加载了
SceneView
,通过FeatureLayer
的renderer
属性和visualVariables
实现了建筑物的 3D 挤出。Highlighter
逻辑通过在useEffect
中动态切换UniqueValueRenderer
来实现。交互性:实现了点击弹出信息 (
PopupTemplate
) 和通过侧边栏按钮控制地图状态的功能。
下一步扩展建议:
数据导入:您可以添加文件上传功能(如使用
papaparse
或@loaders.gl/shapefile
),让用户上传自己的 GeoJSON 或 Shapefile,并将其转换为GraphicsLayer
或FeatureLayer
添加到地图上。高级状态管理:如果应用变得更复杂(例如,有多个图层、多种过滤器),可以引入 Redux Toolkit 或 Zustand 来管理全局状态。
UI 优化:引入 Esri 的 Calcite Design System for React,以获得一套专业且与 GIS 应用风格统一的 UI 组件。
性能优化:对于超大规模的数据集,可以研究
FeatureLayer
的量化(quantization)参数或考虑使用自定义的 WebGL 渲染(如@deck.gl/arcgis
)。