Google Map、Solar Api

基于 React 和 @react-google-maps/api 库渲染 Google Map 地图,提供地址搜索补全、根据详细地址 / placeId 获取地址的详细信息、点击地图获取经纬度等功能。支持地图类型选择、地图多语言、多项操作控件、快捷键操作等配置。

安装库

bash 复制代码
npm i @react-google-maps/api
npm i -D @types/google.maps

加载地图资源库

ts 复制代码
import { useLoadScript } from "@react-google-maps/api";

export default function GoogleMapComponent() {
  const { isLoaded } = useLoadScript({
    // google map key
    googleMapsApiKey: '',
    // 需要额外加载其他的资源库
    libraries: [],
    // 地图语言环境
    language: "en",
  });
}

渲染地图组件

options全参数文档: 因Google文档是原生的,我们使用的组件是基于 React 封装过后的,所以使用上可能会有部分差异。

地图控件文档: developers.google.com/maps/docume...

tsx 复制代码
import { GoogleMap, useLoadScript } from "@react-google-maps/api";
import { Skeleton } from "antd";
import { useCallback, useState } from "react";
// 需要自行到官网去申请
const apiKey = "AAAAAAAAAAAAAAAAA";
export default function GoogleMapComponent() {
  const [defaultCenter] = useState({
    lat: 37.44491,
    lng: -122.13925,
  });
  const { isLoaded } = useLoadScript({
    googleMapsApiKey: apiKey,
    libraries: [],
    language: "en",
  });

  // 地图实例
  const [mapInstance, setMapInstance] = useState<google.maps.Map | null>(null);
  const onLoad = useCallback((map: google.maps.Map) => {
    setMapInstance(map);
  }, []);

  return isLoaded ? (
    <GoogleMap
      mapContainerStyle={{
        width: "100%",
        height: "100%",
        borderRadius: "0",
      }}
      // 地图初始化时的中心点
      center={defaultCenter}
      zoom={18}
      // 地图渲染完成,能拿到map实例
      onLoad={onLoad}
      options={{
        maxZoom: 22,
        mapTypeId: "hybrid", // 地图类型
        tilt: 0, // 倾斜角度
        disableDefaultUI: true, // 禁用所有控件UI, 优先级低于设置指定的控件
        fullscreenControl: true, // 全屏按钮
        mapTypeControl: true, // 地图类型切换按钮
        streetViewControl: true, // 3d道路漫游
        cameraControl: true, // 移动、缩放
        rotateControl: true, // 旋转、倾斜
        scaleControl: true, // 比例尺
        gestureHandling: "greedy", // 手势处理方式
        keyboardShortcuts: true, // 键盘快捷键操作
        disableDoubleClickZoom: true, // 禁用双击缩放
        zoomControl: true, // 缩放按钮
        // 配置地图展示的边界
        restriction: {
          // 限制地图的边界, 东南西北四个边界坐标
          latLngBounds: {
            north: 49.345786,
            east: -122.631844,
            south: 24.396308,
            west: 113.569345,
          },
          // 是否严格限制在边界内, 默认true
          strictBounds: false,
        },
      }}
    />
  ) : (
    <Skeleton active paragraph={{ rows: 4 }} />
  );
}
  • mapTypeId : 地图类型

    • roadmap 显示默认的道路地图视图。这是默认地图类型
    • terrain 根据地形信息显示自然地图(地形图), 略去了建筑物
    • satellite 显示 Google 地球卫星图像
    • hybrid 混合显示普通视图和卫星视图,展示道路、建筑物名称
  • gestureHandling : 手势操作

    • auto : (默认)手势处理是greedy还是cooperative,取决于网页是否可滚动或是否位于 iframe 中。

    • greedy : 允许所有手势,所有触摸手势和滚动事件都会平移或缩放地图。

    • cooperative : 允许部分手势。

      • 页面有滚动条时,滚动事件和单指触摸手势会滚动网页,需要按住 CtrlCommand 键才可缩放,平移地图。
      • 双指触摸手势可平移和缩放地图,双指滑动会滚动网页页面。
    • none : 禁用所有手势,用户无法通过手势平移或缩放地图,包括双击鼠标左右键的缩放事件。若要完全停用平移和缩放地图的功能,必须添加以下两个选项:gestureHandling: nonezoomContro: false,因为 zoomContro 的控件任可控制地图。

  • mapInstance : 地图实例。可以动态 获取/设置 当前 GoogleMap 组件的各项属性参数,移动到指定位置等。

Marker标记点

需要在 libraries 加载 marker 库。

文档地址:developers.google.com/maps/docume...

ts 复制代码
const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["marker"],
  language: "en",
});

添加标记点前,需等待 GoogleMap 已经加载好,拿到 mapInstance 实例。

配置项参数

参数名 类型 描述
animation google.maps.Animation 将标记添加到地图时播放哪个动画
clickable boolean 点击
draggable boolean 拖动
icon stringgoogle.maps.Icongoogle.maps.Symbol 图标svg / image可以传地址url
title string 鼠标hover时提示文字
map google.maps.Map 地图实例
opacity number (0 - 1) 透明度
optimization boolean 优化。通过将多个标记渲染为单个标记来提高性能。
visible boolean 是否可见
zIndex number 图层

Marker类

new google.maps.Marker 此方法截止 2024年2月21 日起弃用,后续不做维护,但任可以继续正常使用。

ts 复制代码
const [markerInstance, setMarkerInstance] = useState<google.maps.Marker | null>(null);

function createMarker(map?: google.maps.Map) {
    if (!map || markerInstance) return;
    const marker = new google.maps.Marker({
      // 传入map实例(渲染在哪个地图上)可以不传,通过 marker.setMap(map)设置;
      map,
      // 定位
      position: { lat: 37.44491, lng: -122.13925 },
      // 拖动
      draggable: false,
    });
    setMarkerInstance(marker);
}

修改 marker 的位置

ts 复制代码
// 获取地图中心点经纬度
const center = mapInstance.getCenter();
// 接收参数类型: google.maps.LatLng
markerInstance.setPosition(center);

移除 marker

ts 复制代码
markerInstance.setMap(null);

监听点击事件

ts 复制代码
// Add a click listener for each marker, and set up the info window.
const listener = markerInstance.addListener('click', ({ domEvent, latLng }) => {
    const { target } = domEvent;
});

移除点击事件

ts 复制代码
// Remove the listener.
google.maps.event.removeListener(listener);

AdvancedMarkerElement - 高级标记

new google.maps.marker.AdvancedMarkerElement 相对性能更好,后续开始维护这个新库。

需要设置地图 MapId 开启功能。高级标记需要地图 ID,如果缺少地图 ID,则无法加载高级标记。

文档地址:developers.google.com/maps/docume...

tsx 复制代码
const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["marker"],
  language: "en",
  mapIds: ["DEMO_MAP_ID", "CLOUD_BASED_MAP_ID"],
});


// 渲染地图组件时。这里需要配置mapId与useLoadScript里面的对应
<GoogleMap  options={{ mapId: 'CLOUD_BASED_MAP_ID' }} />
  • CLOUD_BASED_MAP_ID : Google 提供的云基础 Map ID,具有一些基础功能;

  • DEMO_MAP_ID : 为了演示提供的;

  • 自定义 Map ID : 需要在 Google Cloud Console 中创建;

PinElement

文档地址: developers.google.com/maps/docume...

PinElement 是一个通过配置项渲染出的DOM 元素,通常搭配AdvancedMarkerElement 一起使用。可以自定义形状,样式配置,传入 SVG ICON DOM 等。

创建AdvancedMarkerElement标记

ts 复制代码
const [advancedMarker, setAdvancedMarker] = useState<google.maps.marker.AdvancedMarkerElement | null>(null);
function createAdvancedMarker(map?: google.maps.Map) {
    if (!map) return;
    const latLng = {
      lat: 37.44496,
      lng: -122.1393,
    };
    const pinElement = new google.maps.marker.PinElement({
      scale: 1.2,
      background: "#FF5722",
      borderColor: "#FFFFFF",
      glyph: "🏠",
      glyphColor: "#FFFFFF",
    });
    const buildingMarker = new google.maps.marker.AdvancedMarkerElement({
      map,
      position: {
        lat: latLng.lat,
        lng: latLng.lng,
      },
      // 鼠标hover时的提示文字
      title: `建筑物中心 lat: ${latLng.lat}, lng: ${latLng.lng}`,
      // 渲染的元素,可以使用PinElement,也可以使用 document.createElement('div') 传递一个dom
      content: pinElement.element,
    });
    setAdvancedMarker(buildingMarker);
}

修改marker位置

ts 复制代码
if (advancedMarker && mapInstance) {
  // 获取地图中心点经纬度
  const center = mapInstance.getCenter();
  // 接收参数类型: google.maps.LatLng
  advancedMarker.position = center;
}

清除marker

ts 复制代码
advancedMarker && (advancedMarker.map = null);

Marker组件

用法同上, 改成了标签形式渲染。

tsx 复制代码
import { GoogleMap, Marker } from "@react-google-maps/api";


<GoogleMap>
  {/* 
    可以在这里使数组map渲染多个。
    Marker标签不能直接传递dom渲染,可以使用new marker在这里map
    */}
    <Marker
        position={defaultCenter}
        title={`lat: ${defaultCenter.lat}, lng: ${defaultCenter.lng}`}
        icon={{
          path: google.maps.SymbolPath.CIRCLE,
          scale: 10,
          fillColor: "#FF5722",
          fillOpacity: 1,
          strokeColor: "#FF5722",
        }}
      />
</GoogleMap>

信息窗口

文档地址:developers.google.com/maps/docume...

ts 复制代码
const [infoWindow, setInfoWindow] = useState<google.maps.InfoWindow | null>(null);
// 地图加载完成回调
const onLoad = useCallback((map: google.maps.Map) => {
  // 创建 InfoWindow
  const infoWindowInstance = new google.maps.InfoWindow();
  setInfoWindow(infoWindowInstance);
}, []);

窗口内容:可以传递模版字符串、DOM节点

ts 复制代码
const content = `
  <div style="padding: 10px; min-width: 200px;">
    <h4 style="margin: 0 0 10px 0;">位置信息</h4>
    <p style="margin: 5px 0;"><strong>纬度:</strong> ${lat.toFixed(6)}</p>
    <p style="margin: 5px 0;"><strong>经度:</strong> ${lng.toFixed(6)}</p>
    <button 
      id="switch-building-btn"
      style="
        background: #1890ff; 
        color: white; 
        border: none; 
        padding: 8px 16px; 
        border-radius: 4px; 
        cursor: pointer;
        margin-top: 10px;
        width: 100%;
      "
    >
      更换建筑物
    </button>
  </div>
`;
// 设置内容
infoWindow.setContent(content);

定位

ts 复制代码
// 接收参数类型为 google.maps.LatLng
// 可以通过new google.maps.LatLng(location.lat, location.lng)转换
infoWindow.setPosition({
    lat: () => number,
    lng: () => number
});

框口显示、关闭

ts 复制代码
// 显示
infoWindow.open(mapInstance);
// 关闭窗口
infoWindow.close();

点击地图,获取点击位置的经纬度,触发自定义按钮事件

tsx 复制代码
const switchBuilding = (lat: number, lng: number) => {
    console.log(lat, lng);
};
const handleMapClick = (event: google.maps.MapMouseEvent) => {
    if (!event.latLng || !infoWindow) return;
    const lat = event.latLng.lat();
    const lng = event.latLng.lng();
    
    // 创建 InfoWindow 内容
    const content = `
        <div style="padding: 10px; min-width: 200px;">
          <h4 style="margin: 0 0 10px 0;">位置信息</h4>
          <p style="margin: 5px 0;"><strong>纬度:</strong> ${lat.toFixed(6)}</p>
          <p style="margin: 5px 0;"><strong>经度:</strong> ${lng.toFixed(6)}</p>
          <button 
            id="switch-building-btn"
            style="
              background: #1890ff; 
              color: white; 
              border: none; 
              padding: 8px 16px; 
              border-radius: 4px; 
              cursor: pointer;
              margin-top: 10px;
              width: 100%;
            "
          >
            更换建筑物
          </button>
        </div>
      `;
    
    infoWindow.setContent(content);
    infoWindow.setPosition(event.latLng);
    infoWindow.open(mapInstance);
    
    // 添加事件监听器
    google.maps.event.addListenerOnce(infoWindow, 'domready', () => {
      const button = document.getElementById('switch-building-btn');
      if (button) {
        button.addEventListener('click', () => {
          switchBuilding(lat, lng);
        });
      }
    });
};
<GoogleMap
    ...props
    onClick={handleMapClick}
/>

模版字符串内,也可以直接使用

ts 复制代码
`onclick="${switchBuilding(lat, lng)}"`

但是这样会需要 switchBuilding 挂到全局作用域中,而且会立即执行一次。

地址搜索

提供地址补全、附近搜索、指定类型建筑物搜索、地点详情等功能;

文档:developers.google.com/maps/docume...

需要在 libraries 加载 places

ts 复制代码
const { isLoaded } = useLoadScript({
  googleMapsApiKey: '',
  libraries: ["places"],
  language: "en",
});

地址数据字段文档:developers.google.com/maps/docume...

地点类型文档:developers.google.com/maps/docume...

PlaceId 文档地址: developers.google.com/maps/docume...

初始化 Place 类实例;

tsx 复制代码
// 代码补全
const searchServiceRef = useRef<google.maps.places.AutocompleteService | null>(null);
// geocoder地址转换
const geocoderServiceRef = useRef<google.maps.places.PlacesService | null>(null);

const onLoad = (map: google.maps.Map) => {
    if (google.maps.places) {
      searchServiceRef.current = new google.maps.places.AutocompleteService();
      geocoderServiceRef.current = new google.maps.Geocoder();
    }
}


<GoogleMap onLoad={onLoad}></GoogleMap>

地址自动补全(根据输入文字,提供地点预测结果)

ts 复制代码
const [searchValue, setSearchValue] = useState('');
const [searchOptions, setSearchOptions] = useState<{ value: string; label: string }[]>([]);
const [isSearching, setIsSearching] = useState(false);
const handleSearch = async (value: string) => {
    setSearchValue(value);
    if (!value.trim() || !searchServiceRef.current) {
      setSearchOptions([]);
      return;
    }
    setIsSearching(true);
    try {
      searchServiceRef.current.getPlacePredictions({
        input: value,
        // 指定搜索地点类型,建筑物类型
        types: ['geocode', 'establishment'],
        language: 'zh-CN',
      }, (predictions, status) => {
        if (status === google.maps.places.PlacesServiceStatus.OK && predictions) {
          const options = predictions.map(prediction => ({
            value: prediction.place_id, // 地点id
            label: prediction.description, // 详细地址描述
          }));
          setSearchOptions(options);
        } else {
          setSearchOptions([]);
        }
      });
    } catch (error) {
      console.error('搜索地址时出错:', error);
      setSearchOptions([]);
    } finally {
      setIsSearching(false);
    }
};

地址自动补全(新接口)

文档地址: developers.google.com/maps/docume...

ts 复制代码
const token = new google.maps.places.AutocompleteSessionToken();
const { suggestions } =
await google.maps.places.AutocompleteSuggestion.fetchAutocompleteSuggestions(
  {
    sessionToken: token,
    input: value,
    language: "en-US",
  }
);
console.log(suggestions);

const options = suggestions.map((prediction) => ({
    value: prediction.placePrediction?.placeId || "",
    label: prediction.placePrediction?.text.toString() || "",
}));
setSearchOptions(options);

这里有一个会话的概念,会话令牌将用户自动补全搜索的查询和选择阶段归入不同的会话,以便进行结算费用。

通过调用 fetchFields 结束会话

ts 复制代码
let place = suggestions[0].placePrediction.toPlace(); // Get first predicted place.
await place.fetchFields({  fields: ["displayName", "formattedAddress"],});

placeId 获取地址信息

通过Place类, 根据placeId获取地址的部分简单信息

ts 复制代码
const place = new google.maps.places.Place({
  id: placeId,
  requestedLanguage: 'en'
});
await place.fetchFields({ fields: ['displayName', 'formattedAddress', 'location'] });
console.log(place.displayName);
console.log(place.formattedAddress);
console.log(place.location?.lat(), place.location?.lng());

geocode

通过 geocoderServiceRef.current?.geocode 方法,可以根据 placeIdaddress: 地址location: 经纬度获取所在位置的详细信息。这三个字段只能同时取一个。

通过上面的地址补全,拿到地址列表后,可以拿到 placeId,随后可以拿到经纬度,定位到指定区域

ts 复制代码
const handleSelectAddress = (placeId: string) => {
    geocoderServiceRef.current?.geocode({
      placeId,
      language: 'en',
    }, (results, status) => {
      if (status === google.maps.GeocoderStatus.OK && results?.[0]) {
        if (mapInstance) {
          const { lat, lng } = results[0].geometry.location;
          // 移动地图到新位置
          mapInstance.panTo({
            lat: lat(),
            lng: lng(),
          });
          mapInstance.setZoom(20); // 设置合适的缩放级别
        }
      }
    });
}

HTML部分

tsx 复制代码
import { AutoComplete } from "antd";

<AutoComplete
    value={searchValue}
    options={searchOptions}
    onSearch={handleSearch}
    onSelect={(value) => handleSelectAddress(value)}
    style={{ width: "100%" }}
    notFoundContent={isSearching ? <Spin size="small" /> : null}
    allowClear
>
<Input.Search
  size="large"
  placeholder="搜索地址..."
  loading={isSearching}
  onSearch={handleSearch}
  style={{
    borderRadius: 8,
    boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
  }}
/>
</AutoComplete>

定位到指定区域后,会触发 GoogleMap 的 地图中心点改变时间,随后再通过经纬度,更加准确的获取到具体的地址信息。

tsx 复制代码
const [loading, setLoading] = useState(false);
const handleCenterChanged = async () => {
    if (!geocoderServiceRef.current || !mapInstance) return;
    setLoading && setLoading(true);

    try {
      const center = mapInstance.getCenter(); 

      geocoderServiceRef.current.geocode(
        {
          location: center,
          language: "en"
        },
        (results, status) => {
          if (status === google.maps.GeocoderStatus.OK && results?.[0]) {
            console.log(results[0]);

            const {
              address_components,
              formatted_address,
              place_id,
              geometry,
            } = results[0] || {};
            const locationInfo = {
              ...location,
              // 地址
              address: formatted_address || "",
              // 地址id
              placeId: place_id || "",
              // 经纬度
              lat: geometry?.location?.lat(),
              lng: geometry?.location?.lng(),
              // 邮政编码
              postalCode: address_components?.find((component) =>
                component.types.includes("postal_code")
              )?.long_name,
              // 国家
              country: address_components?.find((component) =>
                component.types.includes("country")
              )?.long_name,
              // 国家简称
              countryShortName: address_components?.find((component) =>
                component.types.includes("country")
              )?.short_name,
              // 县
              county: address_components?.find((component) =>
                component.types.includes("administrative_area_level_2")
              )?.long_name,
              // 州
              state: address_components?.find((component) =>
                component.types.includes("administrative_area_level_1")
              )?.long_name,
              // 州简称
              stateShortName: address_components?.find((component) =>
                component.types.includes("administrative_area_level_1")
              )?.short_name,
              // 地区
              area: address_components?.find((component) =>
                component.types.includes("administrative_area_level_3")
              )?.long_name,
              // 城市
              city: address_components?.find((component) =>
                component.types.includes("locality")
              )?.long_name,
              // 街道号
              streetNumber: address_components?.find((component) =>
                component.types.includes("street_number")
              )?.long_name,
              // 街道名
              streetName: address_components?.find((component) =>
                component.types.includes("route")
              )?.long_name,
            };
            // 更新input搜索值
            setSearchValue(formatted_address || "");
          }
        }
      );
    } finally {
      setLoading && setLoading(false);
    }
  };
    
  
  <GoogleMap onCenterChanged={handleCenterChanged} />

基于 Google Solar Api 获取建筑物屋顶面积、年日照时长、基于Solar Api估算推荐的光伏板规格能够安装的最大太阳能光伏板数量、年发电量、安装成本、能源覆盖率等;通过 Layer 图层渲染建筑物周边各时段光照图;根据安装等光伏板数量推荐最合适的安装位置。

demo地址:solar-potential-kypkjw5jmq-uc.a.run.app/

Solar Api 文档:developers.google.com/maps/docume...

接口需要在后台开通apiKeySolar权限

建筑物太阳能发电潜力

buildingInsights可提供有关建筑物位置、尺寸和太阳能发电潜力的数据分析。具体而言,您可以获取以下方面的信息:

  • 太阳能发电潜力,包括太阳能电池板的大小、年日照量、碳抵消系数等
  • 太阳能板的位置、朝向和发电量
  • 最佳太阳能布局的估算月度电费以及相关费用和益处

Api

查找与查询点的中心点距离最近的建筑物。如果查询点周围约 50 米内没有建筑物,则返回代码为 NOT_FOUND 的错误。

参数
location 必需。API 用于查找最近已知建筑物的经纬度。
requiredQuality 可选。结果中允许的最低质量级别。系统不会返回质量低于此值的结果。如果未指定此参数,则相当于仅限于"高"质量。

Solar Api覆盖范围:developers.google.com/maps/docume...

requiredQuality - 枚举
IMAGERY_QUALITY_UNSPECIFIED 质量未知。
HIGH 太阳能数据来自于在低空拍摄的航拍图像,并以 0.1 米/像素的处理分辨率处理。
MEDIUM 太阳能数据来自高海拔拍摄的增强型航拍图像,处理分辨率为 0.25 米/像素。
BASE 太阳能数据派生自以 0.25 米/像素的像素间隔处理的增强型卫星图像。注意:只有在请求中设置了 experiments=EXPANDED_COVERAGE 时,此枚举才可用。
ts 复制代码
export async function findClosestBuilding(
  location: {lat: number, lng: number},
  apiKey: string,
): Promise<BuildingInsightsResponse> {
  const args = {
    'location.latitude': location.lat,
    'location.longitude': location.lng,
  };
  const params = new URLSearchParams({ ...args, key: apiKey, requiredQuality: 'MEDIUM' });
  // https://developers.google.com/maps/documentation/solar/reference/rest/v1/buildingInsights/findClosest
  return fetch(`https://solar.googleapis.com/v1/buildingInsights:findClosest?${params}`).then(
    async (response) => {
      const content = await response.json();
      if (response.status != 200) {
        console.error('findClosestBuilding\n', content);
        throw content;
      }
      // console.log('buildingInsightsResponse', content);
      return content;
    },
  );
}

ApiResponse响应数据

ts 复制代码
const buildObj = {
    "name": "buildings/ChIJh0CMPQW7j4ARLrRiVvmg6Vs", // 相应建筑物的资源名称,格式为 buildings/{place_id}。
    // 位于建筑物中心附近的一个点。
    "center": {
        "latitude": 37.4449439,
        "longitude": -122.13914659999998
    },
    // 建筑物影像的拍摄日期。底层图像的获取日期。这是一个近似值。
    "imageryDate": {
        "year": 2022,
        "month": 8,
        "day": 14
    },
    "postalCode": "94303", // 邮政编码(例如美国邮政编码)所涵盖的区域。
    "administrativeArea": "CA", // 包含此建筑物的行政区 1(例如,在美国,是指州)。例如,在美国,缩写可能为"MA"或"CA"。
    "statisticalArea": "06085511100", // 统计区域(例如美国人口普查区)。
    "regionCode": "US", // 相应建筑物所在国家/地区的区域代码。
    // 建筑物的太阳能发电潜力。
    "solarPotential": {
        "maxArrayPanelsCount": 1163, // 屋顶上可容纳的最大面板数量。
        "maxArrayAreaMeters2": 1903.5983, // 屋顶上可容纳的最大面板面积(以平方米为单位)。
        "maxSunshineHoursPerYear": 1802, // 屋顶上任意一点每年接收的日照小时数上限(1 太阳小时 = 每千瓦 1 千瓦时)
        "carbonOffsetFactorKgPerMwh": 428.9201, // 每兆瓦时电力产生的二氧化碳排放量(碳排放强度,以千克为单位)。
        "wholeRoofStats": { // 分配给某个屋顶细分的屋顶部分的总大小和日照百分位数。尽管名称如此,但这可能并不包括整个建筑物
            "areaMeters2": 2399.3958, // 屋顶的总面积(以平方米为单位),这是屋顶面积(考虑了倾斜度),而不是地面占地面积。。
            // 屋顶上任意一点每年接收的日照小时数百分位数。
            "sunshineQuantiles": [
                351,
                1396,
                1474,
                1527,
                1555,
                1596,
                1621,
                1640,
                1664,
                1759,
                1864
            ],
            "groundAreaMeters2": 2279.71 // 屋顶或屋顶片段覆盖的地面占地面积(以平方米为单位)。
        },
        "roofSegmentStats": [
            {
                "pitchDegrees": 11.350553, // 屋顶片段相对于理论地平面的角度。0 = 平行于地面,90 = 垂直于地面。
                // 屋顶片段所指的罗盘方向。0 = 北,90 = 东,180 = 南。对于"平坦"屋顶片段(pitchDegrees 非常接近 0),方位角未定义清楚,因此为保持一致,我们将其任意定义为 0(北)。
                "azimuthDegrees": 269.6291, // 屋顶片段相对于理论地平面的方位角。0 = 北,90 = 东,180 = 南,270 = 西。
                "stats": {
                    "areaMeters2": 452.00052,
                    "sunshineQuantiles": [
                        408,
                        1475,
                        1546,
                        1575,
                        1595,
                        1606,
                        1616,
                        1626,
                        1635,
                        1643,
                        1761
                    ],
                    "groundAreaMeters2": 443.16
                },
                "center": {
                    "latitude": 37.444972799999995,
                    "longitude": -122.13936369999999
                },
                // 相应建筑物的边界框。
                "boundingBox": {
                    "sw": {
                        "latitude": 37.444732099999996,
                        "longitude": -122.1394224
                    }, // 边界框的西南角。
                    "ne": {
                        "latitude": 37.4451909,
                        "longitude": -122.13929279999999
                    } // 边界框的东北角。
                },
                "planeHeightAtCenterMeters": 10.7835045 // 屋顶片段平面在 center 指定的点       处的高度(以米为单位,海拔高度)。与屋顶板的坡度、方位角和中心位置一起,这完全定义了屋顶板片平面。
            }
        ],
        // 屋顶太阳能面板配置。
        "solarPanelConfigs": [
            {
                "panelsCount": 4, // 屋顶上安装的面板数量。
                "yearlyEnergyDcKwh": 1819.8662, // 屋顶上安装的面板每年产生的能量(以千瓦时为单位)。
                // 此布局中至少带有 1 个面板的每个屋顶板块的生产信息。roofSegmentSummaries[i] 用于描述第 i 个屋顶片段,包括其大小、预期产量和方向。
                "roofSegmentSummaries": [
                    {
                        "pitchDegrees": 12.273684, // 倾斜角度
                        "azimuthDegrees": 179.12555, // 屋顶片段所指的罗盘方向
                        "panelsCount": 4, // 此片段安装的太阳能板数量
                        "yearlyEnergyDcKwh": 1819.8663, // 此片段安装的太阳能板每年产生的能量(以千瓦时为单位)。
                        "segmentIndex": 1 // 此片段在 roofSegmentStats 数组中的索引。
                    }
                ]
            }
        ],
        // 用于计算采用太阳能后可节省的电费,前提是每月电费和电力供应商已知。它们按每月账单金额从低到高排序。如果 Solar API 没有足够的信息来对位于相应区域的建筑物执行财务计算,则此字段将为空。
        "financialAnalyses": [
            {
                "monthlyBill": {
                    "currencyCode": "USD", // 货币代码。
                    "units": "20" // 假设的每月电费。
                },
                "panelConfigIndex": -1 // 此配置在 solarPanelConfigs 数组中的索引。-1,表示没有布局。在这种情况下,系统会省略其余子消息。
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "25"
                },
                "panelConfigIndex": -1
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "30"
                },
                "panelConfigIndex": -1
            },
            {
                "monthlyBill": {
                    "currencyCode": "USD",
                    "units": "35"
                },
                "panelConfigIndex": 0,
                "financialDetails": {
                    "initialAcKwhPerYear": 1546.8864, // 这个索引布局下安装的面板每年产生的能量(以千瓦时为单位)。
                    "remainingLifetimeUtilityBill": {
                        "currencyCode": "USD",
                        "units": "2563" // 太阳能板使用寿命内,非太阳能发电所产生的电费
                    },
                    "federalIncentive": {
                        "currencyCode": "USD",
                        "units": "1483" // 可获得的联邦补贴金额;如果用户购买(无论是否通过贷款)太阳能板,则适用此属性。以下补贴均是
                    },
                    "stateIncentive": {
                        "currencyCode": "USD" // 可从州级补贴中获得的金额;
                    },
                    "utilityIncentive": {
                        "currencyCode": "USD" // 可从公共事业补贴中获得的金额;
                    },
                    "lifetimeSrecTotal": {
                        "currencyCode": "USD" // 用户在太阳能板的使用寿命内可通过太阳能可再生能源抵扣获得的金额;
                    },
                    "costOfElectricityWithoutSolar": {
                        "currencyCode": "USD",
                        "units": "10362" // 如果用户未安装太阳能板,在整个生命周期内需要支付的电费总金额。
                    },
                    "netMeteringAllowed": true, // 如果为 true,则表示用户可以将其太阳能发电量与公共事业公司共享,并从公共事业公司获得电费。(是否允许净计量。)
                    "solarPercentage": 86.7469, // 用户由太阳能供电的百分比 (0-100)。适用于第一年,但对于未来几年,数据大致准确。
                    "percentageExportedToGrid": 52.136684 // 我们假设太阳能发电量中有百分之几(0-100)会输出到电网,该百分比基于第一季度的发电量。如果不允许净计量,这会影响计算结果。
                },
                // 租赁太阳能板的费用和好处。
                "leasingSavings": {
                    "leasesAllowed": true, // 此管辖区是否允许租赁(某些州不允许租赁)。如果此字段为 false,则应忽略此消息中的值。
                    "leasesSupported": true, // 财务计算引擎是否支持在此管辖区内使用租赁。这与 leasesAllowed 无关:在某些地区,允许租赁,但在财务模型无法处理的情况下。
                    // 预计的租赁年费用。
                    "annualLeasingCost": {
                        "currencyCode": "USD",
                        "units": "335", // 具体金额,整数位
                        "nanos": 85540771
                    },
                    // 在生命周期内节省(或未节省)多少钱。
                    "savings": {
                        // 第一年的节省金额。
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "-10"
                        },
                        // 前20年的节省金额。
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        // 前20年的节省金额的现值。
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        },
                        // 是否财务可行。
                        "financiallyViable": true,
                        // 整个面板生命周期内节省的金额
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        // 整个面板生命周期内节省的金额的现值。
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        }
                    }
                },
                // 现金购买的成本和效益
                "cashPurchaseSavings": {
                    // 税收优惠前的初始费用:必须自掏腰包支付的金额。与 upfrontCost(扣除税收优惠)相对。
                    "outOfPocketCost": {
                        "currencyCode": "USD",
                        "units": "5704"
                    },
                    // 扣除税收优惠后的初始费用:是指第一年必须支付的金额。与 outOfPocketCost(税收优惠之前)相对。
                    "upfrontCost": {
                        "currencyCode": "USD",
                        "units": "4221"
                    },
                    // 所有退税的价值。
                    "rebateValue": {
                        "currencyCode": "USD",
                        "units": "1483",
                        "nanos": 40039063
                    },
                    // 收回投资所需的年数。负值表示在生命周期内永远不会收回投资。
                    "paybackYears": 11.5,
                    "savings": {
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "325"
                        },
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "7799"
                        },
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "1083",
                            "nanos": 500244141
                        },
                        "financiallyViable": true,
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "7799"
                        },
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1083",
                            "nanos": 500244141
                        }
                    }
                },
                // 分期的成本和效益
                "financedPurchaseSavings": {
                    // 贷款的年度还款额。
                    "annualLoanPayment": {
                        "currencyCode": "USD",
                        "units": "335",
                        "nanos": 85540771
                    },
                    // 所有税款返还金额(包括联邦投资税抵免 [ITC])。
                    "rebateValue": {
                        "currencyCode": "USD"
                    },
                    // 这组计算中假设的贷款利率。
                    "loanInterestRate": 0.05,
                    // 在生命周期内节省(或未节省)多少钱。
                    "savings": {
                        "savingsYear1": {
                            "currencyCode": "USD",
                            "units": "-10"
                        },
                        "savingsYear20": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        "presentValueOfSavingsYear20": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        },
                        "financiallyViable": true,
                        "savingsLifetime": {
                            "currencyCode": "USD",
                            "units": "1098"
                        },
                        "presentValueOfSavingsLifetime": {
                            "currencyCode": "USD",
                            "units": "568",
                            "nanos": 380859375
                        }
                    }
                }
            }
        ],
        "panelCapacityWatts": 400, // 计算中使用的面板的容量(以瓦为单位)。
        "panelHeightMeters": 1.879, // 计算中使用的面板的高度(竖屏模式,以米为单位)。
        "panelWidthMeters": 1.045, // 计算中使用的面板的高度(纵向模式,以米为单位)。
        "panelLifetimeYears": 20, // 太阳能电池板的预期寿命(以年为单位)。此值用于财务计算。
        // 建筑物的统计信息。
        "buildingStats": {
            "areaMeters2": 2533.1233, // 屋顶面积
            "sunshineQuantiles": [
                348,
                1376,
                1460,
                1519,
                1550,
                1590,
                1618,
                1638,
                1662,
                1756,
                1864
            ],
            "groundAreaMeters2": 2356.03 // 屋顶占地面积
        },
        // 屋顶太阳能面板配置。
        "solarPanels": [
            {
                "center": {
                    "latitude": 37.4449659,
                    "longitude": -122.139089
                },
                // 面板的方向。
                /**
                 * 
                 * SOLAR_PANEL_ORIENTATION_UNSPECIFIED  面板方向未知。
                    LANDSCAPE   LANDSCAPE 面板的长边垂直于其所在屋顶片段的方位角方向。
                    PORTRAIT    PORTRAIT 面板的长边与其所在屋顶片段的方位角方向平行。
                 * 
                 */
                "orientation": "LANDSCAPE",
                // 此布局在一年内能捕获多少太阳能(以直流千瓦时为单位)。
                "yearlyEnergyDcKwh": 455.40714,
                "segmentIndex": 1
            }
        ],
        "imageryQuality": "HIGH", // 用于计算此建筑物数据的图像质量。
        "imageryProcessedDate": {
            "year": 2023,
            "month": 8,
            "day": 4
        } // 此图像的处理完成时间。
    }
}

建筑物边界

ts 复制代码
restriction: {
  latLngBounds: {
    north: 37.4452261, // boundingBox.ne.latitude
    east: -122.13873059999999, // boundingBox.ne.longitude
    south: 37.444723499999995, // boundingBox.sw.latitude
    west: -122.13943150000001, // boundingBox.sw.longitude
  },
  strictBounds: false,
},

屋顶光伏板铺设

本质上是在地图上绘制多边形,根据经纬度计算,将其摆放铺设在屋顶上。

如果 buildingInsights 响应不包含 solarPanelConfigs 字段,则表示建筑物已正确处理,但我们无法在屋顶上安装太阳能板。如果屋顶太小而无法放置太阳能板,或者阴影太多而导致太阳能板无法产生大量能量,则可能会发生这种情况。

需要在 libraries 加载 geometry库;

php 复制代码
const { isLoaded } = useLoadScript({
    googleMapsApiKey: '',
    libraries: ["marker", "places", "geometry"],
    language: "en",
    mapIds: ["CLOUD_BASED_MAP_ID", "DEMO_MAP_ID"],
  });

颜色转换工具函数

文档:developers.google.com/maps/docume...

  • 指定颜色范围,生成颜色深浅范围值,用于光伏板铺设时,区分发电量
tsx 复制代码
/**
 * Creates an {r, g, b} color palette from a hex list of colors.
 *
 * Each {r, g, b} value is a number between 0 and 255.
 * The created palette is always of size 256, regardless of the number of
 * hex colors passed in. Inbetween values are interpolated.
 *
 * @param  {string[]} hexColors  List of hex colors for the palette.
 * @return {{r, g, b}[]}         RGB values for the color palette.
 */
export function createPalette(hexColors: string[]): { r: number; g: number; b: number }[] {
  // Map each hex color into an RGB value.
  const rgb = hexColors.map(colorToRGB);
  // Create a palette with 256 colors derived from our rgb colors.
  const size = 256;
  const step = (rgb.length - 1) / (size - 1);
  return Array(size)
    .fill(0)
    .map((_, i) => {
      // Get the lower and upper indices for each color.
      const index = i * step;
      const lower = Math.floor(index);
      const upper = Math.ceil(index);
      // Interpolate between the colors to get the shades.
      return {
        r: lerp(rgb[lower].r, rgb[upper].r, index - lower),
        g: lerp(rgb[lower].g, rgb[upper].g, index - lower),
        b: lerp(rgb[lower].b, rgb[upper].b, index - lower),
      };
    });
}

/**
 * Convert a hex color into an {r, g, b} color.
 *
 * @param  {string} color  Hex color like 0099FF or #0099FF.
 * @return {{r, g, b}}     RGB values for that color.
 */
export function colorToRGB(color: string): { r: number; g: number; b: number } {
  const hex = color.startsWith('#') ? color.slice(1) : color;
  return {
    r: parseInt(hex.substring(0, 2), 16),
    g: parseInt(hex.substring(2, 4), 16),
    b: parseInt(hex.substring(4, 6), 16),
  };
}

/**
 * Normalizes a number to a given data range.
 *
 * @param  {number} x    Value of interest.
 * @param  {number} max  Maximum value in data range, defaults to 1.
 * @param  {number} min  Minimum value in data range, defaults to 0.
 * @return {number}      Normalized value.
 */
export function normalize(x: number, max: number = 1, min: number = 0): number {
  const y = (x - min) / (max - min);
  return clamp(y, 0, 1);
}

获取最推荐的光伏板配置所需变量(也可用于能量、费用计算)

  • buildingInsights : 当前建筑物信息
  • solarPanelConfigs :光伏板的所有排列配置情况
  • panelPaths:当前铺设的光伏板(多边形)配置
  • panelConfigIndex : 当前光伏板数量推荐的配置索引下标

以下变量可通过input输入随时修改。

  • monthlyAverageEnergyBill :月均电费
  • energyCostPerKwh :电费单价 (每千瓦时的能源成本)
  • panelCapacityWatts : kwh,光伏板容量 watts
  • dcToAcDerateInput :%,直流交流转换率(逆变器将太阳能板产生的直流电转换为家庭使用的交流电的效率)
  • installationCostPerWatt : 每瓦安装成本
tsx 复制代码
// 建筑信息
const [buildingInsights, setBuildingInsights] = useState<BuildingInsightsResponse | null>(null);

// 光伏板配置
const solarPanelConfigs = useMemo(() => {
    return buildingInsights?.solarPotential?.solarPanelConfigs || [];
}, [buildingInsights]);

// 面板能量转化率
const panelCapacityRatio = useMemo(() => {
    return (
      panelCapacityWatts /
      (buildingInsights?.solarPotential?.panelCapacityWatts || 0)
    );
}, [panelCapacityWattsInput, buildingInsights]);

// 当前铺设的光伏板数量, 在建筑信息中光伏板配置的所属下标, 建议只通过配置下标来拿到能铺设的光伏板数量,不要直接设置光伏板数量
const [panelConfigIndex, setPanelConfigIndex] = useState<number | null>(null);

// 当前铺设的光伏板路径list
const [panelPaths, setPanelPaths] = useState<(google.maps.PolygonOptions)[]>([]);

// 月均电费
const [monthlyAverageEnergyBill, setMonthlyAverageEnergyBill] = useState<number>(300);

// 每千瓦时能源成本(电费单价)
const [energyCostPerKwh, setEnergyCostPerKwh] = useState<number>(0.31);

// 光伏板容量 watts
const [panelCapacityWatts, setPanelCapacityWatts] = useState<number>(250);

// 直流交流转换率(逆变器将太阳能板产生的直流电转换为家庭使用的交流电的效率)
const [dcToAcDerateInput, setDcToAcDerateInput] = useState<number>(85);

获取推荐配置

根据上方的配置变量的值,初始化时,获取最推荐安装的光伏板配置的下标;

  • 实际年发电消耗:(月均电费 / 电费单价)* 12
  • 当前光伏板配置的消耗:年发电量 * (当前光伏板容量 / 推荐的默认光伏板容量)* (逆变器转换率 / 100)
  • 比较两个值,从所有配置中,遍历出最接近的一项,作为推荐配置。
tsx 复制代码
const findSolarConfigIndex = (data?: BuildingInsightsResponse): number => {
    // 只在panelConfigIndex为null时(初始化)才设置
    if (panelConfigIndex !== null) return panelConfigIndex;
    const res = data || buildingInsights;
    if (!res) return -1;
    
    // 直接计算年能源消耗量,不依赖solar
    const yearlyKwhEnergyConsumption =
      (monthlyAverageEnergyBill / energyCostPerKwh) * 12;
    // 默认光伏板容量
    const defaultPanelCapacity = res.solarPotential.panelCapacityWatts;
    // 找到推荐铺设的光伏板配置的所属下标
    const i = res.solarPotential.solarPanelConfigs.findIndex(
      (config) =>
        config.yearlyEnergyDcKwh *
          (panelCapacityWatts / defaultPanelCapacity) *
          (dcToAcDerateInput / 100) >=
        yearlyKwhEnergyConsumption
    );
    setPanelConfigIndex(i > -1 ? i : 0);
    return i > -1 ? i : 0;
};

获取当前配置下,光伏板的排列路径、配置

  • 遍历所有的光伏板配置;
  • 根据每块板子的 长、宽,粗略得到绘制这块板子所需要的四个点,画成一个矩形;
  • 遍历当前板子的坐标点,根据光伏板中心点经纬度坐标、排列方向、倾斜角度,得到当前板子在地图上的经纬度路径 path
  • 根据光伏板的年发电量,得到板子的颜色配置下标;
tsx 复制代码
function getPanelPaths(data?: BuildingInsightsResponse, index?: number) {
    const building = data || buildingInsights;
    const configIndex = index || panelConfigIndex;
    if (!building || configIndex === null) {
      setPanelPaths([]);
      return;
    }
    
    // 建筑物太阳能发电潜力
    const solarPotential = building.solarPotential;
    // 根据颜色值范围,生成范围内的颜色深浅值
    const palette = createPalette(['E8EAF6', '1A237E']).map(rgbToColor);
    // 最小年发电量
    const minEnergy = solarPotential.solarPanels.slice(-1)[0].yearlyEnergyDcKwh;
    // 最大年发电量
    const maxEnergy = solarPotential.solarPanels[0].yearlyEnergyDcKwh;
    
    // 获取光伏板路径
    try {
      const roofPanels = solarPotential.solarPanels.map((panel) => {
    
        // 光伏板宽高配置, 单位为米
        const [w, h] = [
          solarPotential.panelWidthMeters / 2,
          solarPotential.panelHeightMeters / 2,
        ];
        const points = [
          { x: +w, y: +h }, { x: +w, y: -h }, { x: -w, y: -h }, 
          { x: -w, y: +h }, { x: +w, y: +h }
        ];
        // 光伏板方向
        const orientation = panel.orientation == "PORTRAIT" ? 90 : 0;
        // 光伏板倾斜角度(相对于地面)
        const azimuth = solarPotential.roofSegmentStats[panel.segmentIndex].azimuthDegrees;
        // 根据年发电量,计算光伏板颜色索引
        const colorIndex = Math.round(
          normalize(panel.yearlyEnergyDcKwh, maxEnergy, minEnergy) * 255
        );
        return {
          paths: points.map(({ x, y }) =>
            google.maps.geometry.spherical.computeOffset(
              { lat: panel.center.latitude, lng: panel.center.longitude },
              Math.sqrt(x * x + y * y),
              Math.atan2(y, x) * (180 / Math.PI) + orientation + azimuth
            )
          ),
          // 边框颜色
          strokeColor: "#B0BEC5",
          strokeOpacity: 0.9,
          strokeWeight: 1,
          // 填充颜色
          fillColor: palette[colorIndex],
          fillOpacity: 0.9,
        };
      });
      console.log(roofPanels);
      setPanelPaths(roofPanels);
    } catch (error) {
      console.error(error);
      setPanelPaths([]);
    }
}

实际渲染

ts 复制代码
import { Polygon } from "@react-google-maps/api";
  • 调用 buildingInsights:findClosest 接口,获取建筑物信息,获取推荐配置,得到当前建筑物的光伏板铺设路径配置;
ts 复制代码
async function getBuildingInsights({ lat, lng }: { lat: number, lng: number }) {
    if (!lat || !lng) return;
    const buildingInsights = await findClosestBuilding({ lat, lng }, apiKey);
    setBuildingInsights(buildingInsights);
    const configIndex = findSolarConfigIndex(buildingInsights);
    getPanelPaths(buildingInsights, configIndex);
}
  • 使用 Polygon 组件渲染
tsx 复制代码
import { useMemo } from "react";

return <GoogleMap>
    (panelPaths || []).map((item, index) => {
    let roofPanelCount = panelConfigIndex !== null && panelConfigIndex >= 0 ? solarPanelConfigs[panelConfigIndex].panelsCount : 0;
      if (index < roofPanelCount) {
        return (
          <Polygon
            key={index + "roof"}
            options={{
              ...item,
              clickable: false,
              visible: true,
            }}
            paths={item.paths || []}
          />
        );
      }
  })
</GoogleMap>

动态修改光伏板数量

  • 最少的光伏板数量: solarPanelConfigs[0].panelsCount

  • 最大的光伏板数量: solarPanelConfigs[solarPanelConfigs.length - 1].panelsCount

    • 所有的板子配置: buildingInsights.solarPotential?.solarPanels?.length
  • 最大光伏板配置下标: solarPanelConfigs.length - 1

tsx 复制代码
import { Slider } from "antd";


// 光伏板配置
const solarPanelConfigs = useMemo(() => {
    return buildingInsights?.solarPotential?.solarPanelConfigs || [];
}, [buildingInsights]);



{solarPanelConfigs.length > 0 && (
    <div>
      <div>
        <div>
          当前面板数量:{" "}
          {panelConfigIndex || panelConfigIndex === 0
            ? solarPanelConfigs[panelConfigIndex].panelsCount
            : "--"}
        </div>
        <div>
          最大面板数量:{" "}
          {solarPanelConfigs[solarPanelConfigs.length - 1].panelsCount}
        </div>
      </div>
      <Slider
        defaultValue={panelConfigIndex ?? 0}
        value={panelConfigIndex ?? 0}
        step={1}
        max={solarPanelConfigs.length - 1}
        min={0}
        tooltip={{
          open: false,
        }}
        onChange={(value) => {
          setPanelConfigIndex(Number(value || 0));
        }}
      />
    </div>
  )}

常用能源数据,用于能耗、成本费用计算

  • 建筑物的太阳能发电潜力

    ts 复制代码
    const solarPotential = buildingInsights.solarPotential;

年日照时长

  • h - 小时
ts 复制代码
const maxSunshineHoursPerYear = buildingInsights.solarPotential?.maxSunshineHoursPerYear.toFixed(2)

屋顶面积

  • m² - 平方米
ts 复制代码
const roofArea = buildingInsights.solarPotential?.wholeRoofStats?.areaMeters2.toFixed(2)

CO₂ 减排量

  • Kg/MWh
ts 复制代码
const co2 = buildingInsights.solarPotential?.carbonOffsetFactorKgPerMwh.toFixed(2)

年发电量 - 单位:kwh

  • 最小值

    ts 复制代码
    const minEnergy = solarPotential.solarPanels.slice(-1)[0].yearlyEnergyDcKwh;
  • 最大值

    ts 复制代码
    const maxEnergy = solarPotential.solarPanels[0].yearlyEnergyDcKwh;
  • 当前数量的板子年发电量

    ts 复制代码
    const currEnergy = solarPanelConfigs[panelConfigIndex]?.yearlyEnergyDcKwh ?? 0) * panelCapacityRatio

安装尺寸容量

  • Kw - 千瓦
  • 计算公式:(光伏板数量 * 面板容量) / 1000
ts 复制代码
// 太阳能板安装容量 (kW)
 const installationSizeKw = useMemo(() => {
    let roofCapacity = 0;
    if (
      solarPanelConfigs &&
      solarPanelConfigs.length > 0 &&
      panelConfigIndex !== null &&
      panelConfigIndex >= 0
    ) {
      roofCapacity = (solarPanelConfigs[configId].panelsCount * panelCapacityWatts) / 1000;
    }
    return roofCapacity;
}, [panelConfigIndex, panelCapacityWatts, solarPanelConfigs]);

安装成本

  • 计算公式: 每瓦安装成本 * 安装尺寸容量 * 1000
ts 复制代码
const installationCostTotal = useMemo(() => {
    return installationCostPerWatt * installationSizeKw * 1000;
}, [installationCostPerWatt, installationSizeKw]);

以下计算需要新增一些变量,通过input输入可修改

文档地址:developers.google.com/maps/docume...

  • solarIncentives :太阳能激励措施
  • installationLifeSpan :太阳能光伏板使用寿命 - 年
  • efficiencyDepreciationFactor :太阳能板每年的效率下降幅度。 针对美国境内的地理位置使用 0.995(每年减少 0.5%)。
  • costIncreaseFactor :每年成本增加百分比。针对美国境内的位置使用 1.022(每年上调 2.2%)
  • discountRate :货币每年增值率。针对美国境内的位置使用 1.04(每年增长 4%)

能源覆盖率

  • % - 百分比

  • 计算公式:

    • 初始年交流发电量 = 当前面板配置年发电量 * 面板转换比 * 逆变器转换比
    • 每年交流电发电量(考虑效率衰减) = 初始年交流发电量 * 每年效率下降百分比 * 第几年
    • 年能源消耗 = (月均电费 / 光伏板瓦数) * 12
    • 能源覆盖率 = (第一年交流发电量 / 年能源消耗) * 100
ts 复制代码
// 初始年交流发电量 (kWh/年)
 const initialAcKwhPerYear = useMemo(() => {
    if (
      solarPanelConfigs &&
      solarPanelConfigs.length > 0 &&
      panelConfigIndex !== null &&
      panelConfigIndex >= 0
    ) {
      return (
        solarPanelConfigs[panelConfigIndex].yearlyEnergyDcKwh *
        panelCapacityRatio *
        (dcToAcDerateInput / 100)
      );
    }
    return 0;
}, [dcToAcDerateInput, panelCapacityRatio, configId, solarPanelConfigs]);

// 每年交流发电量数组 (考虑效率衰减)
const yearlyProductionAcKwh = useMemo(() => {
    return [...Array(installationLifeSpan).keys()].map(
      (year) => initialAcKwhPerYear * efficiencyDepreciationFactor ** year
    );
}, [initialAcKwhPerYear, installationLifeSpan, efficiencyDepreciationFactor]);
  
// 年能源消耗量 (kWh/年)
const yearlyKwhEnergyConsumption = useMemo(() => {
    return (monthlyAverageEnergyBill / energyCostPerKwh) * 12;
}, [monthlyAverageEnergyBill, energyCostPerKwh]);
  
// 能源覆盖率 (%)
const energyCovered = useMemo(() => {
    return (yearlyProductionAcKwh[0] / yearlyKwhEnergyConsumption) * 100;
  }, [
    yearlyProductionAcKwh,
    yearlyKwhEnergyConsumption,
    monthlyAverageEnergyBill,
]);

不使用太阳能的成本

  • 计算公式:太阳能光伏板使用周期内,每年的电费成本相加加
ts 复制代码
// 每年不使用太阳能的电费成本数组
const yearlyCostWithoutSolar = useMemo(
    () =>
      [...Array(installationLifeSpan).keys()].map(
        (year) =>
          (monthlyAverageEnergyBill * 12 * costIncreaseFactor ** year) /
          discountRate ** year
      ),
    [
      monthlyAverageEnergyBill,
      costIncreaseFactor,
      discountRate,
      installationLifeSpan,
    ]
);

  // 总的不使用太阳能的电费成本
  const totalCostWithoutSolar = useMemo(() => {
    return yearlyCostWithoutSolar.reduce((x, y) => x + y, 0);
  }, [yearlyCostWithoutSolar]);

使用太阳能的成本

  • 计算公式

    • 太阳能板安装总成本 = 每瓦安装成本 * 太阳能板安装容量 * 1000
    • 使用太阳能总成本 = 太阳能板安装总成本 + 剩余寿命期间的电费账单 - 太阳能激励措施奖励
ts 复制代码
// 太阳能板安装总成本 (美元)
const installationCostTotal = useMemo(() => {
    return installationCostPerWatt * installationSizeKw * 1000;
}, [installationCostPerWatt, installationSizeKw]);

// 剩余寿命期间的电费账单 (考虑太阳能发电后的净成本)
  const remainingLifetimeUtilityBill = useMemo(() => {
    return yearlyProductionAcKwh
      .map((yearlyKwhEnergyProduced, year) => {
        const billEnergyKwh =
          yearlyKwhEnergyConsumption - yearlyKwhEnergyProduced;
        const billEstimate =
          (billEnergyKwh * energyCostPerKwh * costIncreaseFactor ** year) /
          discountRate ** year;
        return Math.max(billEstimate, 0);
      })
      .reduce((x, y) => x + y, 0);
  }, [
    energyCostPerKwh,
    costIncreaseFactor,
    discountRate,
    yearlyKwhEnergyConsumption,
    yearlyProductionAcKwh,
  ]);

  // 使用太阳能的终身总成本 (美元)
  const totalCostWithSolar = useMemo(() => {
    return (
      installationCostTotal + remainingLifetimeUtilityBill - solarIncentives
    );
  }, [installationCostTotal, remainingLifetimeUtilityBill, solarIncentives]);

使用太阳能板,回本的年份

ts 复制代码
// 每年电费估算数组 (使用太阳能后)
const yearlyUtilityBillEstimates = useMemo(() => {
    return yearlyProductionAcKwh.map((yearlyKwhEnergyProduced, year) => {
      const billEnergyKwh =
        yearlyKwhEnergyConsumption - yearlyKwhEnergyProduced;
      const billEstimate =
        (billEnergyKwh * energyCostPerKwhInput * costIncreaseFactor ** year) /
        discountRate ** year;
      return Math.max(billEstimate, 0);
    });
  }, [
    yearlyKwhEnergyConsumption,
    energyCostPerKwhInput,
    costIncreaseFactor,
    discountRate,
    yearlyProductionAcKwh,
]);

  // 回本年份 (从安装开始计算)
const breakEvenYear = useMemo(() => {
    let costWithSolar = 0;
    const cumulativeCostsWithSolar = yearlyUtilityBillEstimates.map(
      (billEstimate, i) =>
        (costWithSolar +=
          i == 0
            ? billEstimate + installationCostTotal - solarIncentives
            : billEstimate)
    );
    let costWithoutSolar = 0;
    const cumulativeCostsWithoutSolar = yearlyCostWithoutSolar.map(
      (cost) => (costWithoutSolar += cost)
    );
    return cumulativeCostsWithSolar.findIndex(
      (costWithSolar, i) => costWithSolar <= cumulativeCostsWithoutSolar[i]
    );
  }, [
    yearlyUtilityBillEstimates,
    installationCostTotal,
    solarIncentives,
    yearlyCostWithoutSolar,
]);

Data Layers

dataLayers 端点可提供指定地点周围区域的详细太阳能信息。该端点会返回 17 个可下载的 TIFF 文件,包括:

  • 数字地表模型 (DSM)
  • RGB 复合图层(航空影像)
  • 用于标识分析边界的掩码图层
  • 年太阳辐射量,或给定表面的年产量
  • 每月太阳辐射量,或给定表面的每月产量
  • 每小时遮阳度(24 小时)

Api

ini 复制代码
curl -X GET "https://solar.googleapis.com/v1/dataLayers:get?location.latitude=37.4450&location.longitude=-122.1390&radiusMeters=100&view=FULL_LAYERS&requiredQuality=HIGH&exactQualityRequired=true&pixelSizeMeters=0.5&key=YOUR_API_KEY"
参数 类型 描述
location LatLng 必需。获取数据的地区中心的经纬度
radiusMeters number 必需。半径(以米为单位),用于定义应返回数据的中心点周围的区域。此值存在以下限制:- 您可以随时指定不超过 100 米的任何值。
  • 可以指定超过 1 亿的值,前提是 radiusMeters <= pixelSizeMeters * 1000

  • 对于大于 175 米的值,请求中的 DataLayerView 不得包含月度通量或每小时阴影。 | | view | DataLayerView | 可选。要返回的太阳能信息的子集类型。FULL_LAYERS 全部返回。 | | requiredQuality | ImageryQuality | 可选。结果中允许的最低质量级别。不指定,默认为 HIGH 高质量。 | | pixelSizeMeters | number | 可选。要返回的数据的最小比例(以每像素米为单位)。支持0.1、0.25、0.5、1.0 | | exactQualityRequired | boolean | 可选。是否要求图像质量完全一致。若指定 requiredQualityMEDIUM 中等,但是能查到 HIGH 质量,会优先返回HIGH 质量,指定了 exactQualityRequired 则只会返回 MEDIUM 。 |

ApiResponse响应数据

详细文档:

developers.google.com/maps/docume...

注意 : 响应数据中的网址仅在初始请求后一小时内有效。如需访问超出此时间范围的网址,您必须再次向 dataLayers 端点发送请求。

地图叠加层

文档:developers.google.com/maps/docume...

使用 GroundOverlay 对象,可以在地图上叠加覆盖任意层内容,只需要指定边界范围、覆盖内容即可。

叠加 GeoJsondevelopers.google.com/maps/docume...

搭配 Layers Api 叠加日照、阴影面积等图层;

ts 复制代码
// https://developers.google.com/maps/documentation/solar/reference/rest/v1/dataLayers
type LayerId = 'mask' | 'dsm' | 'rgb' | 'annualFlux' | 'monthlyFlux' | 'hourlyShade';
  
// 图层类型
const layerOption: { label: string; value: LayerId | "none" }[] = [
  { label: "无图层", value: "none" },
  { label: "屋顶遮罩", value: "mask" },
  { label: "数字表面模型", value: "dsm" },
  { label: "航空图像", value: "rgb" },
  { label: "年日照", value: "annualFlux" },
  { label: "月日照", value: "monthlyFlux" },
  { label: "小时阴影", value: "hourlyShade" },
];

// 月份名称
const monthNames = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

// 当前图层
const [layerId, setLayerId] = useState<LayerId | "none">("none");
// 当前图层实例信息
const [layerInstance, setLayerInstance] = useState<any>(null);
// layer Api Data
const [layerData, setLayerData] = useState<any>(null);
// GroundOverlay对象(盖在图层上的数据)
const [overlays, setOverlays] = useState<google.maps.GroundOverlay[] | null>(null);

const [month, setMonth] = useState(0);
const [day, setDay] = useState(14);
const [hour, setHour] = useState(0);

// 是否展示遮罩层
const [showRoof, setShowRoof] = useState(true);

请求接口

需要先拿到一个地点的 经纬度 ,这里我使用 buildingInsights:findClosest 接口拿到建筑物信息,能够获取到建筑物的 中心点,建筑物边界信息 ,随后调取 Layer Api 拿到 TIFF 文件。

ts 复制代码
async function getLayerData(
_layerId: LayerId | "none",
buildData: BuildingInsightsResponse,
) {
    // 中心点经纬度    
    const center = buildData.center;
    // 边界点经纬度
    const ne = buildData.boundingBox.ne;
    const sw = buildData.boundingBox.sw;
    // 计算展示内容半径
    const diameter = google.maps.geometry.spherical.computeDistanceBetween(
      new google.maps.LatLng(ne.latitude, ne.longitude),
      new google.maps.LatLng(sw.latitude, sw.longitude)
    );
    const radius = Math.ceil(diameter / 2);
    // radius半径可以自定义设置,可以不需要计算
    const res = await getDataLayerUrls(center, radius, apiKey);
    setLayerData(res);
    return res;
}

Solar Api下载 TIFF 像素数据

需要安装三个库,用来解析数据。

  • geotiff
  • geotiff-geokeys-to-proj4
  • proj4
ts 复制代码
import * as geotiff from 'geotiff';
import * as geokeysToProj4 from 'geotiff-geokeys-to-proj4';
import proj4 from 'proj4';

/**
 * Downloads the pixel values for a Data Layer URL from the Solar API.
 *
 * @param  {string} url        URL from the Data Layers response.
 * @param  {string} apiKey     Google Cloud API key.
 * @return {Promise<GeoTiff>}  Pixel values with shape and lat/lon bounds.
 */
export async function downloadGeoTIFF(url: string, apiKey: string): Promise<GeoTiff> {
  console.log(`Downloading data layer: ${url}`);

  // Include your Google Cloud API key in the Data Layers URL.
  const solarUrl = url.includes('solar.googleapis.com') ? url + `&key=${apiKey}` : url;
  const response = await fetch(solarUrl);
  if (response.status != 200) {
    const error = await response.json();
    console.error(`downloadGeoTIFF failed: ${url}\n`, error);
    throw error;
  }

  // Get the GeoTIFF rasters, which are the pixel values for each band.
  const arrayBuffer = await response.arrayBuffer();
  const tiff = await geotiff.fromArrayBuffer(arrayBuffer);
  const image = await tiff.getImage();
  const rasters = await image.readRasters();

  // Reproject the bounding box into lat/lon coordinates.
  const geoKeys = image.getGeoKeys();
  const projObj = geokeysToProj4.toProj4(geoKeys);
  const projection = proj4(projObj.proj4, 'WGS84');
  const box = image.getBoundingBox();
  const sw = projection.forward({
    x: box[0] * projObj.coordinatesConversionParameters.x,
    y: box[1] * projObj.coordinatesConversionParameters.y,
  });
  const ne = projection.forward({
    x: box[2] * projObj.coordinatesConversionParameters.x,
    y: box[3] * projObj.coordinatesConversionParameters.y,
  });

  return {
    // Width and height of the data layer image in pixels.
    // Used to know the row and column since Javascript
    // stores the values as flat arrays.
    width: rasters.width,
    height: rasters.height,
    // Each raster reprents the pixel values of each band.
    // We convert them from `geotiff.TypedArray`s into plain
    // Javascript arrays to make them easier to process.
    rasters: [...Array(rasters.length).keys()].map((i) =>
      Array.from(rasters[i] as geotiff.TypedArray),
    ),
    // The bounding box as a lat/lon rectangle.
    bounds: {
      north: ne.y,
      south: sw.y,
      east: ne.x,
      west: sw.x,
    },
  };
}
// [END solar_api_download_geotiff]

根据不同的图层类型,拿到对应的图层数据,通过 render 函数渲染

渲染到HTML Canvas画布

  • 对应图层类型的色值范围
ini 复制代码
 export const binaryPalette = ['212121', 'B3E5FC'];
 export const rainbowPalette = ['3949AB', '81D4FA', '66BB6A', 'FFE082', 'E53935'];
 export const ironPalette = ['00000A', '91009C', 'E64616', 'FEB400', 'FFFFF6'];
 export const sunlightPalette = ['212121', 'FFCA28'];
RGB GeoTiff 渲染函数
ts 复制代码
// [START visualize_render_rgb]
/**
 * Renders an RGB GeoTiff image into an HTML canvas.
 *
 * The GeoTiff image must include 3 rasters (bands) which
 * correspond to [Red, Green, Blue] in that order.
 *
 * @param  {GeoTiff} rgb   GeoTiff with RGB values of the image.
 * @param  {GeoTiff} mask  Optional mask for transparency, defaults to opaque.
 * @return {HTMLCanvasElement}  Canvas element with the rendered image.
 */
export function renderRGB(rgb: GeoTiff, mask?: GeoTiff): HTMLCanvasElement {
  // Create an HTML canvas to draw the image.
  // https://www.w3schools.com/tags/canvas_createimagedata.asp
  const canvas = document.createElement('canvas');

  // Set the canvas size to the mask size if it's available,
  // otherwise set it to the RGB data layer size.
  canvas.width = mask ? mask.width : rgb.width;
  canvas.height = mask ? mask.height : rgb.height;

  // Since the mask size can be different than the RGB data layer size,
  // we calculate the "delta" between the RGB layer size and the canvas/mask
  // size. For example, if the RGB layer size is the same as the canvas size,
  // the delta is 1. If the RGB layer size is smaller than the canvas size,
  // the delta would be greater than 1.
  // This is used to translate the index from the canvas to the RGB layer.
  const dw = rgb.width / canvas.width;
  const dh = rgb.height / canvas.height;

  // Get the canvas image data buffer.
  const ctx = canvas.getContext('2d')!;
  const img = ctx.getImageData(0, 0, canvas.width, canvas.height);

  // Fill in every pixel in the canvas with the corresponding RGB layer value.
  // Since Javascript doesn't support multidimensional arrays or tensors,
  // everything is stored in flat arrays and we have to keep track of the
  // indices for each row and column ourselves.
  for (let y = 0; y < canvas.height; y++) {
    for (let x = 0; x < canvas.width; x++) {
      // RGB index keeps track of the RGB layer position.
      // This is multiplied by the deltas since it might be a different
      // size than the image size.
      const rgbIdx = Math.floor(y * dh) * rgb.width + Math.floor(x * dw);
      // Mask index keeps track of the mask layer position.
      const maskIdx = y * canvas.width + x;

      // Image index keeps track of the canvas image position.
      // HTML canvas expects a flat array with consecutive RGBA values.
      // Each value in the image buffer must be between 0 and 255.
      // The Alpha value is the transparency of that pixel,
      // if a mask was not provided, we default to 255 which is opaque.
      const imgIdx = y * canvas.width * 4 + x * 4;
      img.data[imgIdx + 0] = rgb.rasters[0][rgbIdx]; // Red
      img.data[imgIdx + 1] = rgb.rasters[1][rgbIdx]; // Green
      img.data[imgIdx + 2] = rgb.rasters[2][rgbIdx]; // Blue
      img.data[imgIdx + 3] = mask // Alpha
        ? mask.rasters[0][maskIdx] * 255
        : 255;
    }
  }

  // Draw the image data buffer into the canvas context.
  ctx.putImageData(img, 0, 0);
  return canvas;
}
// [END visualize_render_rgb]
GeoTiff 渲染函数
ts 复制代码
// [START visualize_render_palette]
/**
 * Renders a single value GeoTiff image into an HTML canvas.
 *
 * The GeoTiff image must include 1 raster (band) which contains
 * the values we want to display.
 *
 * @param  {GeoTiff}  data    GeoTiff with the values of interest.
 * @param  {GeoTiff}  mask    Optional mask for transparency, defaults to opaque.
 * @param  {string[]} colors  Hex color palette, defaults to ['000000', 'ffffff'].
 * @param  {number}   min     Minimum value of the data range, defaults to 0.
 * @param  {number}   max     Maximum value of the data range, defaults to 1.
 * @param  {number}   index   Raster index for the data, defaults to 0.
 * @return {HTMLCanvasElement}  Canvas element with the rendered image.
 */
export function renderPalette({
  data,
  mask,
  colors,
  min,
  max,
  index,
}: {
  data: GeoTiff;
  mask?: GeoTiff;
  colors?: string[];
  min?: number;
  max?: number;
  index?: number;
}): HTMLCanvasElement {
  // First create a palette from a list of hex colors.
  const palette = createPalette(colors ?? ['000000', 'ffffff']);
  // Normalize each value of our raster/band of interest into indices,
  // such that they always map into a value within the palette.
  const indices = data.rasters[index ?? 0]
    .map((x) => normalize(x, max ?? 1, min ?? 0))
    .map((x) => Math.round(x * (palette.length - 1)));
  return renderRGB(
    {
      ...data,
      // Map each index into the corresponding RGB values.
      rasters: [
        indices.map((i: number) => palette[i].r),
        indices.map((i: number) => palette[i].g),
        indices.map((i: number) => palette[i].b),
      ],
    },
    mask,
  );
}

/**
 * Creates an {r, g, b} color palette from a hex list of colors.
 *
 * Each {r, g, b} value is a number between 0 and 255.
 * The created palette is always of size 256, regardless of the number of
 * hex colors passed in. Inbetween values are interpolated.
 *
 * @param  {string[]} hexColors  List of hex colors for the palette.
 * @return {{r, g, b}[]}         RGB values for the color palette.
 */
export function createPalette(hexColors: string[]): { r: number; g: number; b: number }[] {
  // Map each hex color into an RGB value.
  const rgb = hexColors.map(colorToRGB);
  // Create a palette with 256 colors derived from our rgb colors.
  const size = 256;
  const step = (rgb.length - 1) / (size - 1);
  return Array(size)
    .fill(0)
    .map((_, i) => {
      // Get the lower and upper indices for each color.
      const index = i * step;
      const lower = Math.floor(index);
      const upper = Math.ceil(index);
      // Interpolate between the colors to get the shades.
      return {
        r: lerp(rgb[lower].r, rgb[upper].r, index - lower),
        g: lerp(rgb[lower].g, rgb[upper].g, index - lower),
        b: lerp(rgb[lower].b, rgb[upper].b, index - lower),
      };
    });
}

/**
 * Convert a hex color into an {r, g, b} color.
 *
 * @param  {string} color  Hex color like 0099FF or #0099FF.
 * @return {{r, g, b}}     RGB values for that color.
 */
export function colorToRGB(color: string): { r: number; g: number; b: number } {
  const hex = color.startsWith('#') ? color.slice(1) : color;
  return {
    r: parseInt(hex.substring(0, 2), 16),
    g: parseInt(hex.substring(2, 4), 16),
    b: parseInt(hex.substring(4, 6), 16),
  };
}

/**
 * Normalizes a number to a given data range.
 *
 * @param  {number} x    Value of interest.
 * @param  {number} max  Maximum value in data range, defaults to 1.
 * @param  {number} min  Minimum value in data range, defaults to 0.
 * @return {number}      Normalized value.
 */
export function normalize(x: number, max: number = 1, min: number = 0): number {
  const y = (x - min) / (max - min);
  return clamp(y, 0, 1);
}

/**
 * Calculates the linear interpolation for a value within a range.
 *
 * @param  {number} x  Lower value in the range, when `t` is 0.
 * @param  {number} y  Upper value in the range, when `t` is 1.
 * @param  {number} t  "Time" between 0 and 1.
 * @return {number}    Inbetween value for that "time".
 */
export function lerp(x: number, y: number, t: number): number {
  return x + t * (y - x);
}

/**
 * Clamps a value to always be within a range.
 *
 * @param  {number} x    Value to clamp.
 * @param  {number} min  Minimum value in the range.
 * @param  {number} max  Maximum value in the range.
 * @return {number}      Clamped value.
 */
export function clamp(x: number, min: number, max: number): number {
  return Math.min(Math.max(x, min), max);
}
// [END visualize_render_palette]
获取对应类型的render
ts 复制代码
export interface Palette {
   colors: string[];
   min: string;
   max: string;
 }
 
 export interface Layer {
   id: LayerId;
   render: (showRoofOnly: boolean, month: number, day: number) => HTMLCanvasElement[];
   bounds: Bounds;
   palette?: Palette;
 }
 
 export async function getLayer(
   layerId: LayerId,
   urls: DataLayersResponse,
   googleMapsApiKey: string,
 ): Promise<Layer> {
   const get: Record<LayerId, () => Promise<Layer>> = {
     mask: async () => {
       const mask = await downloadGeoTIFF(urls.maskUrl, googleMapsApiKey);
       console.log(mask);
       
       const colors = binaryPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'No roof',
           max: 'Roof',
         },
         render: (showRoofOnly) => {
          console.log(showRoofOnly);
          
          return [
            renderPalette({
              data: mask,
              mask: showRoofOnly ? mask : undefined,
              colors: colors,
            }),
          ]
         },
       };
     },
     dsm: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.dsmUrl, googleMapsApiKey),
       ]);
       const sortedValues = Array.from(data.rasters[0]).sort((x, y) => x - y);
       const minValue = sortedValues[0];
       const maxValue = sortedValues.slice(-1)[0];
       const colors = rainbowPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: `${minValue.toFixed(1)} m`,
           max: `${maxValue.toFixed(1)} m`,
         },
         render: (showRoofOnly) => [
           renderPalette({
             data: data,
             mask: showRoofOnly ? mask : undefined,
             colors: colors,
             min: sortedValues[0],
             max: sortedValues.slice(-1)[0],
           }),
         ],
       };
     },
     rgb: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.rgbUrl, googleMapsApiKey),
       ]);
       return {
         id: layerId,
         bounds: mask.bounds,
         render: (showRoofOnly) => [renderRGB(data, showRoofOnly ? mask : undefined)],
       };
     },
     annualFlux: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.annualFluxUrl, googleMapsApiKey),
       ]);
       const colors = ironPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shady',
           max: 'Sunny',
         },
         render: (showRoofOnly) => [
           renderPalette({
             data: data,
             mask: showRoofOnly ? mask : undefined,
             colors: colors,
             min: 0,
             max: 1800,
           }),
         ],
       };
     },
     monthlyFlux: async () => {
       const [mask, data] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         downloadGeoTIFF(urls.monthlyFluxUrl, googleMapsApiKey),
       ]);
       const colors = ironPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shady',
           max: 'Sunny',
         },
         render: (showRoofOnly) =>
           [...Array(12).keys()].map((month) =>
             renderPalette({
               data: data,
               mask: showRoofOnly ? mask : undefined,
               colors: colors,
               min: 0,
               max: 200,
               index: month,
             }),
           ),
       };
     },
     hourlyShade: async () => {
       const [mask, ...months] = await Promise.all([
         downloadGeoTIFF(urls.maskUrl, googleMapsApiKey),
         ...urls.hourlyShadeUrls.map((url) => downloadGeoTIFF(url, googleMapsApiKey)),
       ]);
       const colors = sunlightPalette;
       return {
         id: layerId,
         bounds: mask.bounds,
         palette: {
           colors: colors,
           min: 'Shade',
           max: 'Sun',
         },
         render: (showRoofOnly, month, day) =>
           [...Array(24).keys()].map((hour) =>
             renderPalette({
               data: {
                 ...months[month],
                 rasters: months[month].rasters.map((values) =>
                   values.map((x) => x & (1 << (day - 1))),
                 ),
               },
               mask: showRoofOnly ? mask : undefined,
               colors: colors,
               min: 0,
               max: 1,
               index: hour,
             }),
           ),
       };
     },
   };
   try {
     return get[layerId]();
   } catch (e) {
     console.error(`Error getting layer: ${layerId}\n`, e);
     throw e;
   }
 }

通过GroundOverlay实际渲染

因为有 月日照、小时阴影 ,所以渲染图层可能会有多个, overlays 用的是数组。

  • 清除渲染
ts 复制代码
// 清除单个
overlay.setMap(null)

// 多个
overlays && overlays.map((overlay) => overlay.setMap(null));
  • 渲染函数
ts 复制代码
async function DrawerLayer(_layerId: LayerId | "none", res: DataLayersResponse) {
    if (_layerId == "none" || !res) {
        clearLayer();
        return;
    };

    let layerRes: any;
    // 当前在渲染的,就不需要重新在解析一次数据了
    if (layerInstance && layerInstance.id === _layerId) {
      layerRes = layerInstance;
    } else {
      layerRes = await getLayer(_layerId, res, apiKey);
      setLayerInstance(layerRes);
    }

    const bounds = layerRes.bounds;
    // 渲染前,将之前的图层清掉
    overlays && overlays.map((overlay) => overlay.setMap(null));

    // 重新获取图层信息,再次渲染
    // showRoof 控制是否需要遮罩层
    const overlaysPos = layerRes.render(showRoof, month, day)
        .map((canvas) => new google.maps.GroundOverlay(canvas.toDataURL(), bounds));
    if (!["monthlyFlux", "hourlyShade"].includes(layerRes.id)) {
      overlaysPos[0].setMap(map);
    } else {
        // 根据当前的月份 / 小时,找到对应的图层信息,并渲染
      if (layerInstance.id == "monthlyFlux") {
        overlays.map((overlay, i) => overlay.setMap(i == month ? map : null));
      } else if (layerInstance.id == "hourlyShade") {
        overlays.map((overlay, i) => overlay.setMap(i == hour ? map : null));
      }
    }
    
    // 存储各图层信息
    setOverlays(overlaysPos);
  }

Drawer 绘图框选

DrawingManager 类提供一个图形界面,以供用户在地图上绘制多边形、矩形、多段线、圆形和标记。

绘图库示例文档:developers.google.com/maps/docume...

详细参数文档:developers.google.com/maps/docume...

初始化

需要在 libraries 加载 drawing

ts 复制代码
const { isLoaded } = useLoadScript({
    googleMapsApiKey: apiKey,
    libraries: ["places", "geometry", "marker", "drawing"],
    language: "en",
    mapIds: ["DEMO_MAP_ID"],
});

添加 drawing 参数后,即可创建 DrawingManager 对象,

ts 复制代码
const drawingManager = new google.maps.drawing.DrawingManager({
    drawingControl: true,
})
drawingManager.setMap(mapInstance);
  • drawingControl 开启工具栏

这里目前的需求只需要绘制多边形即可支撑,便关闭 drawingControl,只配置 polygonOptions即可。

ts 复制代码
const [drawingInstance, setDrawingInstance] = useState<google.maps.drawing.DrawingManager | null>(null);

const drawingManager = new google.maps.drawing.DrawingManager({
  drawingMode: null,
  drawingControl: false,
  polygonOptions: {
    fillColor: '#4CAF50',
    fillOpacity: 0.3,
    strokeWeight: 2,
    strokeColor: '#4CAF50',
    clickable: true,
    editable: true,
    zIndex: 1000,
  },
});

drawingManager.setMap(mapInstance);
setDrawingManager(drawingManager);

绘画过程

  • 开始绘画

    • 保存绘画状态
    • 设置绘画模式
ts 复制代码
const [isDrawing, setIsDrawing] = useState(false);
const startDrawing = useCallback(() => {
    if (drawingInstance) {
      // 绘画状态
      setIsDrawing(true);
      // 绘制模式(形状)
      drawingInstance.setDrawingMode(google.maps.drawing.OverlayType.POLYGON);
    }
}, [drawingInstance]);
  • 结束绘画

    • 监听 drawingInstance 绘画工具的 多边形-绘制完成 事件
    • 回调函数返回绘制完成的区域路径信息;
    • 存储到 areas 区域列表中,停止绘画状态;
    • 给当前绘画的区域添加点击事件,可用于 正选/ 反选区域。
ts 复制代码
interface Area {
  id: string;
  polygon: google.maps.Polygon;
  isExcluded: boolean; // 可用区域 / 排除区域
}

// 存储已经绘制完成的多个区域的路径信息
const [areas, setAreas] = useState<Area[]>([]);

useEffect(() => {
    // 监听绘制完成事件
    const polygonCompleteListener = google.maps.event.addListener(
      drawingInstance, 
      'polygoncomplete', 
      (polygon: google.maps.Polygon) => {
         
        // 拿到绘制完成的多边型路径
        const newArea: Area = {
          id: Date.now().toString(),
          polygon: polygon,
          isExcluded: false,
        };
        setAreas(prev => [...prev, newArea]);
        // 修改绘画状态,停止绘画动作
        setIsDrawingEnabled(false);
        drawingInstance.setDrawingMode(null);
        
        
        // 给当前绘画完成的区域,添加点击事件切换区域类型
        const clickListener = polygon.addListener('click', () => {
          setAreas(prev => prev.map(area => {
            if (area.id === newArea.id) {
              const updatedArea = { ...area, isExcluded: !area.isExcluded };
              
              // 更新多边形样式
              polygon.setOptions({
                fillColor: updatedArea.isExcluded ? '#F44336' : '#4CAF50',
                strokeColor: updatedArea.isExcluded ? '#F44336' : '#4CAF50',
              });
              
              return updatedArea;
            }
            return area;
          }));
        });
      }
    );
    
    return () => {
      google.maps.event.removeListener(polygonCompleteListener);
      google.maps.event.removeListener(clickListener);
      drawingInstance.setMap(null);
    };
}, [mapInstance, drawingInstance])
  • 清除区域
ts 复制代码
const clearAll = useCallback(() => {
    areas.forEach(area => {
      area.polygon.setMap(null);
    });
    setAreas([]);
}, [areas]);

LatLng 转 Polygon

  • 通过经纬度,创建一个 new google.maps.Polygon 多边形
ts 复制代码
// 坐标点类型定义
export type Coordinate = { lat: number; lng: number } | google.maps.LatLng;

/**
 * 坐标点数组转Polygon多边形
 * 
 * @param coordinates - 坐标点数组,支持 { lat, lng } 对象或 google.maps.LatLng 实例
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param map - Google Maps 实例,可选,用于设置多边形到地图上
 * @returns 创建的 Polygon 实例
 */
export const coordinatesToPolygon = (
  coordinates: Coordinate[],
  isExcluded: boolean = false,
  polygonOptions?: google.maps.PolygonOptions,
  map?: google.maps.Map
): google.maps.Polygon => {
  if (coordinates.length < 3) {
    throw new Error('至少需要3个坐标点才能形成多边形');
  }
  // 转换坐标格式
  const paths = coordinates.map(coord => {
    if (coord instanceof google.maps.LatLng) {
      return coord;
    }
    return new google.maps.LatLng(coord.lat, coord.lng);
  });
  // 创建多边形
  const polygon = new google.maps.Polygon({
    paths: paths,
    ...{
      fillColor: isExcluded ? '#F44336' : '#4CAF50',
      fillOpacity: 0.3,
      strokeWeight: 2,
      strokeColor: isExcluded ? '#F44336' : '#4CAF50',
      clickable: true,
      editable: true,
      zIndex: 1000,
      ...polygonOptions
    }
  });
  // 如果提供了地图实例,将多边形添加到地图上
  if (map) {
    polygon.setMap(map);
  }
  return polygon;
};

Polygon 转 GeoJson

  • 单个 Polygon 转 GeoJson
ts 复制代码
/**
 * 多边形转GeoJSON
 * 
 * @param polygon - Google Maps Polygon 实例
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param properties - 额外的属性信息
 * @returns GeoJSON Feature 对象
 */
export const polygonToGeoJSON = (
  polygon: google.maps.Polygon,
  isExcluded: boolean = false,
  properties: Record<string, any> = {}
): GeoJSONFeature => {
  const path = polygon.getPath();
  // 确保坐标格式为 [经度, 纬度](标准 GeoJSON 格式)
  const coordinates = path.getArray().map(latLng => [latLng.lng(), latLng.lat()]);
  
  return {
    type: "Feature",
    geometry: {
      type: "Polygon",
      coordinates: [coordinates] // 注意:GeoJSON要求坐标数组的数组
    },
    properties: {
      isExcluded: isExcluded,
      areaType: isExcluded ? "excluded" : "allowed",
      ...properties
    }
  };
};
  • 多个 Polygon 转 GeoJson
ts 复制代码
/**
 * 多个多边形转GeoJSON(支持MultiPolygon)
 * 
 * @param polygons - Google Maps Polygon 实例数组
 * @param isExcluded - 是否为排除区域,默认为 false(允许区域)
 * @param properties - 额外的属性信息
 * @returns GeoJSON Feature 对象(如果只有一个多边形返回Polygon,多个返回MultiPolygon)
 */
export const polygonListToGeoJSON = (
  polygons: google.maps.Polygon[],
  isExcluded: boolean = false,
  properties: Record<string, any> = {}
): GeoJSONFeature => {
  if (polygons.length === 0) {
    throw new Error('至少需要一个多边形');
  }

  if (polygons.length === 1) {
    // 单个多边形返回 Polygon 类型
    return polygonToGeoJSON(polygons[0], isExcluded, properties);
  }

  // 多个多边形返回 MultiPolygon 类型
  // MultiPolygon 格式:[[[polygon1_coords]], [[polygon2_coords]], ...]
  const coordinates = polygons.map(polygon => {
    const path = polygon.getPath();
    const polygonCoords = path.getArray().map(latLng => [latLng.lng(), latLng.lat()]);
    return [polygonCoords];
  });

  return {
    type: "Feature",
    geometry: {
      type: "MultiPolygon",
      coordinates: coordinates
    },
    properties: {
      isExcluded: isExcluded,
      areaType: isExcluded ? "excluded" : "allowed",
      description: `${isExcluded ? "排除" : "允许"}安装太阳能面板的多边形区域`,
      ...properties
    }
  };
};

GeoJson 转 Polygon

  • 判断geoJSON类型, 支持 FeatureCollection 或单个 Feature ,拿到 geoJsonfeatures 数组;

  • feature.properties 添加自定义属性

  • 根据 geometry.type,根据 PolygonMultiPolygon 区分处理逻辑;

  • 拿到 coordinates点位数据,执行 coordinatesToPolygon 根据经纬度渲染区域函数;

    • GeoJSON 使用 [经度, 纬度] 格式
    • Google Maps 使用 [纬度, 经度] 格式
    • 需要转换,调转一下位置
  • 坐标转换函数

ts 复制代码
/**
 * 智能检测坐标格式并转换为 LatLng
 * @param coordinates - 坐标数组
 * @returns 转换后的 LatLng 数组
 */
const convertCoordinatesToLatLngs = (coordinates: number[][]): google.maps.LatLng[] => {
  if (coordinates.length < 3) {
    throw new Error(`坐标点不足: ${coordinates.length} 个点,至少需要3个点`);
  }

  // 智能检测坐标格式:如果第一个坐标的纬度在 -90 到 90 之间,且经度在 -180 到 180 之间
  // 则认为是 [纬度, 经度] 格式,否则认为是 [经度, 纬度] 格式
  const firstCoord = coordinates[0] as number[];
  const isLatLngFormat = firstCoord[0] >= -90 && firstCoord[0] <= 90 && 
                        firstCoord[1] >= -180 && firstCoord[1] <= 180;
  
  return coordinates.map(coord => {
    if (isLatLngFormat) {
      // [纬度, 经度] 格式
      return new google.maps.LatLng(coord[0], coord[1]);
    } else {
      // [经度, 纬度] 格式(标准 GeoJSON)
      return new google.maps.LatLng(coord[1], coord[0]);
    }
  });
};
  • json转polygon
ts 复制代码
/**
 * GeoJSON转多边形
 * @param geoJSON - GeoJSON Feature 或 FeatureCollection
 * @param map - Google Maps 实例,可选,用于设置多边形到地图上
 * @returns 创建的多边形数组和对应的属性信息
 */
export const geoJSONToPolygons = (
  geoJSON: GeoJSONFeature | GeoJSONCollection,
  map?: google.maps.Map
): { polygons: google.maps.Polygon[]; properties: Record<string, any>[] } => {
  const polygons: google.maps.Polygon[] = [];
  const properties: Record<string, any>[] = [];

  const features = geoJSON.type === "FeatureCollection" ? geoJSON.features : [geoJSON];

  features.forEach((feature) => {
    const isExcluded =
      feature.properties?.isExcluded ??
      feature.properties?.areaType === "excluded";
    const featureProperties = feature.properties || {};

    if (feature.geometry.type === "Polygon") {
      // 处理单个多边形
      const coordinates = feature.geometry.coordinates as number[][][];

      try {
        const latLngs = convertCoordinatesToLatLngs(coordinates[0]);
        const polygon = coordinatesToPolygon(
          latLngs,
          isExcluded,
          undefined,
          map
        );
        if (polygon) {
          polygons.push(polygon);
          properties.push(featureProperties);
        }
      } catch (error) {
        console.error(`创建多边形失败:`, error);
      }
    } else if (feature.geometry.type === "MultiPolygon") {
      // 处理多个多边形
      const multiPolygonCoords = feature.geometry.coordinates as number[][][][];
      multiPolygonCoords.forEach((polygonCoords, polygonIndex) => {
        try {
          const latLngs = convertCoordinatesToLatLngs(polygonCoords[0]);
          const polygon = coordinatesToPolygon(
            latLngs,
            isExcluded,
            undefined,
            map
          );
          if (polygon) {
            polygons.push(polygon);
            properties.push(featureProperties);
          }
        } catch (error) {
          console.error(`创建多边形失败:${polygonIndex}`, error);
        }
      });
    }
  });

  return { polygons, properties };
};

检查渲染的光伏板是否在框选区域内

  • 检查 坐标点 是否在 多边形内
ts 复制代码
/**
 * 检查点是否在多边形内
 * @param point 要检查的点
 * @param polygon 多边形顶点数组
 * @returns 是否在多边形内
 */
export function isPointInPolygon(
  point: { lat: number; lng: number },
  polygon: google.maps.LatLng[]
): boolean {
  if (polygon.length < 3) {
    return false;
  }

  const x = point.lng;
  const y = point.lat;
  let inside = false;

  // 使用射线法算法
  for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
    const xi = polygon[i].lng();
    const yi = polygon[i].lat();
    const xj = polygon[j].lng();
    const yj = polygon[j].lat();

    // 检查点是否在边的同一侧
    if (((yi > y) !== (yj > y)) && 
        (x < (xj - xi) * (y - yi) / (yj - yi) + xi)) {
      inside = !inside;
    }
  }

  return inside;
}



/**
 * 检查点是否在任意多边形内
 * @param point 要检查的点
 * @param polygons 多边形数组
 * @returns 是否在任意多边形内
 */
export function isPointInAnyPolygon(
  point: { lat: number; lng: number },
  polygons: google.maps.LatLng[][]
): boolean {
  return polygons.some(polygon => isPointInPolygon(point, polygon));
}
  • 过滤太阳能板铺设数据
ts 复制代码
/**
 * 过滤太阳能面板,排除在不可铺设区域内的面板
 * @param panels 原始面板数组
 * @param excludedAreas 排除区域数组
 * @param allowedAreas 允许区域数组(如果为空,则使用所有非排除区域)
 * @returns 过滤后的面板数组
 */
export function filterSolarPanels(
  panels: SolarPanel[],
  excludedAreas: google.maps.LatLng[][] = [],
  allowedAreas: google.maps.LatLng[][] = []
): SolarPanel[] {
  const filteredPanels = panels.filter(panel => {
    const panelPoint = { lat: panel.center.latitude, lng: panel.center.longitude };
    
    // 如果在排除区域内,则过滤掉
    if (isPointInAnyPolygon(panelPoint, excludedAreas)) {
      return false;
    }
    
    // 如果指定了允许区域,则只保留在允许区域内的面板
    if (allowedAreas.length > 0) {
      return isPointInAnyPolygon(panelPoint, allowedAreas);
    }
    
    // 如果没有指定允许区域,则保留所有非排除区域的面板
    return true;
  });
  
  return filteredPanels;
}

解决地图拖动, 中心点定位图标晃动问题

tsx 复制代码
import { useEffect, useState } from 'react';

interface LocationMarkerProps {
  map: google.maps.Map;
}
export default function LocationMarker({ map }: LocationMarkerProps) {
  // 使用 OverlayView 的 LocationMarker,确保与地理坐标精确对应
  const [overlay, setOverlay] = useState<google.maps.OverlayView | null>(null);

  useEffect(() => {
    if (!map) return;

    class CenterMarkerOverlay extends google.maps.OverlayView {
      private div: HTMLDivElement | null = null;
      private map: google.maps.Map | null = null;
      private listeners: google.maps.MapsEventListener[] = [];

      constructor() {
        super();
        this.div = null;
        this.map = null;
        this.listeners = [];
      }

      onAdd() {
        this.div = CreateElement();

        // 获取地图的 panes(可渲染的窗格) 对象,将 div 添加到 overlayImage 中
        const panes = this.getPanes();
        if (panes) {
          (panes as any).overlayImage.appendChild(this.div);
        }

        // 保存地图引用
        this.map = this.getMap() as google.maps.Map;

        // 使用 requestAnimationFrame 优化更新频率
        let animationId: number | null = null;
        const updatePosition = () => {
          this.updatePosition();
          animationId = requestAnimationFrame(updatePosition);
        };

        // 监听地图事件,使用高频更新
        if (this.map) {
          this.listeners.push(
            this.map.addListener('drag', () => {
              if (animationId) cancelAnimationFrame(animationId);
              animationId = requestAnimationFrame(updatePosition);
            }),
            this.map.addListener('dragend', () => {
              if (animationId) cancelAnimationFrame(animationId);
              this.updatePosition();
            }),
            this.map.addListener('zoom_changed', () => {
              this.updatePosition();
            }),
            this.map.addListener('center_changed', () => {
              this.updatePosition();
            }),
          );
        }

        // 初始化位置
        this.updatePosition();
      }

      updatePosition() {
        if (!this.div || !this.map) return;

        // 返回与此 OverlayView 关联的 MapCanvasProjection 对象, 用于计算div的地理坐标,像素坐标
        const projection = this.getProjection();
        if (!projection) return;

        const center = this.map.getCenter();
        if (!center) return;

        // 计算存放可拖动地图的 DOM 元素中指定地理位置的像素坐标。
        const point = projection.fromLatLngToDivPixel(center);
        if (point) {
          /* Google Maps Marker 的默认定位点是图标的底部中心
          我们的 SVG 图标宽度是 30px,高度是 36.21px
          让图标的底部中心对准地图中心点,与标准 Marker 保持一致 */
          this.div.style.left = point.x - 30 / 2 + 'px'; // 图标宽度的一半
          this.div.style.top = point.y - 36.21 + 'px'; // 图标高度,让图标底部对准中心点
        }
      }

      draw() {
        this.updatePosition();
      }

      onRemove() {
        // 清理事件监听器
        this.listeners.forEach((listener) => {
          google.maps.event.removeListener(listener);
        });
        this.listeners = [];

        if (this.div && this.div.parentNode) {
          this.div.parentNode.removeChild(this.div);
        }
        this.div = null;
        this.map = null;
      }
    }

    const newOverlay = new CenterMarkerOverlay();
    newOverlay.setMap(map);
    setOverlay(newOverlay);

    return () => {
      if (newOverlay) {
        newOverlay.setMap(null);
      }
    };
  }, [map]);

  return null;
}

function CreateElement() {
  const div = document.createElement('div');
  div.style.position = 'absolute';
  div.style.pointerEvents = 'none';
  div.style.zIndex = '1000';
  div.style.width = '30px';
  div.style.height = '36.21px';
  div.style.display = 'flex';
  div.style.alignItems = 'center';
  div.style.justifyContent = 'center';
  // 创建 SVG 元素,使用与原始 LocationMarker 相同的尺寸
  const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  svg.setAttribute('width', '30');
  svg.setAttribute('height', '36.21');
  svg.setAttribute('viewBox', '0 0 30 36.21');
  svg.setAttribute('fill', 'none');
  svg.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
  svg.setAttribute('version', '1.1');

  const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  path.setAttribute(
    'd',
    'M25.6067,25.6067C25.6067,25.6067,15,36.2132,15,36.2132C15,36.2132,4.3934,25.6067,4.3934,25.6067C-1.46447,19.7487,-1.46447,10.2513,4.3934,4.3934C10.2513,-1.46447,19.7487,-1.46447,25.6067,4.3934C31.4645,10.2513,31.4645,19.7487,25.6067,25.6067ZM15,18.3333C16.841,18.3333,18.3333,16.841,18.3333,15C18.3333,13.159,16.841,11.6667,15,11.6667C13.159,11.6667,11.6667,13.159,11.6667,15C11.6667,16.841,13.159,18.3333,15,18.3333Z',
  );
  path.setAttribute('fill', '#E75043');
  path.setAttribute('fillRule', 'evenodd');
  path.setAttribute('fillOpacity', '1');

  svg.appendChild(path);
  div.appendChild(svg);
  return div;
}
相关推荐
狂炫冰美式4 分钟前
不谈技术,搞点文化 🧀 —— 从复活一句明代残诗破局产品迭代
前端·人工智能·后端
xw51 小时前
npm几个实用命令
前端·npm
!win !1 小时前
npm几个实用命令
前端·npm
代码狂想家1 小时前
使用openEuler从零构建用户管理系统Web应用平台
前端
dorisrv2 小时前
优雅的React表单状态管理
前端
蓝瑟3 小时前
告别重复造轮子!业务组件多场景复用实战指南
前端·javascript·设计模式
dorisrv3 小时前
高性能的懒加载与无限滚动实现
前端
韭菜炒大葱3 小时前
别等了!用 Vue 3 让 AI 边想边说,字字蹦到你脸上
前端·vue.js·aigc
StarkCoder3 小时前
求求你,别在 Swift 协程开头写 guard let self = self 了!
前端
清妍_3 小时前
一文详解 Taro / 小程序 IntersectionObserver 参数
前端