chenlei 1 месяц назад
Родитель
Сommit
4ca2e47ec2

+ 8 - 1
.env

@@ -1,2 +1,9 @@
 VITE_TIANDITU_TOKEN=98cddcf67ec0f69936ccbc547dee30e2
-VITE_API_BASE_URL=http://192.168.20.61:8114
+VITE_API_BASE_URL=https://sit-zhgaoxinvideo.4dage.com
+VITE_MAP_IMAGERY_SOURCE=tianditu
+VITE_INTERNAL_IMAGERY_PROXY_TARGET=http://19.50.107.245:8090
+VITE_INTERNAL_IMAGERY_PROVIDER=wmts
+VITE_INTERNAL_IMAGERY_SERVICE_URL=/gxq-map/iserver/services/map-gxq_4490_tif/wmts100
+VITE_INTERNAL_IMAGERY_LAYER=gxq-4490
+VITE_INTERNAL_IMAGERY_TILE_MATRIX_SET=GlobalCRS84Scale_gxq-4490
+VITE_INTERNAL_IMAGERY_TOKEN=GnehG-Yd92ptdGl5dQ2nROJgtyQHsmcE_B--agbGkuVQFBmVoO2WQvymdB4hSYI9lvcVbFzgpy3er5UFqdWC3Q..

+ 6 - 1
.env.production

@@ -1,2 +1,7 @@
 VITE_TIANDITU_TOKEN=98cddcf67ec0f69936ccbc547dee30e2
-VITE_API_BASE_URL=https://sit-zhgaoxinvideo.4dage.com
+VITE_API_BASE_URL=
+VITE_MAP_IMAGERY_SOURCE=internal
+VITE_INTERNAL_IMAGERY_PROVIDER=wms
+VITE_INTERNAL_IMAGERY_SERVICE_URL=http://19.50.107.245:8090/iserver/services/map-gxq_4490_tif/wms111
+VITE_INTERNAL_IMAGERY_LAYER=gxq-4490
+VITE_INTERNAL_IMAGERY_TOKEN=GnehG-Yd92ptdGl5dQ2nROJgtyQHsmcE_B--agbGkuVQFBmVoO2WQvymdB4hSYI9lvcVbFzgpy3er5UFqdWC3Q..

+ 6 - 0
src/apis/index.ts

@@ -67,3 +67,9 @@ export const getEntListApi = (params: EntListParams) => {
 export const getTagListApi = (type: TagType["type"]) => {
   return requestByGet<TagType[]>("/api/show/tagGetList", { type });
 };
+
+export const getModelApi = (resourceCode: string) => {
+  return requestByGet<string>("/api/ownerApi/resourceGetGenerateUrl", {
+    resourceCode,
+  });
+};

+ 5 - 2
src/apis/types.ts

@@ -10,6 +10,8 @@ export type ParkType = {
   area: string;
   // 员工人数
   pcs: string;
+  // 3D 模型资源编码
+  resourceCode?: string;
 };
 
 /** 企业类型 */
@@ -19,9 +21,9 @@ export type EntType = {
   name: string;
   // 园区ID
   parkId: number;
-  // 经度
-  lat?: number;
   // 纬度
+  lat?: number;
+  // 经度
   lon?: number;
   // 缩略图
   thumb: string;
@@ -53,6 +55,7 @@ export type EntType = {
   tagRyLabel: string;
   // 主营产品
   mainProduct: string;
+  focusThumb?: string;
 };
 
 export interface SearchParams {

+ 1 - 14
src/components/CesiumHotspots/index.tsx

@@ -10,7 +10,6 @@ import {
   sitePositionToCartesian,
 } from "@/shared/cesiumOverlay";
 import type { HotsoptData } from "@/storeDB/hotsopt";
-import { HeadingPitchRange, Matrix4, Math as CesiumMath } from "cesium";
 import {
   useCallback,
   useEffect,
@@ -26,7 +25,6 @@ type CesiumHotsoptAnchorProps = {
 const CesiumHotsoptAnchor = ({ data, Component }: CesiumHotsoptAnchorProps) => {
   const viewer = useCesiumViewer();
   const tilesets = useCesiumTilesets();
-  const { state } = useStage();
   const anchorRef = useRef<HTMLDivElement>(null);
   const tileset = tilesets[data.siteId];
 
@@ -82,19 +80,8 @@ const CesiumHotsoptAnchor = ({ data, Component }: CesiumHotsoptAnchorProps) => {
         cursor: "pointer",
       }}
       onPointerDown={(ev) => {
+        // 仅阻止事件落到 Cesium,镜头移动由热点图标自身处理
         ev.stopPropagation();
-        if (!viewer || viewer.isDestroyed()) return;
-        const target = sitePositionToCartesian(tileset, data.position);
-        viewer.camera.lookAt(
-          target,
-          new HeadingPitchRange(
-            viewer.camera.heading,
-            CesiumMath.toRadians(-30),
-            80,
-          ),
-        );
-        viewer.camera.lookAtTransform(Matrix4.IDENTITY);
-        state.setFocusedHotspotId(data.id);
       }}
     >
       <Component data={data} />

+ 9 - 16
src/components/ParkProfile/index.tsx

@@ -1,21 +1,14 @@
-import { getParkDataApi } from "@/apis";
-import { useState, useEffect } from "react";
+import { useAppSelector } from "@/hook/useStore";
 import { useParams } from "react-router";
 import style from "./index.module.scss";
-import type { ParkType } from "@/apis/types";
 
 export const ParkProfile = () => {
-  const [parkData, setParkData] = useState<ParkType | null>(null);
   const params = useParams();
-
-  useEffect(() => {
-    (async () => {
-      if (!params.id) return;
-
-      const pData = await getParkDataApi(Number(params.id));
-      setParkData(pData);
-    })();
-  }, [params.id]);
+  const parkId = params.id ? Number(params.id) : null;
+  const { value: parkData, parkId: loadedParkId } = useAppSelector(
+    (state) => state.park,
+  );
+  const displayData = parkId != null && loadedParkId === parkId ? parkData : null;
 
   return (
     <div className={style.parkProfile}>
@@ -23,7 +16,7 @@ export const ParkProfile = () => {
         <li>
           <div>
             <p>
-              <span>{parkData?.ent ?? "-"}</span>家
+              <span>{displayData?.ent ?? "-"}</span>家
             </p>
           </div>
           <p>入驻行业</p>
@@ -31,7 +24,7 @@ export const ParkProfile = () => {
         <li>
           <div>
             <p>
-              <span>{parkData?.pcs ?? "-"}</span>人
+              <span>{displayData?.pcs ?? "-"}</span>人
             </p>
           </div>
           <p>员工人数</p>
@@ -39,7 +32,7 @@ export const ParkProfile = () => {
         <li>
           <div>
             <p>
-              <span>{parkData?.area ?? "-"}</span>平方
+              <span>{displayData?.area ?? "-"}</span>平方
             </p>
           </div>
           <p>行政区域</p>

+ 134 - 35
src/components/hotspot/index.tsx

@@ -1,19 +1,28 @@
-import type { HotsoptData } from "@/storeDB/hotsopt";
+import { useCallback, useState } from "react";
+import type { HotsoptData, HotsoptItem } from "@/storeDB/hotsopt";
 import { useStage } from "@/hook/useStage";
+import { getEntDetailApi } from "@/apis";
 import { Popover } from "antd";
 import { Mousewheel } from "swiper/modules";
 import { Swiper, SwiperSlide } from "swiper/react";
 import "swiper/css";
 import style from "./style.module.scss";
+import { DetailModal, type DetailFieldItem } from "@/components/detailModal";
+import {
+  buildEntDetailFields,
+  buildFallbackEntDetail,
+  formatDetailValue,
+} from "@/components/detailModal/entDetail";
+import { useCesiumTilesets, useCesiumViewer } from "@/context/cesiumViewer";
+import { sitePositionToCartesian } from "@/shared/cesiumOverlay";
+import { HeadingPitchRange, Matrix4, Math as CesiumMath } from "cesium";
 
-const HotSpot = ({
-  icon,
-  name,
-  tag,
+const HotSpotList = ({
+  items,
+  onSelect,
 }: {
-  icon: string;
-  name: string;
-  tag: string;
+  items: HotsoptItem[];
+  onSelect: (item: HotsoptItem) => void;
 }) => {
   return (
     <Swiper
@@ -25,14 +34,31 @@ const HotSpot = ({
       modules={[Mousewheel]}
       mousewheel
     >
-      {Array.from({ length: 10 }).map((_, index) => (
-        <SwiperSlide key={index}>
+      {items.map((item) => (
+        <SwiperSlide key={item.id}>
           <div className={style["slide-inner"]}>
-            <div className={style.hotspot}>
-              <img src={icon} alt="" />
+            <div
+              className={style.hotspot}
+              role="button"
+              tabIndex={0}
+              onPointerDown={(ev) => {
+                ev.stopPropagation();
+              }}
+              onClick={(ev) => {
+                ev.stopPropagation();
+                onSelect(item);
+              }}
+              onKeyDown={(ev) => {
+                if (ev.key === "Enter" || ev.key === " ") {
+                  ev.preventDefault();
+                  onSelect(item);
+                }
+              }}
+            >
+              <img src={item.content[0]} alt="" />
               <div className="info">
-                <p>{name}</p>
-                <span>{tag}</span>
+                <p>{item.content[1]}</p>
+                <span>{item.content[2]}</span>
               </div>
             </div>
           </div>
@@ -44,33 +70,106 @@ const HotSpot = ({
 
 export const Hotsopt = ({ data }: { data: HotsoptData }) => {
   const stage = useStage();
+  const viewer = useCesiumViewer();
+  const tilesets = useCesiumTilesets();
+  const [popoverOpen, setPopoverOpen] = useState(false);
+  const [detailVisible, setDetailVisible] = useState(false);
+  const [detailLoading, setDetailLoading] = useState(false);
+  const [detailTitle, setDetailTitle] = useState("--");
+  const [detailFields, setDetailFields] = useState<DetailFieldItem[]>([]);
   const focused =
     stage?.state.focusedHotspotId != null &&
     stage.state.focusedHotspotId === data.id;
-  const hasContent = Boolean(data.content);
+  const items =
+    data.items && data.items.length > 0
+      ? data.items
+      : data.content
+        ? [{ id: data.id, content: data.content }]
+        : [];
 
-  return (
-    <Popover
-      classNames={{ root: style.popover }}
-      content={
-        hasContent ? (
-          <HotSpot
-            icon={data.content[0]}
-            name={data.content[1]}
-            tag={data.content[2]}
-          />
-        ) : null
+  const focusOnMap = useCallback(() => {
+    if (!stage || !viewer || viewer.isDestroyed()) return;
+    const tileset = tilesets[data.siteId];
+    if (!tileset) return;
+
+    const target = sitePositionToCartesian(tileset, data.position);
+    viewer.camera.lookAt(
+      target,
+      new HeadingPitchRange(
+        viewer.camera.heading,
+        CesiumMath.toRadians(-30),
+        80,
+      ),
+    );
+    viewer.camera.lookAtTransform(Matrix4.IDENTITY);
+    stage.state.setFocusedHotspotId(data.id);
+  }, [data.id, data.position, data.siteId, stage, tilesets, viewer]);
+
+  const openDetail = useCallback(
+    async (item: HotsoptItem) => {
+      setPopoverOpen(false);
+      setDetailVisible(true);
+      setDetailLoading(true);
+      setDetailTitle(formatDetailValue(item.content[1]));
+      setDetailFields([]);
+
+      try {
+        const detail = await getEntDetailApi(item.id);
+        setDetailTitle(formatDetailValue(detail.name));
+        setDetailFields(buildEntDetailFields(detail));
+      } catch {
+        setDetailFields(
+          buildEntDetailFields(
+            buildFallbackEntDetail({
+              id: item.id,
+              content: item.content,
+              siteId: data.siteId,
+            }),
+          ),
+        );
+      } finally {
+        setDetailLoading(false);
       }
-      placement="right"
-      arrow={false}
-      destroyOnHidden={true}
-    >
-      <div
-        className={`${style["hotsopt-flat"]} ${style[data.className ?? ""]} ${focused ? style["hotsopt-focused"] : ""}`}
+    },
+    [data.siteId],
+  );
+
+  return (
+    <>
+      <Popover
+        classNames={{ root: style.popover }}
+        open={popoverOpen}
+        onOpenChange={setPopoverOpen}
+        content={
+          items.length > 0 ? (
+            <HotSpotList items={items} onSelect={openDetail} />
+          ) : null
+        }
+        placement="right"
+        arrow={false}
+        destroyOnHidden={true}
       >
-        {data.num && <p className="num">【{data.num}】</p>}
-      </div>
-    </Popover>
+        <div
+          className={`${style["hotsopt-flat"]} ${style[data.className ?? ""]} ${focused ? style["hotsopt-focused"] : ""}`}
+          onPointerDown={(ev) => {
+            ev.stopPropagation();
+            focusOnMap();
+          }}
+        >
+          {data.num != null && data.num > 1 && (
+            <p className="num">【{data.num}】</p>
+          )}
+        </div>
+      </Popover>
+
+      <DetailModal
+        open={detailVisible}
+        loading={detailLoading}
+        title={detailTitle}
+        fields={detailFields}
+        onCancel={() => setDetailVisible(false)}
+      />
+    </>
   );
 };
 

+ 1 - 0
src/components/hotspot/style.module.scss

@@ -71,6 +71,7 @@
   width: utils.vw-calc(218);
   height: utils.vw-calc(78);
   background: url("@/assets/images/tag_mid.png") no-repeat center / contain;
+  cursor: pointer;
 
   :global {
     img {

+ 28 - 0
src/constant.ts

@@ -1,6 +1,34 @@
 /** 天地图 API Key,可通过 VITE_TIANDITU_TOKEN 环境变量配置 */
 export const TIANDITU_TOKEN = import.meta.env.VITE_TIANDITU_TOKEN ?? "";
 
+export const MAP_IMAGERY_SOURCE =
+  import.meta.env.VITE_MAP_IMAGERY_SOURCE === "internal"
+    ? "internal"
+    : "tianditu";
+
+export const INTERNAL_IMAGERY_PROVIDER =
+  import.meta.env.VITE_INTERNAL_IMAGERY_PROVIDER === "wmts" ? "wmts" : "wms";
+
+export const INTERNAL_IMAGERY_SERVICE_URL =
+  import.meta.env.VITE_INTERNAL_IMAGERY_SERVICE_URL ??
+  (INTERNAL_IMAGERY_PROVIDER === "wmts"
+    ? import.meta.env.DEV
+      ? "/gxq-map/iserver/services/map-gxq_4490_tif/wmts100"
+      : "http://19.50.107.245:8090/iserver/services/map-gxq_4490_tif/wmts100"
+    : import.meta.env.DEV
+      ? "/gxq-map/iserver/services/map-gxq_4490_tif/wms111"
+      : "http://19.50.107.245:8090/iserver/services/map-gxq_4490_tif/wms111");
+
+export const INTERNAL_IMAGERY_LAYER =
+  import.meta.env.VITE_INTERNAL_IMAGERY_LAYER ?? "gxq-4490";
+
+export const INTERNAL_IMAGERY_TILE_MATRIX_SET =
+  import.meta.env.VITE_INTERNAL_IMAGERY_TILE_MATRIX_SET ??
+  "GlobalCRS84Scale_gxq-4490";
+
+export const INTERNAL_IMAGERY_TOKEN =
+  import.meta.env.VITE_INTERNAL_IMAGERY_TOKEN ?? "";
+
 /** API 基础地址,可通过 VITE_API_BASE_URL 环境变量配置 */
 export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? "";
 

+ 7 - 0
src/env.d.ts

@@ -10,6 +10,13 @@ module "*.vs" {
 interface ImportMetaEnv {
   readonly VITE_TIANDITU_TOKEN?: string;
   readonly VITE_API_BASE_URL?: string;
+  readonly VITE_MAP_IMAGERY_SOURCE?: "tianditu" | "internal";
+  readonly VITE_INTERNAL_IMAGERY_PROVIDER?: "wms" | "wmts";
+  readonly VITE_INTERNAL_IMAGERY_SERVICE_URL?: string;
+  readonly VITE_INTERNAL_IMAGERY_PROXY_TARGET?: string;
+  readonly VITE_INTERNAL_IMAGERY_LAYER?: string;
+  readonly VITE_INTERNAL_IMAGERY_TILE_MATRIX_SET?: string;
+  readonly VITE_INTERNAL_IMAGERY_TOKEN?: string;
 }
 
 interface ImportMeta {

+ 84 - 0
src/loader/cesiumTiles.ts

@@ -7,11 +7,14 @@ import {
   Cesium3DTileset,
   Ellipsoid,
   EllipsoidTerrainProvider,
+  GeographicTilingScheme,
   HeadingPitchRange,
   Matrix4,
   Math as CesiumMath,
   UrlTemplateImageryProvider,
   Viewer,
+  WebMapServiceImageryProvider,
+  WebMapTileServiceImageryProvider,
   WebMercatorTilingScheme,
   type Cesium3DTileset as Cesium3DTilesetType,
   type Viewer as ViewerType,
@@ -71,12 +74,93 @@ export const createTiandituImageryProvider = (
   });
 };
 
+const appendQuery = (url: string, key: string, value: string) => {
+  const separator = url.includes("?") ? "&" : "?";
+  return `${url}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
+};
+
+export const createWmtsImageryProvider = (
+  source: TileSourceData["sources"][number],
+) => {
+  if (!source.url || !source.layer || !source.tileMatrixSetID) {
+    throw new Error("WMTS source requires url, layer and tileMatrixSetID");
+  }
+
+  const tilingScheme =
+    source.projectionID === "3857"
+      ? new WebMercatorTilingScheme()
+      : new GeographicTilingScheme();
+  const url = source.token
+    ? appendQuery(source.url, "token", source.token)
+    : source.url;
+
+  return new WebMapTileServiceImageryProvider({
+    url,
+    layer: source.layer,
+    style: "default",
+    format: source.format ?? "image/png",
+    tileMatrixSetID: source.tileMatrixSetID,
+    tilingScheme,
+    minimumLevel: source.minLevel ?? 0,
+    maximumLevel: source.maxLevel,
+  });
+};
+
+export const createWmsImageryProvider = (
+  source: TileSourceData["sources"][number],
+) => {
+  if (!source.url || !source.layer) {
+    throw new Error("WMS source requires url and layer");
+  }
+
+  const tilingScheme =
+    source.projectionID === "3857"
+      ? new WebMercatorTilingScheme()
+      : new GeographicTilingScheme();
+  const url = source.token
+    ? appendQuery(source.url, "token", source.token)
+    : source.url;
+
+  return new WebMapServiceImageryProvider({
+    url,
+    layers: source.layer,
+    parameters: {
+      transparent: true,
+      format: source.format ?? "image/png",
+    },
+    tilingScheme,
+    enablePickFeatures: false,
+    minimumLevel: source.minLevel ?? 0,
+    maximumLevel: source.maxLevel,
+  });
+};
+
 export const applyTileSourceToViewer = (
   viewer: ViewerType,
   tileSource: TileSourceData,
 ) => {
   viewer.imageryLayers.removeAll();
   for (const source of tileSource.sources) {
+    if (source.provider === "wmts") {
+      const layer = viewer.imageryLayers.addImageryProvider(
+        createWmtsImageryProvider(source),
+      );
+      layer.errorEvent.addEventListener((error) => {
+        console.error(`WMTS layer ${source.layer} failed to load:`, error);
+      });
+      continue;
+    }
+
+    if (source.provider === "wms") {
+      const layer = viewer.imageryLayers.addImageryProvider(
+        createWmsImageryProvider(source),
+      );
+      layer.errorEvent.addEventListener((error) => {
+        console.error(`WMS layer ${source.layer} failed to load:`, error);
+      });
+      continue;
+    }
+
     const style = source.style ?? "img_w";
     const token = source.token ?? TIANDITU_TOKEN;
     if (!token) {

+ 148 - 0
src/shared/entHotspot.ts

@@ -0,0 +1,148 @@
+import { normalizeCoord } from "@/apis/park";
+import type { EntType } from "@/apis/types";
+import { API_BASE_URL } from "@/constant";
+import type { HotsoptData, HotsoptItem } from "@/storeDB/hotsopt";
+import { Cartesian3, Cartographic, type Viewer } from "cesium";
+
+const DEFAULT_SITE_ID = 1;
+const SAMPLE_START_HEIGHT = 1000;
+/** 两点水平距离小于该值(米)则聚为一组 */
+const CLUSTER_DISTANCE_METERS = 10;
+
+type EntWithCoord = EntType & { lon: number; lat: number };
+
+const toRad = (deg: number) => (deg * Math.PI) / 180;
+
+/** 两点大致水平距离(米) */
+const haversineMeters = (
+  lon1: number,
+  lat1: number,
+  lon2: number,
+  lat2: number,
+) => {
+  const R = 6371000;
+  const dLat = toRad(lat2 - lat1);
+  const dLon = toRad(lon2 - lon1);
+  const a =
+    Math.sin(dLat / 2) ** 2 +
+    Math.cos(toRad(lat1)) *
+      Math.cos(toRad(lat2)) *
+      Math.sin(dLon / 2) ** 2;
+  return 2 * R * Math.asin(Math.sqrt(a));
+};
+
+/** 按距离聚类:落入阈值内的企业并入最近一组 */
+const clusterByDistance = (ents: EntWithCoord[]): EntWithCoord[][] => {
+  const groups: { lon: number; lat: number; items: EntWithCoord[] }[] = [];
+
+  for (const ent of ents) {
+    let best: (typeof groups)[number] | undefined;
+    let bestDist = Infinity;
+
+    for (const group of groups) {
+      const dist = haversineMeters(ent.lon, ent.lat, group.lon, group.lat);
+      if (dist <= CLUSTER_DISTANCE_METERS && dist < bestDist) {
+        best = group;
+        bestDist = dist;
+      }
+    }
+
+    if (best) {
+      best.items.push(ent);
+      const n = best.items.length;
+      best.lon = best.items.reduce((sum, e) => sum + e.lon, 0) / n;
+      best.lat = best.items.reduce((sum, e) => sum + e.lat, 0) / n;
+    } else {
+      groups.push({ lon: ent.lon, lat: ent.lat, items: [ent] });
+    }
+  }
+
+  return groups.map((g) => g.items);
+};
+
+/** 解析企业经纬度:兼容字符串、别名字段,以及 lon/lat 写反 */
+const resolveEntCoord = (ent: EntType): EntWithCoord | null => {
+  const raw = normalizeCoord(ent as EntType & Record<string, unknown>);
+  let { lon, lat } = raw;
+
+  if (
+    Number.isFinite(lon) &&
+    Number.isFinite(lat) &&
+    Math.abs(lat) > 90 &&
+    Math.abs(lon) <= 90
+  ) {
+    [lon, lat] = [lat, lon];
+  }
+
+  if (
+    !Number.isFinite(lon) ||
+    !Number.isFinite(lat) ||
+    Math.abs(lon) > 180 ||
+    Math.abs(lat) > 90
+  ) {
+    return null;
+  }
+
+  return { ...ent, lon, lat };
+};
+
+const toHotspotItem = (ent: EntWithCoord): HotsoptItem => ({
+  id: ent.id,
+  content: [
+    API_BASE_URL + (ent.thumb || ""),
+    ent.name,
+    ent.type || ent.tagRyLabel || "",
+  ],
+});
+
+/** 按距离聚合企业,采样模型高度后转为 ECEF 热点 */
+export const sampleEntPositions = async (
+  viewer: Viewer,
+  ents: EntType[],
+): Promise<HotsoptData[]> => {
+  const withCoord = ents
+    .map(resolveEntCoord)
+    .filter((e): e is EntWithCoord => e != null);
+
+  if (withCoord.length === 0) return [];
+
+  const groupList = clusterByDistance(withCoord);
+  const cartographics = groupList.map((list) => {
+    const lon = list.reduce((sum, e) => sum + e.lon, 0) / list.length;
+    const lat = list.reduce((sum, e) => sum + e.lat, 0) / list.length;
+    return Cartographic.fromDegrees(lon, lat, SAMPLE_START_HEIGHT);
+  });
+
+  let sampled: Cartographic[] = cartographics;
+  try {
+    if (!viewer.isDestroyed()) {
+      sampled = await viewer.scene.sampleHeightMostDetailed(cartographics);
+    }
+  } catch {
+    sampled = cartographics;
+  }
+
+  return groupList.map((list, i) => {
+    const carto = sampled[i] ?? cartographics[i];
+    const height =
+      carto.height != null && Number.isFinite(carto.height) ? carto.height : 0;
+    const lon = list.reduce((sum, e) => sum + e.lon, 0) / list.length;
+    const lat = list.reduce((sum, e) => sum + e.lat, 0) / list.length;
+    const cartesian = Cartesian3.fromDegrees(lon, lat, height);
+    const items = list.map(toHotspotItem);
+
+    return {
+      id: list[0].id,
+      siteId: DEFAULT_SITE_ID,
+      position: {
+        x: cartesian.x,
+        y: cartesian.y,
+        z: cartesian.z,
+      },
+      num: items.length,
+      className: "hotspot-overview",
+      content: items[0].content,
+      items,
+    } satisfies HotsoptData;
+  });
+};

+ 2 - 1
src/store/index.ts

@@ -3,9 +3,10 @@ import sites from "./sitesSlice";
 import videos from "./videosSlice";
 import map from "./mapSlice";
 import hotsopt from "./hotsoptSlice";
+import park from "./parkSlice";
 
 export const store = configureStore({
-  reducer: { sites, videos, map, hotsopt },
+  reducer: { sites, videos, map, hotsopt, park },
 });
 
 export type AppStore = typeof store;

+ 39 - 0
src/store/parkSlice.ts

@@ -0,0 +1,39 @@
+import {
+  createAsyncThunk,
+  createSlice,
+  type PayloadAction,
+} from "@reduxjs/toolkit";
+import { getParkDataApi } from "@/apis";
+import type { ParkType } from "@/apis/types";
+import { appendStatus } from "./util";
+
+export type ParkState = SliteState<ParkType | null> & { parkId: number | null };
+const initialState: ParkState = { value: null, parkId: null, status: "idle" };
+
+export const fetchParkDetail = createAsyncThunk(
+  "park/fetchParkDetail",
+  async (parkId: number) => getParkDataApi(parkId),
+);
+
+const parkSlice = createSlice({
+  name: "park",
+  initialState,
+  reducers: {
+    setParkDetail(state, action: PayloadAction<ParkType | null>) {
+      state.value = action.payload;
+    },
+  },
+  extraReducers(builder) {
+    appendStatus(builder, fetchParkDetail).addCase(
+      fetchParkDetail.fulfilled,
+      (state, action) => {
+        state.status = "succeeded";
+        state.value = action.payload;
+        state.parkId = action.meta.arg;
+      },
+    );
+  },
+});
+
+export const parkActions = parkSlice.actions;
+export default parkSlice.reducer;

+ 8 - 4
src/store/sitesSlice.ts

@@ -4,16 +4,20 @@ import {
   type PayloadAction,
 } from "@reduxjs/toolkit";
 import { fetchSitesData, type SiteData } from "@/storeDB/site";
+import { fetchParkDetail } from "./parkSlice";
 import { appendInitValue } from "./util";
 
 export type { SiteData };
 export type SitesState = SliteState<SiteData[]>;
 const initialState: SitesState = { value: [], status: "idle" };
 
-export const fetchSites = createAsyncThunk("sites/fetchSites", async () => {
-  const sitesData = await fetchSitesData();
-  return sitesData;
-});
+export const fetchSites = createAsyncThunk(
+  "sites/fetchSites",
+  async (parkId: number, { dispatch }) => {
+    const parkData = await dispatch(fetchParkDetail(parkId)).unwrap();
+    return fetchSitesData(parkData);
+  },
+);
 
 const sitesSlice = createSlice({
   name: "sites",

+ 5 - 4
src/store/videosSlice.ts

@@ -6,16 +6,17 @@ import {
 } from "@reduxjs/toolkit";
 import { appendInitValue } from "./util";
 import { fetchVideosData, type VideoData } from "@/storeDB/videos";
+import type { ParkType } from "@/apis/types";
 import type { RootState } from ".";
 
 export type { VideoData, VideoProjectorFields } from "@/storeDB/videos";
 export type VideosState = SliteState<VideoData[]>;
 const initialState: VideosState = { value: [], status: "idle" };
 
-export const fetchVideos = createAsyncThunk("videos/fetchVideos", async () => {
-  const sitesData = await fetchVideosData();
-  return sitesData;
-});
+export const fetchVideos = createAsyncThunk(
+  "videos/fetchVideos",
+  async (parkData: ParkType) => fetchVideosData(parkData),
+);
 
 const videosSlice = createSlice({
   name: "videos",

+ 9 - 0
src/storeDB/hotsopt.ts

@@ -1,10 +1,19 @@
 import type { Vec3Like } from "@/shared/vec3";
 
+/** 聚合热点内的单家企业 */
+export type HotsoptItem = {
+  id: number;
+  content: [string, string, string];
+};
+
 export type HotsoptData = {
   id: number;
   position: Mutable<Vec3Like>;
   siteId: number;
+  /** 同位置聚合企业数量 */
   num?: number;
   className?: string;
   content: [string, string, string];
+  /** 同位置企业列表(Swiper 用) */
+  items?: HotsoptItem[];
 };

+ 40 - 1
src/storeDB/mapTileSources.ts

@@ -1,15 +1,28 @@
-import { TIANDITU_TOKEN } from "@/constant";
+import {
+  INTERNAL_IMAGERY_LAYER,
+  INTERNAL_IMAGERY_PROVIDER,
+  INTERNAL_IMAGERY_SERVICE_URL,
+  INTERNAL_IMAGERY_TILE_MATRIX_SET,
+  INTERNAL_IMAGERY_TOKEN,
+  MAP_IMAGERY_SOURCE,
+  TIANDITU_TOKEN,
+} from "@/constant";
 
 export type TileProjectionId = "3857" | "4326";
 
 export type TileSourceItem = {
+  provider?: "tianditu" | "wmts" | "wms";
   url?: string;
   maxLevel: number;
+  minLevel?: number;
   projectionID?: TileProjectionId;
   subdomains?: string;
   token?: string;
   dataType?: string;
   style?: "img_c" | "cia_c" | "img_w" | "cia_w";
+  layer?: string;
+  tileMatrixSetID?: string;
+  format?: string;
 };
 
 export type TileSourceData = {
@@ -23,13 +36,39 @@ const tiandituSource = (
   style: TileSourceItem["style"],
   projectionID: TileProjectionId = "3857",
 ): TileSourceItem => ({
+  provider: "tianditu",
   maxLevel: 18,
   projectionID,
   token: TIANDITU_TOKEN,
   style,
 });
 
+const internalImagerySource = (): TileSourceItem => ({
+  provider: INTERNAL_IMAGERY_PROVIDER,
+  url: INTERNAL_IMAGERY_SERVICE_URL,
+  maxLevel: 18,
+  projectionID: "4326",
+  token: INTERNAL_IMAGERY_TOKEN,
+  layer: INTERNAL_IMAGERY_LAYER,
+  tileMatrixSetID:
+    INTERNAL_IMAGERY_PROVIDER === "wmts"
+      ? INTERNAL_IMAGERY_TILE_MATRIX_SET
+      : undefined,
+  format: "image/png",
+});
+
 export const fetchTileSources = async (): Promise<TileSourceData[]> => {
+  if (MAP_IMAGERY_SOURCE === "internal") {
+    return [
+      {
+        id: 2,
+        name: "高新区正射影像",
+        sources: [internalImagerySource()],
+        coord: "cgcs2000",
+      },
+    ];
+  }
+
   return [
     {
       id: 1,

+ 16 - 2
src/storeDB/site.ts

@@ -1,3 +1,5 @@
+import { getModelApi } from "@/apis";
+import type { ParkType } from "@/apis/types";
 import type { Vec3Like } from "@/shared/vec3";
 
 export type SiteData = {
@@ -15,7 +17,19 @@ export type SiteData = {
   loader?: "cesium";
 };
 
-export const fetchSitesData = async (): Promise<SiteData[]> => {
+export const fetchSitesData = async (
+  parkData: ParkType,
+): Promise<SiteData[]> => {
+  const url = import.meta.env.DEV
+    ? "b3dm/TL/tileset.json"
+    : parkData.resourceCode
+      ? await getModelApi(parkData.resourceCode)
+      : "";
+
+  if (!url) {
+    return [];
+  }
+
   return [
     {
       id: 1,
@@ -26,7 +40,7 @@ export const fetchSitesData = async (): Promise<SiteData[]> => {
       },
       opacity: 1,
       loader: "cesium",
-      url: `b3dm/TL/tileset.json`,
+      url,
     },
   ];
 };

+ 5 - 2
src/storeDB/videos.ts

@@ -1,3 +1,4 @@
+import type { ParkType } from "@/apis/types";
 import { fetchSitesData } from "./site";
 import {
   DEFAULT_CROP_RECT,
@@ -72,8 +73,10 @@ export type VideoData = {
   viewFadeRadius?: number;
 } & VideoProjectorFields;
 
-export const fetchVideosData = async (): Promise<VideoData[]> => {
-  const sites = await fetchSitesData();
+export const fetchVideosData = async (
+  parkData: ParkType,
+): Promise<VideoData[]> => {
+  const sites = await fetchSitesData(parkData);
   return [
     {
       id: 1,

+ 28 - 51
src/views/enterprise/index.tsx

@@ -3,7 +3,6 @@ import style from "./index.module.scss";
 import { useAppDispatch } from "@/hook/useStore";
 import { hotsoptActions } from "@/store/hotsoptSlice";
 import type { HotsoptData } from "@/storeDB/hotsopt";
-import Icon from "@/assets/images/4dage.jpg";
 import { SearchInput } from "@/components/SearchInput";
 import { ParkProfile } from "@/components/ParkProfile";
 import { IndustryBarChart } from "@/components/IndustryBarChart";
@@ -16,42 +15,6 @@ import { getEntListApi, getEntRankApi, getTagListApi, getTrackTagApi } from "@/a
 import { buildIndustryBarData } from "@/shared/industryChart";
 import { API_BASE_URL } from "@/constant";
 
-const ENTERPRISE_HOTSPOTS: HotsoptData[] = [
-  {
-    id: 1,
-    position: {
-      x: 223,
-      y: -9,
-      z: 6,
-    },
-    content: [Icon, "珠海市四维时代网络科技有限公司", "高新技术企业"],
-    className: "hotspot-overview",
-    siteId: 1,
-  },
-  {
-    id: 2,
-    position: {
-      x: -11,
-      y: 0,
-      z: 54,
-    },
-    content: [Icon, "其他", "高新技术企业"],
-    className: "hotspot-overview",
-    siteId: 1,
-  },
-  {
-    id: 3,
-    position: {
-      x: 207,
-      y: -8.5,
-      z: 33.62239497574047,
-    },
-    content: [Icon, "其他", "高新技术企业"],
-    className: "hotspot-overview",
-    siteId: 1,
-  },
-];
-
 const toTagOptions = (tags: TagType[]) =>
   tags.map((tag) => ({
     value: tag.id,
@@ -60,18 +23,11 @@ const toTagOptions = (tags: TagType[]) =>
 
 const PAGE_SIZE = 10;
 
-const toEnterpriseItem = (item: EntType): HotsoptData => ({
-  id: item.id,
-  position: {
-    x: item.lat ?? 0,
-    y: item.lon ?? 0,
-    z: 0,
-  },
-  siteId: item.parkId,
-  content: [API_BASE_URL + item.thumb, item.name, item.establishDate],
-});
-
-export const Enterprise = () => {
+type EnterpriseProps = {
+  hotspots?: HotsoptData[];
+};
+
+export const Enterprise = ({ hotspots = [] }: EnterpriseProps) => {
   const dispatch = useAppDispatch();
   const [trackTagData, setTrackTagData] = useState<CountDictItem[]>([]);
   const [entRankData, setEntRankData] = useState<EntType[]>([]);
@@ -86,17 +42,38 @@ export const Enterprise = () => {
   const [entList, setEntList] = useState<EntType[]>([]);
   const [entTotal, setEntTotal] = useState(0);
 
+  const hotspotById = useMemo(() => {
+    const map = new Map<number, HotsoptData>();
+    for (const hotspot of hotspots) {
+      map.set(hotspot.id, hotspot);
+      for (const item of hotspot.items ?? []) {
+        map.set(item.id, hotspot);
+      }
+    }
+    return map;
+  }, [hotspots]);
+
+  const toEnterpriseItem = (item: EntType): HotsoptData => {
+    const hotspot = hotspotById.get(item.id);
+    return {
+      id: item.id,
+      position: hotspot?.position ?? { x: 0, y: 0, z: 0 },
+      siteId: hotspot?.siteId ?? 1,
+      content: [API_BASE_URL + item.thumb, item.name, item.establishDate],
+    };
+  };
+
   const { data: barData } = useMemo(
     () => buildIndustryBarData(trackTagData),
     [trackTagData],
   );
 
   useEffect(() => {
-    dispatch(hotsoptActions.setSites(ENTERPRISE_HOTSPOTS));
+    dispatch(hotsoptActions.setSites(hotspots));
     return () => {
       dispatch(hotsoptActions.setSites([]));
     };
-  }, [dispatch]);
+  }, [dispatch, hotspots]);
 
   useEffect(() => {
     (async () => {

+ 0 - 2
src/views/index.tsx

@@ -6,7 +6,6 @@ import StagePage from "./stage";
 import { useEffect } from "react";
 import { useAppDispatch } from "@/hook/useStore";
 import { fetchMap } from "@/store/mapSlice";
-import { fetchSites } from "@/store/sitesSlice";
 import { setParkId } from "@/apis/parkId";
 import { SearchFocusHandler } from "@/components/SearchFocusHandler";
 
@@ -43,7 +42,6 @@ export const Index = ({ stage }: { stage: IStage }) => {
 
   useEffect(() => {
     dispatch(fetchMap());
-    dispatch(fetchSites());
   }, [dispatch]);
 
   return (

+ 7 - 0
src/views/overview/index.module.scss

@@ -63,6 +63,13 @@
         }
       }
       &-3 {
+        width: 100%;
+
+        img {
+          width: utils.vh-calc(400);
+          height: utils.vh-calc(200);
+          object-fit: cover;
+        }
         &::before {
           background: url("./images/dbxqy.png") no-repeat center / contain;
         }

+ 8 - 23
src/views/overview/index.tsx

@@ -3,7 +3,6 @@ import style from "./index.module.scss";
 import { useAppDispatch } from "@/hook/useStore";
 import { hotsoptActions } from "@/store/hotsoptSlice";
 import type { HotsoptData } from "@/storeDB/hotsopt";
-import Icon from "@/assets/images/4dage.jpg";
 import { SearchInput } from "@/components/SearchInput";
 import { IndustryProportionChart } from "@/components/IndustryProportionChart";
 import { LayerPieChart, LayerPieLegendItem } from "@/components/LayerPieChart";
@@ -25,25 +24,11 @@ import {
   buildLayerPieData,
 } from "@/shared/industryChart";
 
-const OVERVIEW_HOTSPOTS: HotsoptData[] = [
-  {
-    id: 1,
-    position: { x: -2362095.01, y: 5407790.16, z: 2412068.07 },
-    content: [Icon, "珠海市四维时代网络科技有限公司", "高新技术企业"],
-    className: "hotspot-overview",
-    siteId: 1,
-    num: 10,
-  },
-  {
-    id: 2,
-    position: { x: -2362069.66, y: 5407924.37, z: 2411931.53 },
-    content: [Icon, "其他", "高新技术企业"],
-    className: "hotspot-overview",
-    siteId: 1,
-  },
-];
-
-export const Overview = () => {
+type OverviewProps = {
+  hotspots?: HotsoptData[];
+};
+
+export const Overview = ({ hotspots = [] }: OverviewProps) => {
   const dispatch = useAppDispatch();
   const [keyEntList, setKeyEntList] = useState<EntType[]>([]);
   const [keyEntLogo, setKeyEntLogo] = useState<string | null>(null);
@@ -67,11 +52,11 @@ export const Overview = () => {
   );
 
   useEffect(() => {
-    dispatch(hotsoptActions.setSites(OVERVIEW_HOTSPOTS));
+    dispatch(hotsoptActions.setSites(hotspots));
     return () => {
       dispatch(hotsoptActions.setSites([]));
     };
-  }, [dispatch]);
+  }, [dispatch, hotspots]);
 
   useEffect(() => {
     (async () => {
@@ -105,7 +90,7 @@ export const Overview = () => {
                 <p className="enterprise-item-industry">{item.type}</p>
                 <Image
                   classNames={{ root: "enterprise-item-image" }}
-                  src={API_BASE_URL + item.thumb}
+                  src={API_BASE_URL + item.focusThumb}
                 />
               </div>
             ))}

+ 60 - 3
src/views/stage.tsx

@@ -1,3 +1,4 @@
+import { getEntListApi } from "@/apis";
 import { useStage } from "@/hook/useStage";
 import { useAppDispatch } from "@/hook/useStore";
 import { fetchSites } from "@/store/sitesSlice";
@@ -19,7 +20,10 @@ import {
   type VideoPickMode,
   type VideoPickReadyPayload,
 } from "@/sdk/videoPick";
+import { sampleEntPositions } from "@/shared/entHotspot";
 import type { VideoProjectorParams } from "@/shared/videoProjector";
+import type { EntType } from "@/apis/types";
+import type { HotsoptData } from "@/storeDB/hotsopt";
 
 type EditorSession = {
   siteId: number;
@@ -41,13 +45,64 @@ export const Index = () => {
   const [editorSession, setEditorSession] = useState<EditorSession | null>(
     null,
   );
+  const [entList, setEntList] = useState<EntType[]>([]);
+  const [entHotspots, setEntHotspots] = useState<HotsoptData[]>([]);
 
   useEffect(() => {
-    dispatch(fetchSites());
     dispatch(fetchMap());
   }, [dispatch]);
 
   useEffect(() => {
+    if (!params.id) return;
+    dispatch(fetchSites(Number(params.id)));
+  }, [dispatch, params.id]);
+
+  useEffect(() => {
+    let cancelled = false;
+    (async () => {
+      try {
+        const res = await getEntListApi({
+          searchKey: "",
+          pageNum: 1,
+          pageSize: 999,
+        });
+        if (cancelled) return;
+        setEntList(Array.isArray(res?.records) ? res.records : []);
+      } catch {
+        if (!cancelled) setEntList([]);
+      }
+    })();
+    return () => {
+      cancelled = true;
+    };
+  }, []);
+
+  useEffect(() => {
+    if (!cesiumViewer || cesiumViewer.isDestroyed() || entList.length === 0) {
+      return;
+    }
+
+    const hasTileset = Object.keys(tilesetsBySiteId).length > 0;
+    if (!hasTileset) return;
+
+    let cancelled = false;
+    (async () => {
+      const tileset = Object.values(tilesetsBySiteId)[0] as
+        | { ready?: Promise<void> }
+        | undefined;
+      if (tileset?.ready) await tileset.ready;
+      if (cancelled || cesiumViewer.isDestroyed()) return;
+
+      const hotspots = await sampleEntPositions(cesiumViewer, entList);
+      if (!cancelled) setEntHotspots(hotspots);
+    })();
+
+    return () => {
+      cancelled = true;
+    };
+  }, [cesiumViewer, entList, tilesetsBySiteId]);
+
+  useEffect(() => {
     if (!stage) return;
     (window as any).stage = stage;
   }, [stage]);
@@ -191,8 +246,10 @@ export const Index = () => {
       )}
       <div className="router-view">
         <div>
-          {params.type === "overview" && <Overview />}
-          {params.type === "enterprise" && <Enterprise />}
+          {params.type === "overview" && <Overview hotspots={entHotspots} />}
+          {params.type === "enterprise" && (
+            <Enterprise hotspots={entHotspots} />
+          )}
           {params.type === "monitor" && <Monitor />}
         </div>
       </div>

+ 7 - 0
vite.config.ts

@@ -71,6 +71,13 @@ export default defineConfig(({ mode }) => {
           changeOrigin: true,
           rewrite: (path) => path.replace(/^\/tdt/, ""),
         },
+        "/gxq-map": {
+          target:
+            env.VITE_INTERNAL_IMAGERY_PROXY_TARGET ??
+            "http://19.50.107.245:8090",
+          changeOrigin: true,
+          rewrite: (path) => path.replace(/^\/gxq-map/, ""),
+        },
       },
     },
     plugins: [