@more-than-software/mapkit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +306 -0
- package/dist/README.md +306 -0
- package/dist/components/MapEffectComposer.d.ts +5 -0
- package/dist/components/MapEffectComposer.d.ts.map +1 -0
- package/dist/components/MapEntity.d.ts +10 -0
- package/dist/components/MapEntity.d.ts.map +1 -0
- package/dist/components/MapScene.d.ts +15 -0
- package/dist/components/MapScene.d.ts.map +1 -0
- package/dist/components/MapViewport.d.ts +10 -0
- package/dist/components/MapViewport.d.ts.map +1 -0
- package/dist/hooks/useMapEvents.d.ts +44 -0
- package/dist/hooks/useMapEvents.d.ts.map +1 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +43381 -0
- package/dist/index.js.map +1 -0
- package/dist/package.json +48 -0
- package/dist/plugins/TileCreasedNormalsPlugin.d.ts +11 -0
- package/dist/plugins/TileCreasedNormalsPlugin.d.ts.map +1 -0
- package/dist/src/components/MapEffectComposer.tsx +39 -0
- package/dist/src/components/MapEntity.tsx +98 -0
- package/dist/src/components/MapScene.tsx +138 -0
- package/dist/src/components/MapViewport.tsx +82 -0
- package/dist/src/hooks/useMapEvents.ts +211 -0
- package/dist/src/index.ts +55 -0
- package/dist/src/plugins/TileCreasedNormalsPlugin.ts +64 -0
- package/dist/src/utils/camera.ts +60 -0
- package/dist/src/utils/flyTo.ts +118 -0
- package/dist/src/utils/presets.ts +63 -0
- package/dist/utils/camera.d.ts +12 -0
- package/dist/utils/camera.d.ts.map +1 -0
- package/dist/utils/flyTo.d.ts +13 -0
- package/dist/utils/flyTo.d.ts.map +1 -0
- package/dist/utils/presets.d.ts +14 -0
- package/dist/utils/presets.d.ts.map +1 -0
- package/package.json +59 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@more-than-software/mapkit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"module": "dist/index.js",
|
|
8
|
+
"types": "dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"import": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": ["dist", "README.md"],
|
|
16
|
+
"repository": {
|
|
17
|
+
"type": "git",
|
|
18
|
+
"url": "https://github.com/more-than-software/citykit.git",
|
|
19
|
+
"directory": "packages/mapkit"
|
|
20
|
+
},
|
|
21
|
+
"keywords": ["react", "three.js", "3d-tiles", "google-maps", "geospatial", "globe", "map", "react-three-fiber"],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "vite build",
|
|
24
|
+
"prepack": "pnpm run build"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"3d-tiles-renderer": "0.4.21",
|
|
28
|
+
"@takram/three-atmosphere": "^0.16.0",
|
|
29
|
+
"@takram/three-clouds": "^0.16.0",
|
|
30
|
+
"@takram/three-geospatial": "^0.6.0",
|
|
31
|
+
"@takram/three-geospatial-effects": "^0.6.0",
|
|
32
|
+
"motion": "^12.34.2",
|
|
33
|
+
"postprocessing": "^6.38.2",
|
|
34
|
+
"react-merge-refs": "^3.0.2",
|
|
35
|
+
"tiny-invariant": "^1.3.3"
|
|
36
|
+
},
|
|
37
|
+
"peerDependencies": {
|
|
38
|
+
"@react-three/drei": "^10.0.0",
|
|
39
|
+
"@react-three/fiber": "^9.0.0",
|
|
40
|
+
"@react-three/postprocessing": "^3.0.0",
|
|
41
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
42
|
+
"react-dom": "^18.0.0 || ^19.0.0",
|
|
43
|
+
"three": "^0.180.0"
|
|
44
|
+
},
|
|
45
|
+
"publishConfig": {
|
|
46
|
+
"access": "public"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { Tile } from '3d-tiles-renderer';
|
|
2
|
+
import { Object3D } from 'three';
|
|
3
|
+
export interface TileCreasedNormalsPluginOptions {
|
|
4
|
+
creaseAngle?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare class TileCreasedNormalsPlugin {
|
|
7
|
+
readonly options: TileCreasedNormalsPluginOptions;
|
|
8
|
+
constructor(options?: TileCreasedNormalsPluginOptions);
|
|
9
|
+
processTileModel(scene: Object3D, tile: Tile): Promise<void>;
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=TileCreasedNormalsPlugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TileCreasedNormalsPlugin.d.ts","sourceRoot":"","sources":["../../src/plugins/TileCreasedNormalsPlugin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,mBAAmB,CAAA;AAC7C,OAAO,EAAwB,KAAK,QAAQ,EAAE,MAAM,OAAO,CAAA;AAE3D,MAAM,WAAW,+BAA+B;IAC9C,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAgCD,qBAAa,wBAAwB;IACnC,QAAQ,CAAC,OAAO,EAAE,+BAA+B,CAAA;gBAErC,OAAO,CAAC,EAAE,+BAA+B;IAK/C,gBAAgB,CAAC,KAAK,EAAE,QAAQ,EAAE,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC;CAgBnE"}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EffectComposer as WrappedEffectComposer,
|
|
3
|
+
type EffectComposerProps
|
|
4
|
+
} from '@react-three/postprocessing'
|
|
5
|
+
import {
|
|
6
|
+
NormalPass,
|
|
7
|
+
type EffectComposer as EffectComposerImpl
|
|
8
|
+
} from 'postprocessing'
|
|
9
|
+
import { useLayoutEffect, useRef, type FC, type RefAttributes } from 'react'
|
|
10
|
+
import { mergeRefs } from 'react-merge-refs'
|
|
11
|
+
import { HalfFloatType, type WebGLRenderTarget } from 'three'
|
|
12
|
+
import invariant from 'tiny-invariant'
|
|
13
|
+
|
|
14
|
+
import { reinterpretType } from '@takram/three-geospatial'
|
|
15
|
+
|
|
16
|
+
// Provided for half-float normal buffer.
|
|
17
|
+
export const MapEffectComposer: FC<
|
|
18
|
+
EffectComposerProps & RefAttributes<EffectComposerImpl>
|
|
19
|
+
> = ({ ref: forwardedRef, enableNormalPass = true, ...props }) => {
|
|
20
|
+
const ref = useRef<EffectComposerImpl>(null)
|
|
21
|
+
useLayoutEffect(() => {
|
|
22
|
+
const composer = ref.current
|
|
23
|
+
invariant(ref.current != null)
|
|
24
|
+
const normalPass = composer?.passes.find(pass => pass instanceof NormalPass)
|
|
25
|
+
invariant(normalPass != null)
|
|
26
|
+
reinterpretType<NormalPass & { renderTarget: WebGLRenderTarget }>(
|
|
27
|
+
normalPass
|
|
28
|
+
)
|
|
29
|
+
normalPass.renderTarget.texture.type = HalfFloatType
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<WrappedEffectComposer
|
|
34
|
+
ref={mergeRefs([ref, forwardedRef])}
|
|
35
|
+
{...props}
|
|
36
|
+
enableNormalPass={enableNormalPass}
|
|
37
|
+
/>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useGLTF } from '@react-three/drei'
|
|
2
|
+
import { useFrame } from '@react-three/fiber'
|
|
3
|
+
import { useMemo, type FC, type ReactNode } from 'react'
|
|
4
|
+
import { Sprite, SpriteMaterial, TextureLoader } from 'three'
|
|
5
|
+
import { EastNorthUpFrame } from '@takram/three-geospatial/r3f'
|
|
6
|
+
|
|
7
|
+
import type { MapEntity as MapEntityType } from '../index'
|
|
8
|
+
|
|
9
|
+
export interface MapEntityProps extends MapEntityType {
|
|
10
|
+
children?: ReactNode
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const textureLoader = new TextureLoader()
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Render a GLB model at a geodetic location
|
|
17
|
+
*/
|
|
18
|
+
const ModelEntity: FC<{
|
|
19
|
+
url: string
|
|
20
|
+
position: [number, number, number]
|
|
21
|
+
rotation?: [number, number, number]
|
|
22
|
+
scale?: number
|
|
23
|
+
}> = ({ url, position, rotation, scale = 1 }) => {
|
|
24
|
+
const gltf = useGLTF(url)
|
|
25
|
+
const model = useMemo(() => {
|
|
26
|
+
const cloned = gltf.scene.clone()
|
|
27
|
+
cloned.scale.setScalar(scale)
|
|
28
|
+
if (rotation) {
|
|
29
|
+
cloned.rotation.set(rotation[0], rotation[1], rotation[2])
|
|
30
|
+
}
|
|
31
|
+
return cloned
|
|
32
|
+
}, [gltf.scene, rotation, scale])
|
|
33
|
+
|
|
34
|
+
return <primitive object={model} position={position} />
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Render a billboard sprite at a geodetic location
|
|
39
|
+
*/
|
|
40
|
+
const BillboardEntity: FC<{
|
|
41
|
+
url?: string
|
|
42
|
+
position: [number, number, number]
|
|
43
|
+
scale?: number
|
|
44
|
+
}> = ({ url, position, scale = 1 }) => {
|
|
45
|
+
const sprite = useMemo(() => {
|
|
46
|
+
const material = new SpriteMaterial({
|
|
47
|
+
map: url ? textureLoader.load(url) : undefined,
|
|
48
|
+
color: url ? undefined : 0xffffff,
|
|
49
|
+
transparent: true
|
|
50
|
+
})
|
|
51
|
+
const sprite = new Sprite(material)
|
|
52
|
+
sprite.scale.setScalar(scale * 10) // Default billboard size
|
|
53
|
+
return sprite
|
|
54
|
+
}, [url, scale])
|
|
55
|
+
|
|
56
|
+
return <primitive object={sprite} position={position} />
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* MapEntity component - renders entities at geodetic coordinates
|
|
61
|
+
*/
|
|
62
|
+
export const MapEntity: FC<MapEntityProps> = ({
|
|
63
|
+
id,
|
|
64
|
+
type,
|
|
65
|
+
latitude,
|
|
66
|
+
longitude,
|
|
67
|
+
altitude = 0,
|
|
68
|
+
rotation,
|
|
69
|
+
scale = 1,
|
|
70
|
+
url,
|
|
71
|
+
children
|
|
72
|
+
}) => {
|
|
73
|
+
const position: [number, number, number] = [longitude, latitude, altitude]
|
|
74
|
+
|
|
75
|
+
if (type === 'model') {
|
|
76
|
+
if (!url) {
|
|
77
|
+
console.warn(`MapEntity ${id}: model type requires url`)
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
return (
|
|
81
|
+
<EastNorthUpFrame longitude={longitude} latitude={latitude} height={altitude}>
|
|
82
|
+
<ModelEntity url={url} position={[0, 0, 0]} rotation={rotation} scale={scale} />
|
|
83
|
+
{children}
|
|
84
|
+
</EastNorthUpFrame>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (type === 'billboard') {
|
|
89
|
+
return (
|
|
90
|
+
<EastNorthUpFrame longitude={longitude} latitude={latitude} height={altitude}>
|
|
91
|
+
<BillboardEntity url={url} position={[0, 0, 0]} scale={scale} />
|
|
92
|
+
{children}
|
|
93
|
+
</EastNorthUpFrame>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { useFrame, useThree } from '@react-three/fiber'
|
|
2
|
+
import { SMAA, ToneMapping } from '@react-three/postprocessing'
|
|
3
|
+
import type { GlobeControls as GlobeControlsImpl } from '3d-tiles-renderer'
|
|
4
|
+
import { GlobeControls } from '3d-tiles-renderer/r3f'
|
|
5
|
+
import {
|
|
6
|
+
EffectMaterial,
|
|
7
|
+
type EffectComposer as EffectComposerImpl
|
|
8
|
+
} from 'postprocessing'
|
|
9
|
+
import { Fragment, useEffect, useRef, useState, type FC, type ReactNode } from 'react'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
AerialPerspective,
|
|
13
|
+
Atmosphere,
|
|
14
|
+
type AtmosphereApi
|
|
15
|
+
} from '@takram/three-atmosphere/r3f'
|
|
16
|
+
import type { CloudsEffect } from '@takram/three-clouds'
|
|
17
|
+
import { Clouds } from '@takram/three-clouds/r3f'
|
|
18
|
+
|
|
19
|
+
import { MapEffectComposer } from './MapEffectComposer'
|
|
20
|
+
import { MapViewport } from './MapViewport'
|
|
21
|
+
|
|
22
|
+
export interface MapSceneProps {
|
|
23
|
+
apiKey: string
|
|
24
|
+
children?: ReactNode
|
|
25
|
+
enableClouds?: boolean
|
|
26
|
+
enableAerialPerspective?: boolean
|
|
27
|
+
enableToneMapping?: boolean
|
|
28
|
+
cloudsCoverage?: number
|
|
29
|
+
exposure?: number
|
|
30
|
+
correctAltitude?: boolean
|
|
31
|
+
correctGeometricError?: boolean
|
|
32
|
+
onLoadError?: () => void
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const MapScene: FC<MapSceneProps> = ({
|
|
36
|
+
apiKey,
|
|
37
|
+
children,
|
|
38
|
+
enableClouds = false,
|
|
39
|
+
enableAerialPerspective = true,
|
|
40
|
+
enableToneMapping = true,
|
|
41
|
+
cloudsCoverage = 0.3,
|
|
42
|
+
exposure = 10,
|
|
43
|
+
correctAltitude = true,
|
|
44
|
+
correctGeometricError = true,
|
|
45
|
+
onLoadError
|
|
46
|
+
}) => {
|
|
47
|
+
const camera = useThree(({ camera }) => camera)
|
|
48
|
+
const controls = useThree(
|
|
49
|
+
({ controls }) => controls as GlobeControlsImpl | null
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
// Enable adjustHeight when user first interacts
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (controls != null) {
|
|
55
|
+
const callback = (): void => {
|
|
56
|
+
controls.adjustHeight = true
|
|
57
|
+
controls.removeEventListener('start', callback)
|
|
58
|
+
}
|
|
59
|
+
controls.addEventListener('start', callback)
|
|
60
|
+
return () => {
|
|
61
|
+
controls.removeEventListener('start', callback)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}, [controls])
|
|
65
|
+
|
|
66
|
+
// Effects must know the camera near/far changed by GlobeControls.
|
|
67
|
+
const composerRef = useRef<EffectComposerImpl>(null)
|
|
68
|
+
useFrame(() => {
|
|
69
|
+
const composer = composerRef.current
|
|
70
|
+
if (composer != null) {
|
|
71
|
+
composer.passes.forEach(pass => {
|
|
72
|
+
if (pass.fullscreenMaterial instanceof EffectMaterial) {
|
|
73
|
+
pass.fullscreenMaterial.adoptCameraSettings(camera)
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
}
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
const atmosphereRef = useRef<AtmosphereApi>(null)
|
|
80
|
+
useFrame(() => {
|
|
81
|
+
atmosphereRef.current?.updateByDate(new Date())
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const [clouds, setClouds] = useState<CloudsEffect | null>(null)
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<Atmosphere ref={atmosphereRef} correctAltitude={correctAltitude}>
|
|
88
|
+
<MapViewport apiKey={apiKey} onLoadError={onLoadError}>
|
|
89
|
+
<GlobeControls
|
|
90
|
+
enableDamping
|
|
91
|
+
// Globe controls adjust the camera height based on very low LoD tiles
|
|
92
|
+
// during the initial load, causing the camera to unexpectedly jump to
|
|
93
|
+
// the sky when set to a low altitude.
|
|
94
|
+
// Re-enable it when the user first drags.
|
|
95
|
+
adjustHeight={false}
|
|
96
|
+
maxAltitude={Math.PI * 0.55} // Permit grazing angles
|
|
97
|
+
/>
|
|
98
|
+
</MapViewport>
|
|
99
|
+
{children}
|
|
100
|
+
<MapEffectComposer ref={composerRef} multisampling={0}>
|
|
101
|
+
<Fragment
|
|
102
|
+
// Effects are order-dependant; we need to reconstruct the nodes.
|
|
103
|
+
key={JSON.stringify([
|
|
104
|
+
correctGeometricError,
|
|
105
|
+
enableClouds,
|
|
106
|
+
enableAerialPerspective,
|
|
107
|
+
enableToneMapping,
|
|
108
|
+
exposure
|
|
109
|
+
])}
|
|
110
|
+
>
|
|
111
|
+
{enableClouds && (
|
|
112
|
+
<Clouds
|
|
113
|
+
ref={setClouds}
|
|
114
|
+
shadow-farScale={0.25}
|
|
115
|
+
coverage={cloudsCoverage}
|
|
116
|
+
animate
|
|
117
|
+
/>
|
|
118
|
+
)}
|
|
119
|
+
{enableAerialPerspective && (
|
|
120
|
+
<AerialPerspective
|
|
121
|
+
sky
|
|
122
|
+
sunLight
|
|
123
|
+
skyLight
|
|
124
|
+
correctGeometricError={correctGeometricError}
|
|
125
|
+
albedoScale={2 / Math.PI}
|
|
126
|
+
/>
|
|
127
|
+
)}
|
|
128
|
+
{enableToneMapping && (
|
|
129
|
+
<>
|
|
130
|
+
<ToneMapping mode={exposure > 5 ? 'reinhard' : 'aces'} />
|
|
131
|
+
<SMAA />
|
|
132
|
+
</>
|
|
133
|
+
)}
|
|
134
|
+
</Fragment>
|
|
135
|
+
</MapEffectComposer>
|
|
136
|
+
</Atmosphere>
|
|
137
|
+
)
|
|
138
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { TilesRenderer as TilesRendererImpl } from '3d-tiles-renderer'
|
|
2
|
+
import {
|
|
3
|
+
GLTFExtensionsPlugin,
|
|
4
|
+
GoogleCloudAuthPlugin,
|
|
5
|
+
TileCompressionPlugin,
|
|
6
|
+
TilesFadePlugin,
|
|
7
|
+
UpdateOnChangePlugin
|
|
8
|
+
} from '3d-tiles-renderer/plugins'
|
|
9
|
+
import {
|
|
10
|
+
TilesAttributionOverlay,
|
|
11
|
+
TilesPlugin,
|
|
12
|
+
TilesRenderer
|
|
13
|
+
} from '3d-tiles-renderer/r3f'
|
|
14
|
+
import { useEffect, useState, type FC, type ReactNode, type Ref } from 'react'
|
|
15
|
+
import { mergeRefs } from 'react-merge-refs'
|
|
16
|
+
import { DRACOLoader } from 'three/addons/loaders/DRACOLoader.js'
|
|
17
|
+
|
|
18
|
+
import { radians } from '@takram/three-geospatial'
|
|
19
|
+
|
|
20
|
+
import { TileCreasedNormalsPlugin } from '../plugins/TileCreasedNormalsPlugin'
|
|
21
|
+
|
|
22
|
+
const dracoLoader = new DRACOLoader()
|
|
23
|
+
dracoLoader.setDecoderPath('https://www.gstatic.com/draco/v1/decoders/')
|
|
24
|
+
|
|
25
|
+
export interface MapViewportProps {
|
|
26
|
+
apiKey: string
|
|
27
|
+
ref?: Ref<TilesRendererImpl>
|
|
28
|
+
children?: ReactNode
|
|
29
|
+
onLoadError?: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const MapViewport: FC<MapViewportProps> = ({
|
|
33
|
+
apiKey,
|
|
34
|
+
ref,
|
|
35
|
+
children,
|
|
36
|
+
onLoadError
|
|
37
|
+
}) => {
|
|
38
|
+
const [tiles, setTiles] = useState<TilesRendererImpl | null>(null)
|
|
39
|
+
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (tiles == null) {
|
|
42
|
+
return
|
|
43
|
+
}
|
|
44
|
+
if (onLoadError) {
|
|
45
|
+
const callback = (): void => {
|
|
46
|
+
onLoadError()
|
|
47
|
+
}
|
|
48
|
+
tiles.addEventListener('load-error', callback)
|
|
49
|
+
return () => {
|
|
50
|
+
tiles.removeEventListener('load-error', callback)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}, [tiles, onLoadError])
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TilesRenderer
|
|
57
|
+
ref={mergeRefs([ref, setTiles])}
|
|
58
|
+
// Reconstruct tiles when API key changes.
|
|
59
|
+
key={apiKey}
|
|
60
|
+
// The root URL sometimes becomes null without specifying the URL.
|
|
61
|
+
url={`https://tile.googleapis.com/v1/3dtiles/root.json?key=${apiKey}`}
|
|
62
|
+
>
|
|
63
|
+
<TilesPlugin
|
|
64
|
+
plugin={GoogleCloudAuthPlugin}
|
|
65
|
+
args={{
|
|
66
|
+
apiToken: apiKey,
|
|
67
|
+
autoRefreshToken: true
|
|
68
|
+
}}
|
|
69
|
+
/>
|
|
70
|
+
<TilesPlugin plugin={GLTFExtensionsPlugin} dracoLoader={dracoLoader} />
|
|
71
|
+
<TilesPlugin plugin={TileCompressionPlugin} />
|
|
72
|
+
<TilesPlugin plugin={UpdateOnChangePlugin} />
|
|
73
|
+
<TilesPlugin plugin={TilesFadePlugin} />
|
|
74
|
+
<TilesPlugin
|
|
75
|
+
plugin={TileCreasedNormalsPlugin}
|
|
76
|
+
args={{ creaseAngle: radians(30) }}
|
|
77
|
+
/>
|
|
78
|
+
{children}
|
|
79
|
+
<TilesAttributionOverlay />
|
|
80
|
+
</TilesRenderer>
|
|
81
|
+
)
|
|
82
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { useThree } from '@react-three/fiber'
|
|
2
|
+
import { useEffect, useRef, useState, type RefObject } from 'react'
|
|
3
|
+
import type { TilesRenderer } from '3d-tiles-renderer'
|
|
4
|
+
import { Ray, Vector2, Vector3, type Camera } from 'three'
|
|
5
|
+
import { useFrame } from '@react-three/fiber'
|
|
6
|
+
|
|
7
|
+
import { Ellipsoid, Geodetic, degrees } from '@takram/three-geospatial'
|
|
8
|
+
|
|
9
|
+
import { getCameraView } from '../utils/camera'
|
|
10
|
+
import type { CameraView, LatLng } from '../index'
|
|
11
|
+
|
|
12
|
+
export interface MapClickEvent {
|
|
13
|
+
latlng: LatLng
|
|
14
|
+
point: { x: number; y: number }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface MapHoverEvent {
|
|
18
|
+
latlng: LatLng | null
|
|
19
|
+
point: { x: number; y: number } | null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Convert mouse event to geodetic coordinates by raycasting to ellipsoid
|
|
24
|
+
*/
|
|
25
|
+
function mouseEventToLatLng(
|
|
26
|
+
event: MouseEvent,
|
|
27
|
+
camera: Camera,
|
|
28
|
+
gl: { domElement: HTMLElement }
|
|
29
|
+
): LatLng | null {
|
|
30
|
+
const rect = gl.domElement.getBoundingClientRect()
|
|
31
|
+
const x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
|
32
|
+
const y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
33
|
+
|
|
34
|
+
// Create ray from camera through mouse position
|
|
35
|
+
const pointer = new Vector2(x, y)
|
|
36
|
+
|
|
37
|
+
// Get camera position and direction
|
|
38
|
+
const origin = new Vector3().setFromMatrixPosition(camera.matrixWorld)
|
|
39
|
+
|
|
40
|
+
// Unproject mouse coordinates to get direction vector
|
|
41
|
+
// For perspective camera, use near plane (z = -1) and far plane (z = 1)
|
|
42
|
+
const nearPoint = new Vector3(pointer.x, pointer.y, -1)
|
|
43
|
+
const farPoint = new Vector3(pointer.x, pointer.y, 1)
|
|
44
|
+
|
|
45
|
+
// Transform to world space
|
|
46
|
+
nearPoint.unproject(camera)
|
|
47
|
+
farPoint.unproject(camera)
|
|
48
|
+
|
|
49
|
+
// Calculate direction vector
|
|
50
|
+
const direction = farPoint.sub(nearPoint).normalize()
|
|
51
|
+
|
|
52
|
+
// Create ray
|
|
53
|
+
const ray = new Ray(origin, direction)
|
|
54
|
+
|
|
55
|
+
// Find intersection with ellipsoid
|
|
56
|
+
const ellipsoid = Ellipsoid.WGS84
|
|
57
|
+
const intersection = ellipsoid.getIntersection(ray)
|
|
58
|
+
|
|
59
|
+
if (intersection == null) {
|
|
60
|
+
return null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Convert ECEF to geodetic coordinates
|
|
64
|
+
const geodetic = new Geodetic()
|
|
65
|
+
geodetic.setFromECEF(intersection, { ellipsoid })
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
latitude: degrees(geodetic.latitude),
|
|
69
|
+
longitude: degrees(geodetic.longitude),
|
|
70
|
+
altitude: geodetic.height
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Hook for handling map click events
|
|
76
|
+
*/
|
|
77
|
+
export function useMapClick(
|
|
78
|
+
tilesRenderer: RefObject<TilesRenderer | null>,
|
|
79
|
+
onClick?: (event: MapClickEvent) => void
|
|
80
|
+
): {
|
|
81
|
+
handleClick: (event: MouseEvent) => void
|
|
82
|
+
} {
|
|
83
|
+
const { camera, gl } = useThree()
|
|
84
|
+
const onClickRef = useRef(onClick)
|
|
85
|
+
onClickRef.current = onClick
|
|
86
|
+
|
|
87
|
+
const handleClick = (event: MouseEvent) => {
|
|
88
|
+
if (!onClickRef.current || !tilesRenderer.current) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const latlng = mouseEventToLatLng(event, camera, gl)
|
|
93
|
+
|
|
94
|
+
if (latlng == null) {
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
onClickRef.current({
|
|
99
|
+
latlng,
|
|
100
|
+
point: { x: event.clientX, y: event.clientY }
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return { handleClick }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Hook for handling map hover events
|
|
109
|
+
*/
|
|
110
|
+
export function useMapHover(
|
|
111
|
+
tilesRenderer: RefObject<TilesRenderer | null>,
|
|
112
|
+
onHover?: (event: MapHoverEvent) => void
|
|
113
|
+
): {
|
|
114
|
+
handleMouseMove: (event: MouseEvent) => void
|
|
115
|
+
handleMouseLeave: () => void
|
|
116
|
+
hoveredLatLng: LatLng | null
|
|
117
|
+
} {
|
|
118
|
+
const { camera, gl } = useThree()
|
|
119
|
+
const onHoverRef = useRef(onHover)
|
|
120
|
+
onHoverRef.current = onHover
|
|
121
|
+
const [hoveredLatLng, setHoveredLatLng] = useState<LatLng | null>(null)
|
|
122
|
+
|
|
123
|
+
const handleMouseMove = (event: MouseEvent) => {
|
|
124
|
+
if (!tilesRenderer.current) {
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const latlng = mouseEventToLatLng(event, camera, gl)
|
|
129
|
+
|
|
130
|
+
setHoveredLatLng(latlng)
|
|
131
|
+
if (onHoverRef.current) {
|
|
132
|
+
onHoverRef.current({
|
|
133
|
+
latlng,
|
|
134
|
+
point: { x: event.clientX, y: event.clientY }
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const handleMouseLeave = () => {
|
|
140
|
+
setHoveredLatLng(null)
|
|
141
|
+
if (onHoverRef.current) {
|
|
142
|
+
onHoverRef.current({
|
|
143
|
+
latlng: null,
|
|
144
|
+
point: null
|
|
145
|
+
})
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { handleMouseMove, handleMouseLeave, hoveredLatLng }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Hook for listening to camera position changes
|
|
154
|
+
*/
|
|
155
|
+
export function useMapCameraChange(
|
|
156
|
+
onCameraChange?: (view: CameraView) => void
|
|
157
|
+
): {
|
|
158
|
+
currentView: CameraView | null
|
|
159
|
+
} {
|
|
160
|
+
const camera = useThree(({ camera }) => camera)
|
|
161
|
+
const onCameraChangeRef = useRef(onCameraChange)
|
|
162
|
+
onCameraChangeRef.current = onCameraChange
|
|
163
|
+
const [currentView, setCurrentView] = useState<CameraView | null>(null)
|
|
164
|
+
|
|
165
|
+
useFrame(() => {
|
|
166
|
+
const view = getCameraView(camera)
|
|
167
|
+
if (view) {
|
|
168
|
+
setCurrentView(view)
|
|
169
|
+
if (onCameraChangeRef.current) {
|
|
170
|
+
onCameraChangeRef.current(view)
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
return { currentView }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Hook for listening to tile loading events
|
|
180
|
+
*/
|
|
181
|
+
export function useMapTileLoad(
|
|
182
|
+
tilesRenderer: RefObject<TilesRenderer | null>,
|
|
183
|
+
onTileLoad?: () => void
|
|
184
|
+
): {
|
|
185
|
+
tilesLoaded: boolean
|
|
186
|
+
} {
|
|
187
|
+
const [tilesLoaded, setTilesLoaded] = useState(false)
|
|
188
|
+
const onTileLoadRef = useRef(onTileLoad)
|
|
189
|
+
onTileLoadRef.current = onTileLoad
|
|
190
|
+
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
const tiles = tilesRenderer.current
|
|
193
|
+
if (!tiles) {
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const handleLoadTileset = () => {
|
|
198
|
+
setTilesLoaded(true)
|
|
199
|
+
if (onTileLoadRef.current) {
|
|
200
|
+
onTileLoadRef.current()
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
tiles.addEventListener('load-tileset', handleLoadTileset)
|
|
205
|
+
return () => {
|
|
206
|
+
tiles.removeEventListener('load-tileset', handleLoadTileset)
|
|
207
|
+
}
|
|
208
|
+
}, [tilesRenderer])
|
|
209
|
+
|
|
210
|
+
return { tilesLoaded }
|
|
211
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// MapKit Public API Surface (v0.1)
|
|
2
|
+
|
|
3
|
+
export type LatLng = {
|
|
4
|
+
latitude: number
|
|
5
|
+
longitude: number
|
|
6
|
+
altitude?: number
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export type CameraView = {
|
|
10
|
+
latitude: number
|
|
11
|
+
longitude: number
|
|
12
|
+
heading: number
|
|
13
|
+
pitch: number
|
|
14
|
+
distance: number
|
|
15
|
+
fov?: number
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type MapEntity = {
|
|
19
|
+
id: string
|
|
20
|
+
type: 'model' | 'billboard'
|
|
21
|
+
latitude: number
|
|
22
|
+
longitude: number
|
|
23
|
+
altitude?: number
|
|
24
|
+
rotation?: [number, number, number]
|
|
25
|
+
scale?: number
|
|
26
|
+
url?: string
|
|
27
|
+
metadata?: Record<string, unknown>
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Components
|
|
31
|
+
export { MapViewport } from './components/MapViewport'
|
|
32
|
+
export type { MapViewportProps } from './components/MapViewport'
|
|
33
|
+
|
|
34
|
+
export { MapScene } from './components/MapScene'
|
|
35
|
+
export type { MapSceneProps } from './components/MapScene'
|
|
36
|
+
|
|
37
|
+
export { MapEntity } from './components/MapEntity'
|
|
38
|
+
export type { MapEntityProps } from './components/MapEntity'
|
|
39
|
+
|
|
40
|
+
// Utilities
|
|
41
|
+
export { flyTo } from './utils/flyTo'
|
|
42
|
+
export { setCameraView, getCameraView } from './utils/camera'
|
|
43
|
+
export { createPreset, getPreset, commonPresets } from './utils/presets'
|
|
44
|
+
|
|
45
|
+
// Hooks
|
|
46
|
+
export {
|
|
47
|
+
useMapClick,
|
|
48
|
+
useMapHover,
|
|
49
|
+
useMapCameraChange,
|
|
50
|
+
useMapTileLoad
|
|
51
|
+
} from './hooks/useMapEvents'
|
|
52
|
+
export type {
|
|
53
|
+
MapClickEvent,
|
|
54
|
+
MapHoverEvent
|
|
55
|
+
} from './hooks/useMapEvents'
|