示例

import {
  Button, HStack, Map, MapCircle, MapCompass, MapPitchToggle, MapPolygon, MapPolyline,
  MapScaleView, MapUserLocationButton, Marker, Navigation, NavigationStack, Picker, Script,
  ScrollView, Text, useMemo, useObservable, useState, VStack,
} from "scripting"
import type { MapStyleSpec, MapPointsOfInterestSpec } from "scripting"

function Example() {
  // Camera bound to most of the demos below. Demo 4 swaps it through every
  // MapCameraPosition variant; the other demos read its current value.
  const position = useObservable<MapCameraPosition>(
    MapCameraPosition.region({
      center: { latitude: 31.2354, longitude: 121.4905 },
      span: { latitudeDelta: 0.04, longitudeDelta: 0.04 },
    })
  )

  // ───── Demo 2 state: mapStyle picker ─────
  const [styleKind, setStyleKind] = useState<"standard" | "imagery" | "hybrid">("standard")
  const [showsTraffic, setShowsTraffic] = useState(false)

  const mapStyle: MapStyleSpec = useMemo(() => {
    if (styleKind === "imagery") {
      return { style: "imagery", elevation: "realistic" }
    }
    if (styleKind === "hybrid") {
      return { style: "hybrid", elevation: "realistic", showsTraffic }
    }
    return { style: "standard", showsTraffic }
  }, [styleKind, showsTraffic])

  // ───── Demo 4 state: camera-spec variants ─────
  type CameraKind = "region" | "rect" | "camera" | "automatic" | "userLocation"
  const [cameraKind, setCameraKind] = useState<CameraKind>("region")

  const applyCamera = (kind: CameraKind) => {
    setCameraKind(kind)
    switch (kind) {
      case "region":
        position.setValue(MapCameraPosition.region({
          center: { latitude: 31.2354, longitude: 121.4905 },
          span: { latitudeDelta: 0.04, longitudeDelta: 0.04 },
        }))
        return
      case "rect":
        // 5 km × 5 km square around the Bund
        position.setValue(MapCameraPosition.rect({
          center: { latitude: 31.2407, longitude: 121.4905 },
          size: { width: 5000, height: 5000 },
        }))
        return
      case "camera":
        position.setValue(MapCameraPosition.camera({
          centerCoordinate: { latitude: 31.2397, longitude: 121.4994 },
          distance: 1500,
          heading: 30,
          pitch: 45,
        }))
        return
      case "automatic":
        position.setValue(MapCameraPosition.automatic())
        return
      case "userLocation":
        // Requires user location permission; falls back to .automatic without it.
        position.setValue(MapCameraPosition.userLocation())
        return
    }
  }

  // ───── Demo 5 state: POI filter ─────
  type POIMode = "all" | "excludingAll" | "includes" | "excludes"
  const [poiMode, setPoiMode] = useState<POIMode>("all")

  const poiSpec: MapPointsOfInterestSpec = useMemo(() => {
    if (poiMode === "all") return "all"
    if (poiMode === "excludingAll") return "excludingAll"
    if (poiMode === "includes") return { includes: ["restaurant", "cafe", "park"] }
    return { excludes: ["gasStation", "atm", "parking"] }
  }, [poiMode])

  // ───── Demo 6: geodesic polyline state ─────
  // Toggle between straight and geodesic to visualise the curvature on long routes.
  const [geodesic, setGeodesic] = useState(true)

  // ───── Demo 8: pitch toggle ─────
  const pitchedPosition = useObservable<MapCameraPosition>(
    MapCameraPosition.camera({
      centerCoordinate: { latitude: 31.2397, longitude: 121.4994 },
      distance: 1200,
      heading: 30,
      pitch: 60,
    })
  )

  const dismiss = Navigation.useDismiss()

  return <NavigationStack>
    <ScrollView
      navigationTitle="Map View"
      toolbar={{
        cancellationAction: <Button
          title="Close"
          action={dismiss}
        />
      }}
    >
      <VStack
        navigationTitle={"Map"}
        navigationBarTitleDisplayMode={"inline"}
        spacing={20}
        padding
      >
        <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
          {`\`Map\` is a SwiftUI MapKit view. Pass an \`Observable<MapCameraPosition>\` to
          \`cameraPosition\` for two-way binding — gestures write the new camera back on
          gesture end. Use the children for \`Marker\`, \`MapPolyline\`, \`MapPolygon\`,
          \`MapCircle\`; use \`controls={...}\` to mount built-in MapKit controls.`}
        </Text>

        {/* 1. Basic map with Marker + Polyline + Circle */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>1. Basic map</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Three markers (plain label, SF Symbol, monogram), a polyline tracing a path
            between them, and a circle showing a 300m radius. Drag/zoom — the region writes
            back to `cameraPosition`.
          </Text>
          <Map
            cameraPosition={position}
            mapStyle={mapStyle}
            controls={<>
              <MapUserLocationButton />
              <MapCompass />
              <MapScaleView />
            </>}
            frame={{ height: 320 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <Marker
              title="Bund"
              coordinate={{ latitude: 31.2407, longitude: 121.4905 }}
              tint="systemRed"
            />
            <Marker
              title="People's Square"
              coordinate={{ latitude: 31.2304, longitude: 121.4737 }}
              systemImage="building.2"
              tint="systemBlue"
            />
            <Marker
              title="Lujiazui"
              coordinate={{ latitude: 31.2397, longitude: 121.4994 }}
              monogram="L"
              tint="systemPurple"
            />
            <MapPolyline
              coordinates={[
                { latitude: 31.2304, longitude: 121.4737 },
                { latitude: 31.2407, longitude: 121.4905 },
                { latitude: 31.2397, longitude: 121.4994 },
              ]}
              strokeColor="systemOrange"
              strokeStyle={{ lineWidth: 4, lineCap: "round", lineJoin: "round" }}
            />
            <MapCircle
              center={{ latitude: 31.2354, longitude: 121.4905 }}
              radius={300}
              fillColor="rgba(0, 122, 255, 0.15)"
              strokeColor="systemBlue"
              strokeStyle={{ lineWidth: 2 }}
            />
          </Map>
        </VStack>

        {/* 2. mapStyle + showsTraffic + elevation */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>2. Map style</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Switch the map above between Standard / Imagery / Hybrid. Imagery and Hybrid
            request realistic elevation; only Standard and Hybrid honor `showsTraffic`.
          </Text>
          <HStack spacing={8}>
            <Button title="Standard" action={() => setStyleKind("standard")} />
            <Button title="Imagery" action={() => setStyleKind("imagery")} />
            <Button title="Hybrid" action={() => setStyleKind("hybrid")} />
          </HStack>
          <HStack spacing={8}>
            <Button
              title={showsTraffic ? "Traffic: ON" : "Traffic: OFF"}
              action={() => setShowsTraffic(v => !v)}
            />
          </HStack>
        </VStack>

        {/* 3. Polygon demo */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>3. Polygon</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            A filled polygon outlining a rough quad around People's Square. Uses
            `initialCameraPosition` (one-way init, no write-back).
          </Text>
          <Map
            initialCameraPosition={MapCameraPosition.region({
              center: { latitude: 31.2304, longitude: 121.4737 },
              span: { latitudeDelta: 0.02, longitudeDelta: 0.02 },
            })}
            frame={{ height: 240 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <MapPolygon
              coordinates={[
                { latitude: 31.234, longitude: 121.470 },
                { latitude: 31.234, longitude: 121.478 },
                { latitude: 31.227, longitude: 121.478 },
                { latitude: 31.227, longitude: 121.470 },
              ]}
              fillColor="rgba(52, 199, 89, 0.25)"
              strokeColor="systemGreen"
              strokeStyle={{ lineWidth: 2 }}
            />
          </Map>
        </VStack>

        {/* 4. All 5 MapCameraPosition variants */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>4. Camera spec variants</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Cycle a dedicated `cameraPosition` observable through every
            `MapCameraPosition` form. The map directly below responds.
            `userLocation` needs location permission; without it MapKit falls
            back to `automatic`.
          </Text>
          <Picker
            title="Camera"
            value={cameraKind}
            onChanged={(v: any) => applyCamera(v as CameraKind)}
            pickerStyle={"segmented"}
          >
            <Text tag={"region"}>region</Text>
            <Text tag={"rect"}>rect</Text>
            <Text tag={"camera"}>camera</Text>
            <Text tag={"automatic"}>auto</Text>
            <Text tag={"userLocation"}>user</Text>
          </Picker>
          <Map
            cameraPosition={position}
            frame={{ height: 260 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <Marker
              title="People's Square"
              coordinate={{ latitude: 31.2304, longitude: 121.4737 }}
              tint="systemRed"
            />
            <Marker
              title="Lujiazui"
              coordinate={{ latitude: 31.2397, longitude: 121.4994 }}
              tint="systemBlue"
            />
          </Map>
          <Text font={"caption2"} foregroundStyle={"tertiaryLabel"}>
            Note: dragging the map afterwards writes a `{"{ region }"}` form
            back, regardless of which variant you started with. The map in
            Demo 1 shares the same observable, so it will respond too.
          </Text>
        </VStack>

        {/* 5. POI filter */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>5. Points of interest filter</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Filter which POIs MapKit renders. `includes` shows only the listed categories;
            `excludes` shows everything except them; `excludingAll` hides all POIs.
          </Text>
          <Picker
            title="POI"
            value={poiMode}
            onChanged={(v: any) => setPoiMode(v as POIMode)}
            pickerStyle={"segmented"}
          >
            <Text tag={"all"}>all</Text>
            <Text tag={"excludingAll"}>none</Text>
            <Text tag={"includes"}>food + park</Text>
            <Text tag={"excludes"}>no gas/atm</Text>
          </Picker>
          <Map
            initialCameraPosition={MapCameraPosition.region({
              center: { latitude: 31.2304, longitude: 121.4737 },
              span: { latitudeDelta: 0.015, longitudeDelta: 0.015 },
            })}
            mapStyle={{ style: "standard", pointsOfInterest: poiSpec }}
            frame={{ height: 260 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          />
        </VStack>

        {/* 6. Geodesic polyline (long-haul route) */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>6. Geodesic polyline</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Beijing ⇄ San Francisco great-circle vs straight line. Geodesic follows the
            shortest path on a sphere — visibly curved on long routes; the straight form
            cuts diagonally across the projection.
          </Text>
          <HStack spacing={8}>
            <Button
              title={geodesic ? "Mode: geodesic" : "Mode: straight"}
              action={() => setGeodesic(v => !v)}
            />
          </HStack>
          <Map
            initialCameraPosition={MapCameraPosition.region({
              center: { latitude: 50, longitude: -160 },
              span: { latitudeDelta: 90, longitudeDelta: 200 },
            })}
            mapStyle={{ style: "standard" }}
            frame={{ height: 260 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <Marker title="Beijing"
              coordinate={{ latitude: 39.9042, longitude: 116.4074 }}
              tint="systemRed"
            />
            <Marker title="San Francisco"
              coordinate={{ latitude: 37.7749, longitude: -122.4194 }}
              tint="systemBlue"
            />
            <MapPolyline
              coordinates={[
                { latitude: 39.9042, longitude: 116.4074 },
                { latitude: 37.7749, longitude: -122.4194 },
              ]}
              strokeColor="systemOrange"
              strokeStyle={{ lineWidth: 3, lineCap: "round" }}
              contourStyle={geodesic ? "geodesic" : "straight"}
            />
          </Map>
        </VStack>

        {/* 7. Dashed strokes + lineCap / lineJoin */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>7. Stroke styles</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Three polylines side-by-side showing `dash` + `lineCap` + `lineJoin` combinations,
            plus a dashed `MapCircle` outline. Dash lengths and gaps are in points.
          </Text>
          <Map
            initialCameraPosition={MapCameraPosition.region({
              center: { latitude: 31.2354, longitude: 121.4905 },
              span: { latitudeDelta: 0.012, longitudeDelta: 0.018 },
            })}
            frame={{ height: 280 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <MapPolyline
              coordinates={[
                { latitude: 31.238, longitude: 121.484 },
                { latitude: 31.238, longitude: 121.497 },
              ]}
              strokeColor="systemRed"
              strokeStyle={{ lineWidth: 5, lineCap: "butt" }}
            />
            <MapPolyline
              coordinates={[
                { latitude: 31.235, longitude: 121.484 },
                { latitude: 31.235, longitude: 121.497 },
              ]}
              strokeColor="systemBlue"
              strokeStyle={{ lineWidth: 5, lineCap: "round", dash: [12, 6] }}
            />
            <MapPolyline
              coordinates={[
                { latitude: 31.232, longitude: 121.484 },
                { latitude: 31.232, longitude: 121.490 },
                { latitude: 31.234, longitude: 121.497 },
              ]}
              strokeColor="systemGreen"
              strokeStyle={{ lineWidth: 5, lineJoin: "round", dash: [4, 4] }}
            />
            <MapCircle
              center={{ latitude: 31.2354, longitude: 121.4905 }}
              radius={250}
              fillColor="rgba(175, 82, 222, 0.10)"
              strokeColor="systemPurple"
              strokeStyle={{ lineWidth: 2, dash: [8, 4] }}
            />
          </Map>
        </VStack>

        {/* 8. MapPitchToggle + pitched 3D camera */}
        <VStack alignment={"leading"} spacing={8}>
          <Text font={"headline"}>8. Pitched camera + MapPitchToggle</Text>
          <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
            Hybrid + realistic elevation + a `camera`-form initial position with pitch=60°.
            The pitch-toggle control in the corner flips between flat and pitched view.
          </Text>
          <Map
            cameraPosition={pitchedPosition}
            mapStyle={{ style: "hybrid", elevation: "realistic" }}
            controls={<>
              <MapPitchToggle />
              <MapCompass />
            </>}
            frame={{ height: 320 }}
            clipShape={{
              type: 'rect',
              cornerRadius: 12
            }}
          >
            <Marker title="Lujiazui"
              coordinate={{ latitude: 31.2397, longitude: 121.4994 }}
              systemImage="building.2.crop.circle"
              tint="systemTeal"
            />
          </Map>
        </VStack>

        <CameraBoundsDemo />
      </VStack>
    </ScrollView>
  </NavigationStack>
}

function CameraBoundsDemo() {
  // 中心约束:相机中心被锁在外滩 ~5km 矩形内,推不出去。
  const center = { latitude: 31.2397, longitude: 121.4906 }
  const region = { center, span: { latitudeDelta: 0.05, longitudeDelta: 0.05 } }
  const position = useObservable<MapCameraPosition>(MapCameraPosition.region(region))

  const [mode, setMode] = useState<"region" | "distance" | "off">("region")

  // 用 useMemo 避免每次 render 重建 bounds(SwiftUI 端按对象身份判等)。
  const bounds = useMemo(() => {
    if (mode === "off") return undefined
    if (mode === "region") {
      return MapCameraBounds.centerCoordinateBounds(region, {
        minimumDistance: 500,
        maximumDistance: 8000,
      })
    }
    // distance-only:中心可以拖到任意地方,只限 zoom 范围
    return MapCameraBounds.distance({ minimumDistance: 1000, maximumDistance: 20000 })
  }, [mode])

  return <VStack alignment={"leading"} spacing={8}>
    <Text font={"headline"}>9. `cameraBounds` — clamp pan / zoom</Text>
    <Text font={"caption"} foregroundStyle={"secondaryLabel"}>
      {`\`MapCameraBounds.centerCoordinateBounds\` 把中心锁在 region 内 + 限 zoom。
      \`MapCameraBounds.distance\` 只限 zoom,中心可以自由拖。
      off 时取消约束,作为对照。试着拖远 / 拖近 / 拖到很远的地方看效果。`}
    </Text>
    <Picker title="bounds" value={mode} onChanged={(v: any) => setMode(v as typeof mode)} pickerStyle="segmented">
      <Text tag="region">region + zoom</Text>
      <Text tag="distance">zoom only</Text>
      <Text tag="off">off</Text>
    </Picker>
    <Map
      cameraPosition={position}
      cameraBounds={bounds}
      frame={{ height: 280 }}
      clipShape={{ type: "rect", cornerRadius: 12 }}
    >
      <Marker title="Bund" coordinate={center} tint="systemRed" />
    </Map>
    <Text font={"caption2"} foregroundStyle={"tertiaryLabel"}>
      Bounds 只影响用户手势 — JS 端 `cameraPosition.setValue(...)` 仍可把相机程序化
      移到约束外。MapKit 通常在下一次手势时把相机动画拉回合法范围。
    </Text>
  </VStack>
}

async function run() {
  await Navigation.present({ element: <Example /> })
  Script.exit()
}

run()