diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b7d3ca894..1c16a079e 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,5 +1,6 @@ * @KaWaite /e2e/ @rot1024 +/src/core/ @rot1024 /.github/ @KaWaite @rot1024 /src/components/atoms/Plugin/ @KaWaite @rot1024 /src/components/molecules/Visualizer/ @KaWaite @rot1024 diff --git a/.vscode/settings.json b/.vscode/settings.json index 1275bbd5d..170b9e5d8 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,5 +2,6 @@ "typescript.tsdk": "node_modules/typescript/lib", "yaml.schemas": { "https://json.schemastore.org/github-workflow": "/.github/workflows/**/*.yml" - } + }, + "vitest.commandLine": "yarn run vitest watch" } diff --git a/package.json b/package.json index bdeb1f43f..ecba97d5f 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,11 @@ "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "13.4.0", "@testing-library/user-event": "14.4.3", + "@types/apollo-upload-client": "17.0.2", + "@types/gapi.auth2": "0.0.56", + "@types/gapi.client": "1.0.5", + "@types/gapi.client.sheets": "4.0.20201030", + "@types/google.picker": "0.0.39", "@types/lodash-es": "4.17.6", "@types/node": "18.11.9", "@types/react": "18.0.24", @@ -57,6 +62,8 @@ "@types/react-leaflet": "2.8.2", "@types/storybook__addon-info": "5.2.5", "@types/styled-components": "5.1.26", + "@types/tinycolor2": "1.4.3", + "@types/uuid": "^9.0.0", "@vitejs/plugin-react": "2.2.0", "@vitest/coverage-c8": "0.24.5", "@welldone-software/why-did-you-render": "7.0.1", @@ -95,12 +102,7 @@ "@rot1024/use-transition": "1.0.0", "@sentry/browser": "6.19.7", "@seznam/compose-react-refs": "1.0.6", - "@types/apollo-upload-client": "17.0.2", - "@types/gapi.auth2": "0.0.56", - "@types/gapi.client": "1.0.5", - "@types/gapi.client.sheets": "4.0.20201030", - "@types/google.picker": "0.0.39", - "@types/tinycolor2": "1.4.3", + "@turf/turf": "^6.5.0", "@ungap/event-target": "0.2.3", "apollo-link-sentry": "3.2.0", "apollo-upload-client": "17.0.0", @@ -155,6 +157,7 @@ "ts-easing": "0.2.0", "use-callback-ref": "1.3.0", "use-custom-compare": "1.2.0", - "use-file-input": "1.0.0" + "use-file-input": "1.0.0", + "uuid": "^9.0.0" } } diff --git a/src/core/Crust/Infobox/index.tsx b/src/core/Crust/Infobox/index.tsx new file mode 100644 index 000000000..3c2c61e10 --- /dev/null +++ b/src/core/Crust/Infobox/index.tsx @@ -0,0 +1,5 @@ +export type Props = {}; + +export default function Infobox(_props: Props): JSX.Element | null { + return null; +} diff --git a/src/core/Crust/Plugins/index.tsx b/src/core/Crust/Plugins/index.tsx new file mode 100644 index 000000000..f07b40133 --- /dev/null +++ b/src/core/Crust/Plugins/index.tsx @@ -0,0 +1,5 @@ +export type Props = {}; + +export default function Plugins(_props: Props): JSX.Element | null { + return null; +} diff --git a/src/core/Crust/Widgets/index.tsx b/src/core/Crust/Widgets/index.tsx new file mode 100644 index 000000000..c759e396b --- /dev/null +++ b/src/core/Crust/Widgets/index.tsx @@ -0,0 +1,5 @@ +export type Props = {}; + +export default function Widgets(_props: Props): JSX.Element | null { + return null; +} diff --git a/src/core/Crust/index.tsx b/src/core/Crust/index.tsx new file mode 100644 index 000000000..26ff3fdaf --- /dev/null +++ b/src/core/Crust/index.tsx @@ -0,0 +1,5 @@ +export type Props = {}; + +export default function Crust(_props: Props): JSX.Element | null { + return null; +} diff --git a/src/core/Map/ClusteredLayers/index.tsx b/src/core/Map/ClusteredLayers/index.tsx new file mode 100644 index 000000000..3c073ddf0 --- /dev/null +++ b/src/core/Map/ClusteredLayers/index.tsx @@ -0,0 +1,93 @@ +import { ComponentType, useMemo, useCallback, ReactNode } from "react"; + +import type { Layer, Atom, Typography, DataType } from "../../mantle"; +import LayerComponent, { type CommonProps, type Props as LayerProps } from "../Layer"; + +export type Props = { + layers?: Layer[]; + atomMap?: Map; + overrides?: Record>; + selectedLayerId?: string; + isHidden?: (id: string) => boolean; + clusters?: Cluster[]; + delegatedDataTypes?: DataType[]; + clusterComponent?: ClusterComponentType; + Feature?: LayerProps["Feature"]; +} & Omit; + +export type Cluster = { + id: string; + property?: ClusterProperty; + layers?: string[]; +}; + +export type ClusterComponentProps = { + cluster: Cluster; + property?: ClusterProperty; + children?: ReactNode; +}; + +export type ClusterProperty = { + default?: { + clusterPixelRange: number; + clusterMinSize: number; + clusterLabelTypography?: Typography; + clusterImage?: string; + clusterImageHeight?: number; + clusterImageWidth?: number; + }; + layers?: { layer?: string }[]; +}; + +export type ClusterComponentType = ComponentType; + +export default function ClusteredLayers({ + clusters, + clusterComponent, + layers, + atomMap, + selectedLayerId, + overrides, + delegatedDataTypes, + isHidden, + ...props +}: Props): JSX.Element | null { + const Cluster = clusterComponent; + const clusteredLayers = useMemo>( + () => new Set(clusters?.flatMap(c => (c.layers ?? []).filter(Boolean))), + [clusters], + ); + + const renderLayer = useCallback( + (layer: Layer) => { + const a = atomMap?.get(layer.id); + return !layer.id || !a ? null : ( + + ); + }, + [atomMap, isHidden, overrides, props, selectedLayerId, delegatedDataTypes], + ); + + return ( + <> + {Cluster && + clusters + ?.filter(cluster => !!cluster.id) + .map(cluster => ( + + {layers?.filter(layer => cluster?.layers?.some(l => l === layer.id)).map(renderLayer)} + + ))} + {layers?.filter(layer => !clusteredLayers.has(layer.id)).map(renderLayer)} + + ); +} diff --git a/src/core/Map/Layer/hooks.ts b/src/core/Map/Layer/hooks.ts new file mode 100644 index 000000000..34a64280e --- /dev/null +++ b/src/core/Map/Layer/hooks.ts @@ -0,0 +1,58 @@ +import { useAtom } from "jotai"; +import { useCallback, useLayoutEffect, useMemo } from "react"; + +import { computeAtom, DataType, type Atom } from "../../mantle"; +import type { DataRange, Feature, Layer } from "../../mantle"; + +export type { Atom as Atoms } from "../../mantle"; + +export const createAtom = computeAtom; + +export default function useHooks( + layer: Layer | undefined, + atom: Atom | undefined, + overrides?: Record, + delegatedDataTypes?: DataType[], +) { + const [computedLayer, set] = useAtom(useMemo(() => atom ?? createAtom(), [atom])); + const writeFeatures = useCallback( + (features: Feature[]) => set({ type: "writeFeatures", features }), + [set], + ); + const requestFetch = useCallback( + (range: DataRange) => set({ type: "requestFetch", range }), + [set], + ); + const deleteFeatures = useCallback( + (features: string[]) => set({ type: "deleteFeatures", features }), + [set], + ); + + useLayoutEffect(() => { + set({ type: "updateDelegatedDataTypes", delegatedDataTypes: delegatedDataTypes ?? [] }); + }, [delegatedDataTypes, set]); + + useLayoutEffect(() => { + set({ + type: "override", + overrides, + }); + }, [set, overrides]); + + useLayoutEffect(() => { + set({ + type: "setLayer", + layer: + typeof layer?.visible === "undefined" || layer?.type === null || layer?.type + ? layer + : undefined, + }); + }, [layer, set]); + + return { + computedLayer, + handleFeatureRequest: requestFetch, + handleFeatureFetch: writeFeatures, + handleFeatureDelete: deleteFeatures, + }; +} diff --git a/src/core/Map/Layer/index.tsx b/src/core/Map/Layer/index.tsx new file mode 100644 index 000000000..c2284042e --- /dev/null +++ b/src/core/Map/Layer/index.tsx @@ -0,0 +1,59 @@ +import { ComponentType } from "react"; + +import type { DataRange, Feature, ComputedLayer, Layer, DataType } from "../../mantle"; + +import useHooks, { type Atoms } from "./hooks"; + +export type { Layer, LayerSimple } from "../../mantle"; + +export type FeatureComponentType = ComponentType; + +export type CommonProps = { + isBuilt?: boolean; + isEditable?: boolean; + isHidden?: boolean; + isSelected?: boolean; + sceneProperty?: any; +}; + +export type FeatureComponentProps = { + layer: ComputedLayer; + onFeatureRequest?: (range: DataRange) => void; + onFeatureFetch?: (features: Feature[]) => void; + onFeatureDelete?: (features: string[]) => void; +} & CommonProps; + +export type Props = { + layer?: Layer; + atom?: Atoms; + overrides?: Record; + delegatedDataTypes?: DataType[]; + /** Feature component should be injected by a map engine. */ + Feature?: ComponentType; +} & CommonProps; + +export default function LayerComponent({ + Feature, + layer, + atom: atoms, + overrides, + delegatedDataTypes, + ...props +}: Props): JSX.Element | null { + const { computedLayer, handleFeatureDelete, handleFeatureFetch, handleFeatureRequest } = useHooks( + Feature ? layer : undefined, + atoms, + overrides, + delegatedDataTypes, + ); + + return layer && computedLayer && Feature ? ( + + ) : null; +} diff --git a/src/core/Map/Layers/hooks.test.ts b/src/core/Map/Layers/hooks.test.ts new file mode 100644 index 000000000..672984c04 --- /dev/null +++ b/src/core/Map/Layers/hooks.test.ts @@ -0,0 +1,595 @@ +import { renderHook } from "@testing-library/react"; +import { useRef } from "react"; +import { expect, test, vi } from "vitest"; + +import useHooks, { type Layer, type Ref } from "./hooks"; + +test("hooks", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { + id: "z", + type: "group", + children: [ + { + id: "y", + type: "simple", + title: "Y", + }, + ], + }, + { id: "w", type: "simple", title: "W" }, + ]; + + const { + result: { current }, + } = renderHook(() => useHooks({ layers })); + + expect(current.flattenedLayers).toEqual([ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + { id: "w", type: "simple", title: "W" }, + ]); + expect(current.atomMap.get("y")).not.toBeUndefined(); + expect(current.atomMap.get("v")).toBeUndefined(); +}); + +test("isLayer", () => { + const layers: Layer[] = [{ id: "x", type: "simple" }]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.isLayer(1)).toBe(false); + expect(ref?.isLayer({})).toBe(false); + const layer = ref?.findById("x"); + if (!layer) throw new Error("layer is not found"); + expect(ref?.isLayer(layer)).toBe(true); +}); + +test("layers", () => { + const layers: Layer[] = [ + { id: "x", type: "simple" }, + { id: "y", type: "group", children: [{ id: "z", type: "simple" }] }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + const res = ref?.layers(); + expect(res).toHaveLength(2); + expect(res?.[0].id).toBe("x"); + expect(res?.[1].id).toBe("y"); +}); + +test("findById", () => { + const layers: Layer[] = [{ id: "x", type: "simple", title: "X" }]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.findById("y")).toBeUndefined(); + const layer = ref?.findById("x"); + expect(layer?.id).toBe("x"); + expect(layer?.title).toBe("X"); + expect(layer?.computed).toBeUndefined(); +}); + +test("findByIds", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.findByIds("a", "b")).toEqual([undefined, undefined]); + + const found = ref?.findByIds("x", "y"); + expect(found?.[0]?.id).toBe("x"); + expect(found?.[0]?.title).toBe("X"); + expect(found?.[1]?.id).toBe("y"); + expect(found?.[1]?.title).toBe("Y"); +}); + +test("walk", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { id: "z", type: "group", children: [{ id: "y", type: "simple", title: "Y" }] }, + { id: "w", type: "simple", title: "W" }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + const cb1 = vi.fn(() => "a"); + expect(ref?.walk(cb1)).toBe("a"); + expect(cb1.mock.calls).toEqual([[{ id: "x" }, 0, [{ id: "x" }, { id: "z" }, { id: "w" }]]]); + + const cb2 = vi.fn(); + expect(ref?.walk(cb2)).toBeUndefined(); + expect(cb2.mock.calls).toEqual([ + [{ id: "x" }, 0, [{ id: "x" }, { id: "z" }, { id: "w" }]], + [{ id: "z" }, 1, [{ id: "x" }, { id: "z" }, { id: "w" }]], + [{ id: "y" }, 0, [{ id: "y" }]], + [{ id: "w" }, 2, [{ id: "x" }, { id: "z" }, { id: "w" }]], + ]); +}); + +test("find", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { id: "z", type: "group", children: [{ id: "y", type: "simple", title: "Y" }] }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.find(l => l.title === "A")).toBeUndefined(); + + const found = ref?.find(l => l.title === "X" || l.title === "Y"); + expect(found?.id).toBe("x"); + expect(found?.title).toBe("X"); +}); + +test("findAll", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { id: "z", type: "group", children: [{ id: "y", type: "simple", title: "Y" }] }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.findAll(l => l.title === "A")).toEqual([]); + + const found = ref?.findAll(l => l.title === "X" || l.title === "Y"); + expect(found?.[0]?.id).toBe("x"); + expect(found?.[0]?.title).toBe("X"); + expect(found?.[1]?.id).toBe("y"); + expect(found?.[1]?.title).toBe("Y"); +}); + +test("findByTags", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X", tags: [{ id: "tag", label: "Tag" }] }, + { + id: "z", + type: "group", + children: [ + { + id: "y", + type: "simple", + title: "Y", + tags: [ + { id: "tag2", label: "Tag2" }, + { id: "tag", label: "Tag" }, + ], + }, + ], + }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.findByTags("Tag")).toEqual([]); + + const found = ref?.findByTags("tag", "tag2"); + expect(found?.[0]?.id).toBe("x"); + expect(found?.[0]?.title).toBe("X"); + expect(found?.[1]?.id).toBe("y"); + expect(found?.[1]?.title).toBe("Y"); +}); + +test("findByTagLabels", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X", tags: [{ id: "tag", label: "Tag" }] }, + { + id: "z", + type: "group", + children: [ + { + id: "y", + type: "simple", + title: "Y", + tags: [ + { id: "tag2", label: "Tag2" }, + { id: "tag", label: "Tag" }, + ], + }, + ], + }, + ]; + + const { + result: { + current: { current: ref }, + }, + } = renderHook(() => { + const ref = useRef(null); + const _ = useHooks({ layers, ref }); + return ref; + }); + + expect(ref?.findByTagLabels("tag")).toEqual([]); + + const found = ref?.findByTagLabels("Tag", "Tag2"); + expect(found?.[0]?.id).toBe("x"); + expect(found?.[0]?.title).toBe("X"); + expect(found?.[1]?.id).toBe("y"); + expect(found?.[1]?.title).toBe("Y"); +}); + +test("add, replace, delete", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { + id: "z", + type: "group", + children: [ + { + id: "y", + type: "simple", + title: "Y", + }, + ], + }, + { id: "w", type: "simple", title: "W" }, + ]; + + const { result, rerender } = renderHook(() => { + const ref = useRef(null); + const { flattenedLayers } = useHooks({ layers, ref }); + return { ref, flattenedLayers }; + }); + + const idLength = 36; + const addedLayers = result.current.ref.current?.addAll({ + type: "group", + title: "C", + children: [ + { + type: "group", + title: "B", + children: [ + { + type: "simple", + title: "A", + infobox: { + blocks: [{ extensionId: "a" }], + }, + }, + ], + }, + ], + }); + const l = addedLayers?.[0]; + expect(l?.id).toBeTypeOf("string"); + expect(l?.id).toHaveLength(idLength); + expect(l?.type).toBe("group"); + expect(l?.title).toBe("C"); + if (l?.type !== "group") throw new Error("invalid layer type"); + expect(l.children[0].id).toBeTypeOf("string"); + expect(l.children[0].id).toHaveLength(idLength); + expect(l.children[0].type).toBe("group"); + expect(l.children[0].title).toBe("B"); + if (l.children[0].type !== "group") throw new Error("invalid layer type"); + expect(l.children[0].children[0].id).toBeTypeOf("string"); + expect(l.children[0].children[0].id).toHaveLength(idLength); + expect(l.children[0].children[0].type).toBe("simple"); + expect(l.children[0].children[0].title).toBe("A"); + expect(l.children[0].children[0].infobox?.blocks?.[0].id).toBeTypeOf("string"); + expect(l.children[0].children[0].infobox?.blocks?.[0].id).toHaveLength(idLength); + + rerender(); + + expect(result.current.flattenedLayers).toEqual([ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + { id: "w", type: "simple", title: "W" }, + { + id: l.children[0].children[0].id, + type: "simple", + title: "A", + infobox: { + blocks: [{ id: l.children[0].children[0].infobox?.blocks?.[0].id, extensionId: "a" }], + }, + }, + ]); + + result.current.ref.current?.replace({ + id: l.children[0].children[0].id, + type: "simple", + title: "A!", + }); + + rerender(); + + expect(result.current.flattenedLayers).toEqual([ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + { id: "w", type: "simple", title: "W" }, + { + id: l.children[0].children[0].id, + type: "simple", + title: "A!", + }, + ]); + + result.current.ref.current?.deleteLayer(l.children[0].id, "w"); + + rerender(); + + expect(result.current.flattenedLayers).toEqual([ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + { id: "w", type: "simple", title: "W" }, + ]); +}); + +test("override", () => { + const dataValue = { + type: "Feature", + geometry: { type: "Point", coordinates: [1, 2] }, + hoge: "foobar", // test + }; + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { + id: "z", + type: "group", + children: [ + { + id: "y", + type: "simple", + title: "Y", + data: { type: "geojson", value: dataValue }, + marker: { pointSize: 10, pointColor: "red" }, + }, + ], + }, + ]; + + const { result, rerender } = renderHook(() => { + const ref = useRef(null); + const { flattenedLayers } = useHooks({ layers, ref }); + return { ref, flattenedLayers }; + }); + + const dataValue2 = { + type: "geojson", + value: { type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } }, + }; + result.current.ref.current?.override("y", { + id: "z", // should be ignored + ...({ + type: "group", // should be ignored + } as any), + title: "Y!", + data: { value: dataValue2 }, + marker: { pointSize: 100 }, + tags: [{ id: "t", label: "t" }], + }); + rerender(); + const l = result.current.flattenedLayers[1]; + if (l.type !== "simple") throw new Error("invalid layer type"); + expect(l.title).toBe("Y!"); + expect(l.data?.value).toBe(dataValue2); + expect(l.marker).toEqual({ pointSize: 100, pointColor: "red" }); + expect(l.tags).toEqual([{ id: "t", label: "t" }]); + expect(result.current.ref.current?.findById("y")?.title).toBe("Y"); + + result.current.ref.current?.override("y", { + title: "Y!!", + marker: { pointColor: "blue" }, + tags: [{ id: "t2", label: "t2" }], + }); + rerender(); + const l2 = result.current.flattenedLayers[1]; + if (l2.type !== "simple") throw new Error("invalid layer type"); + expect(l2.title).toBe("Y!!"); + expect(l2.data?.value).toBe(dataValue); + expect(l2.marker).toEqual({ pointSize: 10, pointColor: "blue" }); + expect(l2.tags).toEqual([{ id: "t2", label: "t2" }]); + + result.current.ref.current?.override("y"); + rerender(); + const l3 = result.current.flattenedLayers[1]; + if (l3.type !== "simple") throw new Error("invalid layer type"); + expect(l3.title).toBe("Y"); + expect(l3.data?.value).toBe(dataValue); + expect(l3.marker).toEqual({ pointSize: 10, pointColor: "red" }); + expect(l3.tags).toBeUndefined(); +}); + +test("hide and show", () => { + const layers: Layer[] = [ + { id: "x", type: "simple", title: "X" }, + { id: "y", type: "simple", title: "Y" }, + ]; + + const { result, rerender } = renderHook( + ({ hiddenLayers }: { hiddenLayers: string[] }) => { + const ref = useRef(null); + const { isHidden } = useHooks({ layers, ref, hiddenLayers }); + return { ref, isHidden }; + }, + { + initialProps: { hiddenLayers: ["y"] }, + }, + ); + + expect(result.current.isHidden("x")).toBe(false); + expect(result.current.isHidden("y")).toBe(true); + + result.current.ref.current?.hide("x"); + rerender({ hiddenLayers: ["y"] }); + + expect(result.current.isHidden("x")).toBe(true); + expect(result.current.isHidden("y")).toBe(true); + + rerender({ hiddenLayers: ["x"] }); + + expect(result.current.isHidden("x")).toBe(true); + expect(result.current.isHidden("y")).toBe(false); + + result.current.ref.current?.show("x"); + result.current.ref.current?.show("y"); + rerender({ hiddenLayers: ["x"] }); + + expect(result.current.isHidden("x")).toBe(true); + expect(result.current.isHidden("y")).toBe(false); + + rerender({ hiddenLayers: [] }); + + expect(result.current.isHidden("x")).toBe(false); + expect(result.current.isHidden("y")).toBe(false); +}); + +test("compat", () => { + const { result, rerender } = renderHook(() => { + const ref = useRef(null); + const { flattenedLayers } = useHooks({ ref }); + return { ref, flattenedLayers }; + }); + + result.current.ref.current?.add({ + type: "item", + title: "X", + extensionId: "marker", + property: { + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }, + } as any); + rerender(); + const l = result.current.flattenedLayers[0]; + if (l.type !== "simple") throw new Error("invalid layer type"); + expect(l.title).toBe("X"); + expect(l.marker).toEqual({ pointSize: 10 }); + expect(l.data).toEqual({ + type: "geojson", + value: { type: "Feature", geometry: { type: "Point", coordinates: [2, 1] } }, + }); + + const l2: any = result.current.ref.current?.findById(l.id); + expect(l2.marker).toEqual({ pointSize: 10 }); + expect(l2.pluginId).toBe("reearth"); + expect(l2.extensionId).toBe("marker"); + expect(l2.property).toEqual({ + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }); + + result.current.ref.current?.override(l.id, { + marker: { pointColor: "blue" }, + }); + rerender(); + const l3 = result.current.flattenedLayers[0]; + if (l3.type !== "simple") throw new Error("invalid layer type"); + expect(l3.marker).toEqual({ pointSize: 10, pointColor: "blue" }); + expect(l3.compat?.property).toEqual({ + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }); + + const l4: any = result.current.ref.current?.findById(l.id); + expect(l4.marker).toEqual({ pointSize: 10 }); + expect(l4.pluginId).toBe("reearth"); + expect(l4.extensionId).toBe("marker"); + expect(l4.property).toEqual({ + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }); + + result.current.ref.current?.override(l.id, { + property: { default: { pointColor: "yellow" } }, + } as any); + rerender(); + const l5 = result.current.flattenedLayers[0]; + if (l5.type !== "simple") throw new Error("invalid layer type"); + expect(l5.marker).toEqual({ pointSize: 10, pointColor: "yellow" }); + expect(l5.compat?.property).toEqual({ + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }); + + const l6: any = result.current.ref.current?.findById(l.id); + expect(l6.marker).toEqual({ pointSize: 10 }); + expect(l6.pluginId).toBe("reearth"); + expect(l6.extensionId).toBe("marker"); + expect(l6.property).toEqual({ + default: { + location: { lat: 1, lng: 2 }, + pointSize: 10, + }, + }); +}); diff --git a/src/core/Map/Layers/hooks.ts b/src/core/Map/Layers/hooks.ts new file mode 100644 index 000000000..67089e3a8 --- /dev/null +++ b/src/core/Map/Layers/hooks.ts @@ -0,0 +1,534 @@ +import { atom, useAtomValue } from "jotai"; +import { merge, omit } from "lodash-es"; +import { + ForwardedRef, + useCallback, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { useSet } from "react-use"; +import { v4 as uuidv4 } from "uuid"; + +import { objectFromGetter } from "@reearth/util/object"; + +import { computeAtom, convertLegacyLayer } from "../../mantle"; +import type { Atom, ComputedLayer, Layer, NaiveLayer } from "../../mantle"; +import { useGet } from "../utils"; + +import { computedLayerKeys, layerKeys } from "./keys"; + +export type { Layer, NaiveLayer } from "../../mantle"; + +/** + * Same as a Layer, but all fields except id is lazily evaluated, + * in order to reduce unnecessary sending and receiving of data to and from + * QuickJS (a plugin runtime) and to improve performance. + */ +export type LazyLayer = Readonly & { + computed?: Readonly; + // compat + pluginId?: string; + extensionId?: string; + property?: any; + propertyId?: string; + isVisible?: boolean; +}; + +export type Ref = { + findById: (id: string) => LazyLayer | undefined; + findByIds: (...ids: string[]) => (LazyLayer | undefined)[]; + add: (layer: NaiveLayer) => LazyLayer | undefined; + addAll: (...layers: NaiveLayer[]) => (LazyLayer | undefined)[]; + replace: (...layers: Layer[]) => void; + override: (id: string, layer?: Partial | null) => void; + deleteLayer: (...ids: string[]) => void; + isLayer: (obj: any) => obj is LazyLayer; + layers: () => LazyLayer[]; + walk: ( + fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => T | void, + ) => T | undefined; + find: ( + fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => boolean, + ) => LazyLayer | undefined; + findAll: (fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => boolean) => LazyLayer[]; + findByTags: (...tagIds: string[]) => LazyLayer[]; + findByTagLabels: (...tagLabels: string[]) => LazyLayer[]; + hide: (...layers: string[]) => void; + show: (...layers: string[]) => void; +}; + +export default function useHooks({ + layers, + ref, + hiddenLayers, +}: { + layers?: Layer[]; + ref?: ForwardedRef; + hiddenLayers?: string[]; +}) { + const layerMap = useMemo(() => new Map(), []); + const [overriddenLayers, setOverridenLayers] = useState[]>([]); + const atomMap = useMemo(() => new Map(), []); + const lazyLayerMap = useMemo(() => new Map(), []); + + const [hiddenLayerIds, { add: hideLayer, remove: showLayer }] = useSet(); + const isHidden = useCallback( + (id: string) => hiddenLayerIds.has(id) || !!hiddenLayers?.includes(id), + [hiddenLayerIds, hiddenLayers], + ); + + const layersRef = useGet(layers); + const [tempLayers, setTempLayers] = useState([]); + const tempLayersRef = useRef([]); + const flattenedLayers = useMemo((): Layer[] => { + const newLayers = [...flattenLayers(layers ?? []), ...flattenLayers(tempLayers)]; + // apply overrides + return newLayers.map(l => { + const ol: any = overriddenLayers.find(ll => ll.id === l.id); + if (!ol) return l; + + // prevents unnecessary copying of data value + const dataValue = ol.data?.value ?? (l.type === "simple" ? l.data?.value : undefined); + const res = merge( + {}, + { + ...l, + ...(l.type === "simple" && l.data ? { data: omit(l.data, "value") } : {}), + }, + { ...ol, ...(ol.data ? { data: omit(ol.data, "value") } : {}) }, + ); + + if (dataValue && res.data) { + res.data.value = dataValue; + } + + return res; + }); + }, [tempLayers, layers, overriddenLayers]); + + const getComputedLayer = useAtomValue( + useMemo( + () => + atom(get => (layerId: string) => { + const atoms = atomMap.get(layerId); + if (!atoms) return; + const cl = get(atoms); + return cl; + }), + [atomMap], + ), + ); + + const lazyComputedLayerPrototype = useMemo(() => { + return objectFromGetter( + // id and layer should not be accessible + computedLayerKeys, + function (key) { + const id: string | undefined = (this as any).id; + if (!id || typeof id !== "string") throw new Error("layer ID is not specified"); + + const layer = getComputedLayer(id); + if (!layer) return undefined; + return (layer as any)[key]; + }, + ); + }, [getComputedLayer]); + + const lazyLayerPrototype = useMemo(() => { + return objectFromGetter(layerKeys, function (key) { + const id: string | undefined = (this as any).id; + if (!id || typeof id !== "string") throw new Error("layer ID is not specified"); + + const layer = layerMap.get(id); + if (!layer) return undefined; + + // compat + if (key === "pluginId") return layer.compat?.extensionId ? "reearth" : undefined; + else if (key === "extensionId") return layer.compat?.extensionId; + else if (key === "property") return layer.compat?.property; + else if (key === "propertyId") return layer.compat?.propertyId; + else if (key === "isVisible") return layer.visible; + // computed + else if (key === "computed") { + const computedLayer = getComputedLayer(layer.id); + if (!computedLayer) return undefined; + const computed = Object.create(lazyComputedLayerPrototype); + computed.id = id; + return computed; + } + + return (layer as any)[key]; + }); + }, [getComputedLayer, layerMap, lazyComputedLayerPrototype]); + + const findById = useCallback( + (id: string): LazyLayer | undefined => { + const lazyLayer = lazyLayerMap.get(id); + if (lazyLayer) return lazyLayer; + + if (!layerMap.has(id)) return; + + const l = Object.create(lazyLayerPrototype); + l.id = id; + lazyLayerMap.set(id, l); + + return l; + }, + [layerMap, lazyLayerMap, lazyLayerPrototype], + ); + + const findByIds = useCallback( + (...ids: string[]): (LazyLayer | undefined)[] => { + return ids.map(id => findById(id)); + }, + [findById], + ); + + const add = useCallback( + (layer: NaiveLayer): LazyLayer | undefined => { + if (!isValidLayer(layer)) return; + + const rawLayer = compat(layer); + if (!rawLayer) return; + + const newLayer = { ...rawLayer, id: uuidv4() }; + + // generate ids for layers and blocks + walkLayers([newLayer], l => { + if (!l.id) { + l.id = uuidv4(); + } + l.infobox?.blocks?.forEach(b => { + if (b.id) return; + b.id = uuidv4(); + }); + layerMap.set(l.id, l); + atomMap.set(l.id, computeAtom()); + }); + + tempLayersRef.current = [...tempLayersRef.current, newLayer]; + setTempLayers(layers => [...layers, newLayer]); + + const newLazyLayer = findById(newLayer.id); + if (!newLazyLayer) throw new Error("layer not found"); + + return newLazyLayer; + }, + [atomMap, findById, layerMap], + ); + + const addAll = useCallback( + (...layers: NaiveLayer[]): (LazyLayer | undefined)[] => { + return layers.map(l => add(l)); + }, + [add], + ); + + const replace = useCallback( + (...layers: Layer[]) => { + const validLayers = layers + .filter(isValidLayer) + .map(compat) + .filter((l): l is Layer => !!l); + setTempLayers(currentLayers => { + replaceLayers(currentLayers, l => { + const i = validLayers.findIndex(ll => ll.id === l.id); + if (i >= 0) { + const newLayer = { ...validLayers[i] }; + tempLayersRef.current[i] = newLayer; + layerMap.set(newLayer.id, newLayer); + return newLayer; + } + return; + }); + return [...currentLayers]; + }); + }, + [layerMap], + ); + + const override = useCallback( + (id: string, layer?: Partial | null) => { + if (!layer) { + setOverridenLayers(layers => layers.filter(l => l.id !== id)); + return; + } + + const originalLayer = layerMap.get(id); + if (!originalLayer) return; + + const rawLayer = compat({ + ...layer, + ...(originalLayer.compat && "property" in layer + ? { + type: originalLayer.type === "group" ? "group" : "item", + extensionId: originalLayer.compat.extensionId, + } + : {}), + }); + if (!rawLayer) return; + const layer2 = { id, ...omit(rawLayer, "id", "type", "children", "compat") }; + setOverridenLayers(layers => { + const i = layers.findIndex(l => l.id === id); + if (i < 0) return [...layers, layer2]; + return [...layers.slice(0, i - 1), layer2, ...layers.slice(i + 1)]; + }); + }, + [layerMap], + ); + + const deleteLayer = useCallback( + (...ids: string[]) => { + setTempLayers(layers => { + const deleted: Layer[] = []; + const newLayers = filterLayers(layers, l => { + if (ids.includes(l.id)) { + deleted.push(l); + return false; + } + return true; + }); + deleted + .map(l => l.id) + .forEach(id => { + layerMap.delete(id); + atomMap.delete(id); + lazyLayerMap.delete(id); + showLayer(id); + }); + tempLayersRef.current = tempLayersRef.current.filter( + l => !deleted.find(ll => ll.id === l.id), + ); + return newLayers; + }); + }, + [layerMap, atomMap, lazyLayerMap, showLayer], + ); + + const isLayer = useCallback( + (obj: any): obj is LazyLayer => { + return typeof obj === "object" && Object.getPrototypeOf(obj) === lazyLayerPrototype; + }, + [lazyLayerPrototype], + ); + + const rootLayers = useCallback(() => { + return [...(layersRef() ?? []), ...tempLayersRef.current] + .map(l => findById(l.id)) + .filter((l): l is LazyLayer => !!l); + }, [findById, layersRef]); + + const walk = useCallback( + (fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => T | void): T | undefined => { + return walkLayers(layersRef() ?? [], (l, i, p) => { + const ll = findById(l.id); + if (!ll) return; + return fn( + ll, + i, + p.map(l => findById(l.id)).filter((l): l is LazyLayer => !!l), + ); + }); + }, + [findById, layersRef], + ); + + const find = useCallback( + ( + fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => boolean, + ): LazyLayer | undefined => { + return walk((...args) => (fn(...args) ? args[0] : undefined)); + }, + [walk], + ); + + const findAll = useCallback( + (fn: (layer: LazyLayer, index: number, parents: LazyLayer[]) => boolean): LazyLayer[] => { + const res: LazyLayer[] = []; + walk((...args) => { + if (fn(...args)) res.push(args[0]); + }); + return res; + }, + [walk], + ); + + const findByTags = useCallback( + (...tagIds: string[]): LazyLayer[] => { + return findAll( + l => + !!l.tags?.some( + t => tagIds.includes(t.id) || !!t.tags?.some(tt => tagIds.includes(tt.id)), + ), + ); + }, + [findAll], + ); + + const findByTagLabels = useCallback( + (...tagLabels: string[]): LazyLayer[] => { + return findAll( + l => + !!l.tags?.some( + t => tagLabels.includes(t.label) || !!t.tags?.some(tt => tagLabels.includes(tt.label)), + ), + ); + }, + [findAll], + ); + + const hideLayers = useCallback( + (...layers: string[]) => { + for (const l of layers) { + hideLayer(l); + } + }, + [hideLayer], + ); + + const showLayers = useCallback( + (...layers: string[]) => { + for (const l of layers) { + showLayer(l); + } + }, + [showLayer], + ); + + useImperativeHandle( + ref, + () => ({ + findById, + add, + addAll, + replace, + override, + deleteLayer, + findByIds, + isLayer, + layers: rootLayers, + walk, + find, + findAll, + findByTags, + findByTagLabels, + hide: hideLayers, + show: showLayers, + }), + [ + findById, + add, + addAll, + replace, + override, + deleteLayer, + findByIds, + isLayer, + rootLayers, + walk, + find, + findAll, + findByTags, + findByTagLabels, + hideLayers, + showLayers, + ], + ); + + useLayoutEffect(() => { + const ids = new Set(); + + walkLayers(layers ?? [], l => { + ids.add(l.id); + if (!atomMap.has(l.id)) { + atomMap.set(l.id, computeAtom()); + } + layerMap.set(l.id, l); + }); + + const deleted = Array.from(atomMap.keys()).filter(k => !ids.has(k)); + deleted.forEach(k => { + atomMap.delete(k); + layerMap.delete(k); + lazyLayerMap.delete(k); + showLayer(k); + }); + setOverridenLayers(layers => layers.filter(l => !deleted.includes(l.id))); + }, [atomMap, layers, layerMap, lazyLayerMap, setOverridenLayers, showLayer]); + + return { atomMap, flattenedLayers, isHidden }; +} + +function flattenLayers(layers: Layer[]): Layer[] { + return layers.flatMap(l => + l.type === "group" && Array.isArray(l.children) ? flattenLayers(l.children) : [l], + ); +} + +function walkLayers( + layers: Layer[], + cb: (layer: Layer, i: number, parent: Layer[]) => T | void, +): T | undefined { + for (let i = 0; i < layers.length; i++) { + const l = layers[i]; + const res = cb(l, i, layers); + if (typeof res !== "undefined") { + return res; + } + if (l.type === "group" && Array.isArray(l.children)) { + const res = walkLayers(l.children, cb); + if (typeof res !== "undefined") { + return res; + } + } + } + return; +} + +function replaceLayers( + layers: Layer[], + cb: (layer: Layer, i: number, parent: Layer[]) => Layer | void, +): Layer[] { + for (let i = 0; i < layers.length; i++) { + const l = layers[i]; + const nl = cb(l, i, layers); + if (nl) { + layers[i] = nl; + } + if (l.type === "group" && Array.isArray(l.children)) { + l.children = replaceLayers(l.children, cb); + } + } + return layers; +} + +function filterLayers( + layers: Layer[], + cb: (layer: Layer, i: number, parent: Layer[]) => boolean, +): Layer[] { + const newLayers: Layer[] = []; + for (let i = 0; i < layers.length; i++) { + const l = layers[i]; + if (cb(l, i, layers)) { + newLayers.push(l); + } + if (l.type === "group" && Array.isArray(l.children)) { + l.children = filterLayers(l.children, cb); + } + } + return newLayers; +} + +function isValidLayer(l: unknown): l is Layer { + return !!l && typeof l === "object" && ("type" in l || "extensionId" in l); +} + +function compat(layer: unknown): Layer | undefined { + if (!layer || typeof layer !== "object") return; + return "extensionId" in layer || "property" in layer + ? convertLegacyLayer(layer as any) + : (layer as Layer); +} diff --git a/src/core/Map/Layers/index.test.tsx b/src/core/Map/Layers/index.test.tsx new file mode 100644 index 000000000..498a90bb6 --- /dev/null +++ b/src/core/Map/Layers/index.test.tsx @@ -0,0 +1,177 @@ +import { useEffect, useRef, useState } from "react"; +import { expect, test, vi } from "vitest"; + +import { render, screen, waitFor } from "@reearth/test/utils"; + +import Component, { LayerSimple, Layer, FeatureComponentProps, Ref } from "."; + +test("simple", () => { + const Feature = vi.fn((_: FeatureComponentProps) =>
Hello
); + const layers: LayerSimple[] = [ + { id: "a", type: "simple" }, + { id: "b", type: "simple" }, + ]; + const { rerender } = render(); + + expect(screen.getAllByText("Hello")[0]).toBeVisible(); + expect(screen.getAllByText("Hello")).toHaveLength(2); + expect(Feature).toBeCalledTimes(2); + expect(Feature.mock.calls[0][0]).toEqual({ + isHidden: false, + isSelected: false, + layer: { + id: "a", + features: [], + layer: layers[0], + status: "ready", + originalFeatures: [], + }, + onFeatureDelete: expect.any(Function), + onFeatureFetch: expect.any(Function), + onFeatureRequest: expect.any(Function), + }); + expect(Feature.mock.calls[1][0]).toEqual({ + isHidden: false, + isSelected: false, + layer: { + id: "b", + features: [], + layer: layers[1], + status: "ready", + originalFeatures: [], + }, + onFeatureDelete: expect.any(Function), + onFeatureFetch: expect.any(Function), + onFeatureRequest: expect.any(Function), + }); + + Feature.mockClear(); + + const layers2: LayerSimple[] = [{ id: "c", type: "simple" }]; + rerender(); + expect(screen.getByText("Hello")).toBeVisible(); + expect(Feature.mock.calls[0][0].layer).toEqual({ + id: "c", + features: [], + layer: layers2[0], + status: "ready", + originalFeatures: [], + }); +}); + +test("tree", () => { + const Feature = vi.fn((_: FeatureComponentProps) => null); + const layers: Layer[] = [ + { + id: "a", + type: "group", + children: [ + { id: "b", type: "simple" }, + { id: "c", type: "group", children: [] }, + ], + }, + ]; + render(); + + expect(Feature).toBeCalledTimes(1); + expect(Feature.mock.calls[0][0].layer).toEqual({ + id: "b", + features: [], + layer: { id: "b", type: "simple" }, + status: "ready", + originalFeatures: [], + }); +}); + +test("ref", async () => { + const Feature = vi.fn((_: FeatureComponentProps) => null); + + function Comp({ del, replace }: { del?: boolean; replace?: boolean }) { + const ref = useRef(null); + const layerId = useRef(""); + const [s, ss] = useState(""); + + useEffect(() => { + if (del) { + ref.current?.deleteLayer(layerId.current); + ss(ref.current?.findById(layerId.current)?.title ?? ""); + } else if (replace) { + ref.current?.replace({ id: layerId.current, type: "simple", title: "A" }); + ss(ref.current?.findById(layerId.current)?.title ?? ""); + } else { + const newLayer = ref.current?.add({ type: "simple", title: "a" }); + layerId.current = newLayer?.id ?? ""; + ss(newLayer?.title ?? ""); + } + }, [del, replace]); + + return ( + <> + +

{s}

+ + ); + } + + // add should add layers and getLayer should return a lazy layer + const { rerender } = render(); + + await waitFor(() => expect(screen.getByTestId("layer")).toHaveTextContent("a")); + + expect(Feature).toBeCalledTimes(1); + expect(Feature.mock.calls[0][0].layer).toEqual({ + id: expect.any(String), + features: [], + layer: { id: expect.any(String), type: "simple", title: "a" }, + status: "ready", + originalFeatures: [], + }); + + // update should update the layer + rerender(); + + await waitFor(() => expect(screen.getByTestId("layer")).toHaveTextContent("A")); + + // deleteLayer should delete the layer and getLayer should return nothing + rerender(); + + await waitFor(() => expect(screen.getByTestId("layer")).toBeEmptyDOMElement()); +}); + +test("computed", async () => { + const layers: Layer[] = [ + { + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [0, 1], + }, + }, + }, + marker: { + pointColor: "red", + }, + }, + ]; + + const Feature = (_: FeatureComponentProps) => null; + + function Comp({ layers }: { layers?: Layer[] }) { + const ref = useRef(null); + return ( + <> + +

{ref.current?.findById("x")?.computed?.id}

+ + ); + } + + const { rerender } = render(); + rerender(); + await waitFor(() => expect(screen.getByTestId("layer")).toHaveTextContent("x")); +}); diff --git a/src/core/Map/Layers/index.tsx b/src/core/Map/Layers/index.tsx new file mode 100644 index 000000000..efc63556c --- /dev/null +++ b/src/core/Map/Layers/index.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type ForwardRefRenderFunction } from "react"; + +import ClusteredLayers, { type Props as ClusteredLayerProps } from "../ClusteredLayers"; + +import useHooks, { type Ref } from "./hooks"; + +export type { + CommonProps, + FeatureComponentProps, + FeatureComponentType, + Layer, + LayerSimple, +} from "../Layer"; +export type { LazyLayer, Ref, NaiveLayer } from "./hooks"; +export type { + ClusterComponentType, + ClusterComponentProps, + ClusterProperty, +} from "../ClusteredLayers"; + +export type Props = Omit & { + hiddenLayers?: string[]; +}; + +const Layers: ForwardRefRenderFunction = ({ layers, hiddenLayers, ...props }, ref) => { + const { atomMap, flattenedLayers, isHidden } = useHooks({ layers, ref, hiddenLayers }); + + return ( + + ); +}; + +export default forwardRef(Layers); diff --git a/src/core/Map/Layers/keys.ts b/src/core/Map/Layers/keys.ts new file mode 100644 index 000000000..4286a27ec --- /dev/null +++ b/src/core/Map/Layers/keys.ts @@ -0,0 +1,48 @@ +import { appearanceKeyObj } from "../../mantle"; +import type { LegacyLayer, ComputedLayer, Layer } from "../../mantle"; + +import type { LazyLayer } from "./hooks"; + +export const layerKeys = objKeys< + Exclude, "id" | "compat"> +>({ + // layer + children: 1, + data: 1, + infobox: 1, + properties: 1, + tags: 1, + title: 1, + type: 1, + creator: 1, + computed: 1, + // appearance + ...appearanceKeyObj, + // legacy layer + property: 1, + propertyId: 1, + pluginId: 1, + extensionId: 1, + isVisible: 1, + visible: 1, +}); + +export const computedLayerKeys = objKeys, "id">>({ + features: 1, + layer: 1, + originalFeatures: 1, + properties: 1, + status: 1, + ...appearanceKeyObj, +}); + +export type KeysOfUnion = T extends T ? keyof T : never; + +/** + * Often we want to make an array of keys of an object type, + * but if we just specify the key names directly, we may forget to change the array if the object type is changed. + * With this function, the compiler checks the object keys for completeness, so the array of keys is always up to date. + */ +export function objKeys(obj: { [k in T]: 1 }): T[] { + return Object.keys(obj) as T[]; +} diff --git a/src/core/Map/hooks.ts b/src/core/Map/hooks.ts new file mode 100644 index 000000000..34700cbef --- /dev/null +++ b/src/core/Map/hooks.ts @@ -0,0 +1,25 @@ +import { type RefObject, useImperativeHandle, useRef, type Ref } from "react"; + +import { EngineRef } from "./types"; + +export type MapRef = { + engineRef: RefObject; +}; + +export default function ({ ref }: { ref: Ref }) { + const engineRef = useRef(null); + + useImperativeHandle( + ref, + () => ({ + engineRef, + // layers + // features + }), + [], + ); + + return { + engineRef, + }; +} diff --git a/src/core/Map/index.tsx b/src/core/Map/index.tsx new file mode 100644 index 000000000..ba63e66c3 --- /dev/null +++ b/src/core/Map/index.tsx @@ -0,0 +1,68 @@ +import { forwardRef, type Ref } from "react"; + +import useHooks, { MapRef } from "./hooks"; +import Layers, { type Props as LayersProps } from "./Layers"; +import { Engine, EngineProps } from "./types"; + +export * from "./types"; + +export type { + NaiveLayer, + LazyLayer, + FeatureComponentType, + FeatureComponentProps, + ClusterProperty, +} from "./Layers"; + +export type Props = { + engines?: Record; + engine?: string; +} & Omit & + EngineProps; + +function Map( + { + engines, + engine, + isBuilt, + isEditable, + sceneProperty, + clusters, + hiddenLayers, + layers, + overrides, + selectedLayerId, + ...props + }: Props, + ref: Ref, +): JSX.Element | null { + const currentEngine = engine ? engines?.[engine] : undefined; + const Engine = currentEngine?.component; + const { engineRef } = useHooks({ ref }); + + return Engine ? ( + + + + ) : null; +} + +export default forwardRef(Map); diff --git a/src/core/Map/types/index.ts b/src/core/Map/types/index.ts new file mode 100644 index 000000000..550a4a508 --- /dev/null +++ b/src/core/Map/types/index.ts @@ -0,0 +1,279 @@ +import type { + ForwardRefExoticComponent, + PropsWithoutRef, + RefAttributes, + ReactNode, + CSSProperties, +} from "react"; + +import type { LatLngHeight, Camera, Rect, LatLng, DataType } from "../../mantle"; +import type { FeatureComponentType, ClusterComponentType } from "../Layers"; + +export type { + FeatureComponentProps, + FeatureComponentType, + ClusterComponentType, + ClusterComponentProps, + ClusterProperty, +} from "../Layers"; +export type { + ComputedFeature, + ComputedLayer, + Geometry, + AppearanceTypes, + Camera, + Typography, + LatLng, + Rect, + LatLngHeight, +} from "../../mantle"; + +export type EngineRef = { + [index in keyof MouseEventHandles]: MouseEventHandles[index]; +} & { + name: string; + requestRender: () => void; + getViewport: () => Rect | undefined; + getCamera: () => Camera | undefined; + getLocationFromScreen: (x: number, y: number, withTerrain?: boolean) => LatLngHeight | undefined; + flyTo: (destination: FlyToDestination, options?: CameraOptions) => void; + lookAt: (destination: LookAtDestination, options?: CameraOptions) => void; + lookAtLayer: (layerId: string) => void; + zoomIn: (amount: number, options?: CameraOptions) => void; + zoomOut: (amount: number, options?: CameraOptions) => void; + orbit: (radian: number) => void; + rotateRight: (radian: number) => void; + changeSceneMode: (sceneMode: SceneMode | undefined, duration?: number) => void; + getClock: () => Clock | undefined; + captureScreen: (type?: string, encoderOptions?: number) => string | undefined; + enableScreenSpaceCameraController: (enabled: boolean) => void; + lookHorizontal: (amount: number) => void; + lookVertical: (amount: number) => void; + moveForward: (amount: number) => void; + moveBackward: (amount: number) => void; + moveUp: (amount: number) => void; + moveDown: (amount: number) => void; + moveLeft: (amount: number) => void; + moveRight: (amount: number) => void; + moveOverTerrain: (offset?: number) => void; + flyToGround: (destination: FlyToDestination, options?: CameraOptions, offset?: number) => void; + mouseEventCallbacks: MouseEvents; +}; + +export type EngineProps = { + className?: string; + style?: CSSProperties; + isEditable?: boolean; + isBuilt?: boolean; + property?: SceneProperty; + camera?: Camera; + small?: boolean; + children?: ReactNode; + ready?: boolean; + selectedLayerId?: string; + selectionReason?: string; + layerSelectionReason?: string; + isLayerDraggable?: boolean; + isLayerDragging?: boolean; + shouldRender?: boolean; + meta?: Record; + onLayerSelect?: (id?: string, options?: SelectLayerOptions) => void; + onCameraChange?: (camera: Camera) => void; + onTick?: (clock: Date) => void; + onLayerDrag?: (layerId: string, position: LatLng) => void; + onLayerDrop?: (layerId: string, propertyKey: string, position: LatLng | undefined) => void; +}; + +export type SelectLayerOptions = { + reason?: string; + overriddenInfobox?: OverriddenInfobox; +}; + +export type OverriddenInfobox = { + title?: string; + content: { key: string; value: string }[]; +}; + +export type Clock = { + current: Date; + start?: Date; + stop?: Date; + speed?: number; + playing?: boolean; +}; + +export type MouseEvent = { + x?: number; + y?: number; + lat?: number; + lng?: number; + height?: number; + layerId?: string; + delta?: number; +}; + +export type MouseEvents = { + click: ((props: MouseEvent) => void) | undefined; + doubleclick: ((props: MouseEvent) => void) | undefined; + mousedown: ((props: MouseEvent) => void) | undefined; + mouseup: ((props: MouseEvent) => void) | undefined; + rightclick: ((props: MouseEvent) => void) | undefined; + rightdown: ((props: MouseEvent) => void) | undefined; + rightup: ((props: MouseEvent) => void) | undefined; + middleclick: ((props: MouseEvent) => void) | undefined; + middledown: ((props: MouseEvent) => void) | undefined; + middleup: ((props: MouseEvent) => void) | undefined; + mousemove: ((props: MouseEvent) => void) | undefined; + mouseenter: ((props: MouseEvent) => void) | undefined; + mouseleave: ((props: MouseEvent) => void) | undefined; + wheel: ((props: MouseEvent) => void) | undefined; +}; + +export type MouseEventHandles = { + onClick: (fn: MouseEvents["click"]) => void; + onDoubleClick: (fn: MouseEvents["doubleclick"]) => void; + onMouseDown: (fn: MouseEvents["mousedown"]) => void; + onMouseUp: (fn: MouseEvents["mouseup"]) => void; + onRightClick: (fn: MouseEvents["rightclick"]) => void; + onRightDown: (fn: MouseEvents["rightdown"]) => void; + onRightUp: (fn: MouseEvents["rightup"]) => void; + onMiddleClick: (fn: MouseEvents["middleclick"]) => void; + onMiddleDown: (fn: MouseEvents["middledown"]) => void; + onMiddleUp: (fn: MouseEvents["middleup"]) => void; + onMouseMove: (fn: MouseEvents["mousemove"]) => void; + onMouseEnter: (fn: MouseEvents["mouseenter"]) => void; + onMouseLeave: (fn: MouseEvents["mouseleave"]) => void; + onWheel: (fn: MouseEvents["wheel"]) => void; +}; + +export type SceneMode = "3d" | "2d" | "columbus"; +export type IndicatorTypes = "default" | "crosshair" | "custom"; + +export type FlyToDestination = { + /** Degrees */ + lat?: number; + /** Degrees */ + lng?: number; + /** Meters */ + height?: number; + /** Radian */ + heading?: number; + /** Radian */ + pitch?: number; + /** Radian */ + roll?: number; + /** Radian */ + fov?: number; +}; + +export type LookAtDestination = { + /** Degrees */ + lat?: number; + /** Degrees */ + lng?: number; + /** Meters */ + height?: number; + /** Radian */ + heading?: number; + /** Radian */ + pitch?: number; + /** Radian */ + range?: number; + /** Radian */ + fov?: number; +}; + +export type CameraOptions = { + /** Seconds */ + duration?: number; + easing?: (time: number) => number; + withoutAnimation?: boolean; +}; + +export type TerrainProperty = { + terrain?: boolean; + terrainType?: "cesium" | "arcgis" | "cesiumion"; // default: cesium + terrainExaggeration?: number; // default: 1 + terrainExaggerationRelativeHeight?: number; // default: 0 + depthTestAgainstTerrain?: boolean; + terrainCesiumIonAsset?: string; + terrainCesiumIonAccessToken?: string; + terrainCesiumIonUrl?: string; + terrainUrl?: string; +}; + +export type SceneProperty = { + default?: { + camera?: Camera; + allowEnterGround?: boolean; + skybox?: boolean; + bgcolor?: string; + ion?: string; + sceneMode?: SceneMode; // default: scene3d + } & TerrainProperty; + cameraLimiter?: { + cameraLimitterEnabled?: boolean; + cameraLimitterShowHelper?: boolean; + cameraLimitterTargetArea?: Camera; + cameraLimitterTargetWidth?: number; + cameraLimitterTargetLength?: number; + }; + indicator?: { + indicator_type: IndicatorTypes; + indicator_image?: string; + indicator_image_scale?: number; + }; + tiles?: { + id: string; + tile_type?: string; + tile_url?: string; + tile_maxLevel?: number; + tile_minLevel?: number; + tile_opacity?: number; + }[]; + terrain?: TerrainProperty; + atmosphere?: { + enable_sun?: boolean; + enable_lighting?: boolean; + ground_atmosphere?: boolean; + sky_atmosphere?: boolean; + shadows?: boolean; + fog?: boolean; + fog_density?: number; + brightness_shift?: number; + hue_shift?: number; + surturation_shift?: number; + }; + timeline?: { + animation?: boolean; + visible?: boolean; + current?: string; + start?: string; + stop?: string; + stepType?: "rate" | "fixed"; + multiplier?: number; + step?: number; + rangeType?: "unbounded" | "clamped" | "bounced"; + }; + googleAnalytics?: { + enableGA?: boolean; + trackingId?: string; + }; + theme?: { + themeType?: "light" | "dark" | "forest" | "custom"; + themeTextColor?: string; + themeSelectColor?: string; + themeBackgroundColor?: string; + }; +}; + +export type EngineComponent = ForwardRefExoticComponent< + PropsWithoutRef & RefAttributes +>; + +export type Engine = { + component: EngineComponent; + featureComponent: FeatureComponentType; + clusterComponent: ClusterComponentType; + delegatedDataTypes?: DataType[]; +}; diff --git a/src/core/Map/utils.test.ts b/src/core/Map/utils.test.ts new file mode 100644 index 000000000..e6aaddc5f --- /dev/null +++ b/src/core/Map/utils.test.ts @@ -0,0 +1,27 @@ +import { renderHook } from "@testing-library/react"; +import { expect, test } from "vitest"; + +import { useGet } from "./utils"; + +test("useGet", () => { + const obj = { a: 1 }; + const { result } = renderHook(() => useGet(obj)); + expect(result.current()).toBe(obj); + expect(result.current().a).toBe(1); + + obj.a = 2; + expect(result.current()).toBe(obj); + expect(result.current().a).toBe(2); + + const { result: result2, rerender: rerender2 } = renderHook( + props => { + return useGet(props); + }, + { + initialProps: { b: 1 }, + }, + ); + expect(result2.current()).toEqual({ b: 1 }); + rerender2({ b: 2 }); + expect(result2.current()).toEqual({ b: 2 }); +}); diff --git a/src/core/Map/utils.ts b/src/core/Map/utils.ts new file mode 100644 index 000000000..afbe54159 --- /dev/null +++ b/src/core/Map/utils.ts @@ -0,0 +1,7 @@ +import { useCallback, useRef } from "react"; + +export function useGet(value: T): () => T { + const ref = useRef(value); + ref.current = value; + return useCallback(() => ref.current, []); +} diff --git a/src/core/README.md b/src/core/README.md new file mode 100644 index 000000000..7fb5c1503 --- /dev/null +++ b/src/core/README.md @@ -0,0 +1,7 @@ +# @reearth/core + +- **Visualizer**: Map + Crust +- **Crust**: Plugins + Widgets + Infobox +- **Map**: Engine + mantle + +![Architecture](docs/architecture.svg) diff --git a/src/core/Visualizer/index.tsx b/src/core/Visualizer/index.tsx new file mode 100644 index 000000000..613016a3d --- /dev/null +++ b/src/core/Visualizer/index.tsx @@ -0,0 +1,5 @@ +export type Props = {}; + +export default function Visualizer(_props: Props): JSX.Element | null { + return null; +} diff --git a/src/core/docs/architecture.d2 b/src/core/docs/architecture.d2 new file mode 100644 index 000000000..4d90aab12 --- /dev/null +++ b/src/core/docs/architecture.d2 @@ -0,0 +1,17 @@ +viz: Visualizer +map: Map +plugin: Plugins +mantle: mantle +engine: Engine +crust: Crust +infobox: Infobox +was: Widgets + +viz -> crust: use +crust -> plugin: use +crust -> was: use +crust -> infobox: use +viz -> map: use +engine -> map: inject +engine -> mantle: use +map -> mantle: use diff --git a/src/core/docs/architecture.svg b/src/core/docs/architecture.svg new file mode 100644 index 000000000..718a7ac9c --- /dev/null +++ b/src/core/docs/architecture.svg @@ -0,0 +1,55 @@ + +VisualizerMapPluginsmantleEngineCrustInfoboxWidgets + + +use + + +use + + +use + + +use + + +use + + +inject + + +use + + +use \ No newline at end of file diff --git a/src/core/engines/Cesium/Cluster.tsx b/src/core/engines/Cesium/Cluster.tsx new file mode 100644 index 000000000..6356da057 --- /dev/null +++ b/src/core/engines/Cesium/Cluster.tsx @@ -0,0 +1,91 @@ +import { Cartesian3, Color, EntityCluster, HorizontalOrigin, VerticalOrigin } from "cesium"; +import React, { useEffect, useMemo } from "react"; +import { CustomDataSource } from "resium"; + +import { toCSSFont } from "@reearth/util/value"; + +import { ClusterComponentProps } from ".."; + +const Cluster: React.FC = ({ property, children }) => { + const { + clusterPixelRange = 15, + clusterMinSize = 3, + clusterLabelTypography = { + fontFamily: "sans-serif", + fontSize: 30, + fontWeight: 400, + color: "#FFF", + textAlign: "center", + bold: false, + italic: false, + underline: false, + }, + clusterImage, + clusterImageWidth, + clusterImageHeight, + } = property?.default ?? {}; + + const cluster = useMemo(() => { + return new EntityCluster({ + enabled: true, + pixelRange: 15, + minimumClusterSize: 2, + clusterBillboards: true, + clusterLabels: true, + clusterPoints: true, + }); + }, []); + + useEffect(() => { + const isClusterHidden = React.Children.count(children) < clusterMinSize; + const removeListener = cluster?.clusterEvent.addEventListener( + (_clusteredEntities, clusterParam) => { + clusterParam.label.font = toCSSFont(clusterLabelTypography, { fontSize: 30 }); + clusterParam.label.horizontalOrigin = + clusterLabelTypography.textAlign === "right" + ? HorizontalOrigin.LEFT + : clusterLabelTypography.textAlign === "left" + ? HorizontalOrigin.RIGHT + : HorizontalOrigin.CENTER; + clusterParam.label.verticalOrigin = VerticalOrigin.CENTER; + clusterParam.label.fillColor = Color.fromCssColorString( + clusterLabelTypography.color ?? "#FFF", + ); + clusterParam.label.eyeOffset = new Cartesian3(0, 0, -5); + clusterParam.billboard.show = true; + // Billboard.{image,height,width} should accept undefined + (clusterParam.billboard.image as any) = clusterImage; + (clusterParam.billboard.height as any) = clusterImageHeight; + (clusterParam.billboard.width as any) = clusterImageWidth; + // Workaround if minimumClusterSize is larger than number of layers event listner breaks + cluster.minimumClusterSize = isClusterHidden + ? React.Children.count(children) + : clusterMinSize; + }, + ); + cluster.enabled = !isClusterHidden; + // Workaround to re-style components + cluster.pixelRange = 0; + cluster.pixelRange = clusterPixelRange; + return () => { + removeListener(); + }; + }, [ + clusterMinSize, + clusterPixelRange, + clusterLabelTypography, + clusterImage, + clusterImageHeight, + clusterImageWidth, + children, + cluster, + ]); + + return cluster ? ( + + {children} + + ) : null; +}; + +export default Cluster; diff --git a/src/core/engines/Cesium/Event.tsx b/src/core/engines/Cesium/Event.tsx new file mode 100644 index 000000000..eaaccc6f0 --- /dev/null +++ b/src/core/engines/Cesium/Event.tsx @@ -0,0 +1,15 @@ +import { useEffect } from "react"; + +export type Props = { + onMount?: () => void; + onUnmount?: () => void; +}; + +export default function Event({ onMount, onUnmount }: Props) { + useEffect(() => { + onMount?.(); + return () => onUnmount?.(); + }, [onMount, onUnmount]); + + return null; +} diff --git a/src/core/engines/Cesium/Feature/Ellipsoid/index.tsx b/src/core/engines/Cesium/Feature/Ellipsoid/index.tsx new file mode 100644 index 000000000..cf2381b83 --- /dev/null +++ b/src/core/engines/Cesium/Feature/Ellipsoid/index.tsx @@ -0,0 +1,70 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { Cartesian3 } from "cesium"; +import { useMemo } from "react"; +import { EllipsoidGraphics } from "resium"; + +import { LatLng, toColor } from "@reearth/util/value"; + +import type { EllipsoidAppearance } from "../../.."; +import { heightReference, shadowMode } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; + +export type Property = EllipsoidAppearance & { + // compat + position?: LatLng; + location?: LatLng; + height?: number; +}; + +export default function Ellipsoid({ id, isVisible, property, geometry, layer, feature }: Props) { + const coordinates = useMemo( + () => + geometry?.type === "Point" + ? geometry.coordinates + : property?.position + ? [property.position.lng, property.position.lat, property.height ?? 0] + : property?.location + ? [property.location.lng, property.location.lat, property.height ?? 0] + : undefined, + [geometry?.coordinates, geometry?.type, property?.height, property?.location], + ); + + const { heightReference: hr, shadows, radius = 1000, fillColor } = property ?? {}; + + const pos = useMemo( + () => + coordinates + ? Cartesian3.fromDegrees(coordinates[0], coordinates[1], coordinates[2]) + : undefined, + [coordinates], + ); + + const raddi = useMemo(() => { + return new Cartesian3(radius, radius, radius); + }, [radius]); + + const material = useMemo(() => toColor(fillColor), [fillColor]); + + return !isVisible || !pos ? null : ( + + + + ); +} + +export const config: FeatureComponentConfig = { + noLayer: true, +}; diff --git a/src/core/engines/Cesium/Feature/Marker/index.tsx b/src/core/engines/Cesium/Feature/Marker/index.tsx new file mode 100644 index 000000000..1cb142327 --- /dev/null +++ b/src/core/engines/Cesium/Feature/Marker/index.tsx @@ -0,0 +1,168 @@ +import { Cartesian3, Color, HorizontalOrigin, VerticalOrigin, Cartesian2 } from "cesium"; +import { useMemo } from "react"; +import { BillboardGraphics, PointGraphics, LabelGraphics, PolylineGraphics } from "resium"; + +import { toCSSFont } from "@reearth/util/value"; + +import type { MarkerAppearance } from "../../.."; +import { useIcon, ho, vo, heightReference, toColor } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +import marker from "./marker.svg"; + +export type Props = FeatureProps; + +export type Property = MarkerAppearance & { + // compat + location?: { lat: number; lng: number }; + height?: number; +}; + +export default function Marker({ property, id, isVisible, geometry, layer, feature }: Props) { + const coordinates = useMemo( + () => + geometry?.type === "Point" + ? geometry.coordinates + : property?.location + ? [property.location.lng, property.location.lat, property.height ?? 0] + : undefined, + [geometry?.coordinates, geometry?.type, property?.height, property?.location], + ); + + const { + extrude, + pointSize = 10, + style, + pointColor, + pointOutlineColor, + pointOutlineWidth, + label, + labelTypography, + labelText, + labelPosition: labelPos = "right", + labelBackground, + image = marker, + imageSize, + imageHorizontalOrigin: horizontalOrigin, + imageVerticalOrigin: verticalOrigin, + imageColor, + imageCrop: crop, + imageShadow: shadow, + imageShadowColor: shadowColor, + imageShadowBlur: shadowBlur, + imageShadowPositionX: shadowOffsetX, + imageShadowPositionY: shadowOffsetY, + heightReference: hr, + } = property ?? {}; + + const pos = useMemo(() => { + return coordinates + ? Cartesian3.fromDegrees(coordinates[0], coordinates[1], coordinates[2]) + : undefined; + }, [coordinates]); + + const extrudePoints = useMemo(() => { + return extrude && coordinates && typeof coordinates[3] === "number" + ? [ + Cartesian3.fromDegrees(coordinates[0], coordinates[1], coordinates[2]), + Cartesian3.fromDegrees(coordinates[0], coordinates[1], 0), + ] + : undefined; + }, [coordinates, extrude]); + + const isStyleImage = !style || style === "image"; + const [icon, imgw, imgh] = useIcon({ + image: isStyleImage ? image : undefined, + imageSize, + crop, + shadow, + shadowColor, + shadowBlur, + shadowOffsetX, + shadowOffsetY, + }); + + const cesiumImageColor = useMemo( + () => (imageColor ? Color.fromCssColorString(imageColor) : undefined), + [imageColor], + ); + + const pixelOffset = useMemo(() => { + const padding = 15; + const x = (isStyleImage ? imgw : pointSize) / 2 + padding; + const y = (isStyleImage ? imgh : pointSize) / 2 + padding; + return new Cartesian2( + labelPos.includes("left") || labelPos.includes("right") + ? x * (labelPos.includes("left") ? -1 : 1) + : 0, + labelPos.includes("top") || labelPos.includes("bottom") + ? y * (labelPos.includes("top") ? -1 : 1) + : 0, + ); + }, [isStyleImage, imgw, pointSize, imgh, labelPos]); + + const extrudePointsLineColor = useMemo(() => { + return Color.WHITE.withAlpha(0.4); + }, []); + + return !pos || !isVisible ? null : ( + <> + {extrudePoints && ( + + + + )} + + {style === "point" ? ( + + ) : ( + + )} + {label && ( + + )} + + + ); +} + +export const config: FeatureComponentConfig = { + noLayer: true, +}; diff --git a/src/core/engines/Cesium/Feature/Marker/marker.svg b/src/core/engines/Cesium/Feature/Marker/marker.svg new file mode 100644 index 000000000..87adcb2bc --- /dev/null +++ b/src/core/engines/Cesium/Feature/Marker/marker.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/core/engines/Cesium/Feature/Model/index.tsx b/src/core/engines/Cesium/Feature/Model/index.tsx new file mode 100644 index 000000000..a873753ea --- /dev/null +++ b/src/core/engines/Cesium/Feature/Model/index.tsx @@ -0,0 +1,101 @@ +import { Cartesian3, HeadingPitchRoll, Math as CesiumMath, Transforms } from "cesium"; +import { useMemo } from "react"; +import { ModelGraphics } from "resium"; + +import { toColor } from "@reearth/util/value"; + +import type { ModelAppearance } from "../../.."; +import { colorBlendMode, heightReference, shadowMode } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; + +export type Property = ModelAppearance & { + location?: { lat: number; lng: number }; + height?: number; +}; + +export default function Model({ id, isVisible, property, geometry, layer, feature }: Props) { + const coordinates = useMemo( + () => + geometry?.type === "Point" + ? geometry.coordinates + : property?.location + ? [property.location.lng, property.location.lat, property.height ?? 0] + : undefined, + [geometry?.coordinates, geometry?.type, property?.height, property?.location], + ); + + const { + model, + heightReference: hr, + heading, + pitch, + roll, + scale, + maximumScale, + minimumPixelSize, + animation = true, + shadows = "disabled", + colorBlend = "none", + color, + colorBlendAmount, + lightColor, + silhouette, + silhouetteColor, + silhouetteSize = 1, + } = property ?? {}; + + const position = useMemo(() => { + return coordinates + ? Cartesian3.fromDegrees(coordinates[0], coordinates[1], coordinates[2]) + : undefined; + }, [coordinates]); + const orientation = useMemo( + () => + position + ? Transforms.headingPitchRollQuaternion( + position, + new HeadingPitchRoll( + CesiumMath.toRadians(heading ?? 0), + CesiumMath.toRadians(pitch ?? 0), + CesiumMath.toRadians(roll ?? 0), + ), + ) + : undefined, + [heading, pitch, position, roll], + ); + const modelColor = useMemo(() => (colorBlend ? toColor(color) : undefined), [colorBlend, color]); + const modelLightColor = useMemo(() => toColor(lightColor), [lightColor]); + const modelSilhouetteColor = useMemo(() => toColor(silhouetteColor), [silhouetteColor]); + + return !isVisible || !model || !position ? null : ( + + + + ); +} + +export const config: FeatureComponentConfig = { + noFeature: true, +}; diff --git a/src/core/engines/Cesium/Feature/PhotoOverlay/hooks.ts b/src/core/engines/Cesium/Feature/PhotoOverlay/hooks.ts new file mode 100644 index 000000000..22fb0e797 --- /dev/null +++ b/src/core/engines/Cesium/Feature/PhotoOverlay/hooks.ts @@ -0,0 +1,99 @@ +import useTransition, { TransitionStatus } from "@rot1024/use-transition"; +import { Math as CesiumMath, EasingFunction } from "cesium"; +import { useCallback, useEffect, useRef } from "react"; + +import { useDelayedCount, Durations } from "@reearth/util/use-delayed-count"; + +import type { Camera } from "../../.."; +import { useContext } from "../context"; + +export type { TransitionStatus } from "@rot1024/use-transition"; + +const cameraDuration = 2; +const cameraExitDuration = 2; +const fovDuration = 0.5; +const fovExitDuration = 0.5; +export const photoDuration = 1; +export const photoExitDuration = 0.5; +const defaultFOV = CesiumMath.toRadians(60); + +const durations: Durations = [ + [cameraDuration * 1000, cameraExitDuration * 1000], + [fovDuration * 1000, fovExitDuration * 1000], + [photoDuration * 1000, photoExitDuration * 1000], +]; + +export default function ({ isSelected, camera }: { isSelected?: boolean; camera?: Camera }): { + photoOverlayImageTransiton: TransitionStatus; + exitPhotoOverlay: () => void; +} { + const { selectionReason, flyTo, getCamera } = useContext(); + + // mode 0 = idle, 1 = idle<->fly, 2 = fly<->fov, 3 = fov<->photo, 4 = photo + const [mode, prevMode, startTransition] = useDelayedCount(durations); + const cameraRef = useRef(camera); + cameraRef.current = camera; + const storytelling = useRef(false); + storytelling.current = selectionReason === "storytelling"; + const prevCamera = useRef(); + + // camera flight + useEffect(() => { + if ((prevMode ?? 0) > 1 && mode === 1 && prevCamera.current) { + flyTo?.(prevCamera.current, { + duration: cameraExitDuration, + easing: EasingFunction.CUBIC_IN_OUT, + }); + prevCamera.current = undefined; + } else if ((prevMode ?? 0) === 0 && mode === 1 && cameraRef.current) { + prevCamera.current = getCamera?.(); + flyTo?.( + { ...cameraRef.current, fov: prevCamera.current?.fov }, + { + duration: cameraDuration, + easing: EasingFunction.CUBIC_IN_OUT, + }, + ); + } else if (mode === 2) { + const fov = + (prevMode ?? 0) === 1 ? cameraRef.current?.fov : prevCamera.current?.fov ?? defaultFOV; + flyTo?.( + { fov }, + { + duration: (prevMode ?? 0) === 1 ? fovDuration : fovExitDuration, + easing: EasingFunction.CUBIC_IN_OUT, + }, + ); + } + }, [flyTo, getCamera, mode, prevMode]); + + // start transition: when selection was changed + useEffect(() => { + // restore fov + if (!isSelected && storytelling.current) { + const fov = prevCamera.current?.fov ?? defaultFOV; + flyTo?.({ fov }, { duration: 0 }); + } + // skip camera flight when is not selected + startTransition(!isSelected, !isSelected); + }, [flyTo, startTransition, isSelected]); + + const transition = useTransition( + !!isSelected && mode >= 3, + (prevMode ?? 0) > 3 ? photoExitDuration * 1000 : photoDuration * 1000, + { + mountOnEnter: true, + unmountOnExit: true, + }, + ); + + const exitPhotoOverlay = useCallback( + () => startTransition(true, !camera), + [camera, startTransition], + ); + + return { + photoOverlayImageTransiton: transition, + exitPhotoOverlay, + }; +} diff --git a/src/core/engines/Cesium/Feature/PhotoOverlay/index.tsx b/src/core/engines/Cesium/Feature/PhotoOverlay/index.tsx new file mode 100644 index 000000000..9bd74b96a --- /dev/null +++ b/src/core/engines/Cesium/Feature/PhotoOverlay/index.tsx @@ -0,0 +1,139 @@ +import { Cartesian3 } from "cesium"; +import { useMemo } from "react"; +import nl2br from "react-nl2br"; +import { BillboardGraphics } from "resium"; + +import defaultImage from "@reearth/components/atoms/Icon/Icons/primPhotoIcon.svg"; +import Text from "@reearth/components/atoms/Text"; +import { styled, useTheme } from "@reearth/theme"; + +import type { LegacyPhotooverlayAppearance } from "../../.."; +import { heightReference, ho, useIcon, vo } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +import useHooks, { photoDuration, photoExitDuration, TransitionStatus } from "./hooks"; + +export type Props = FeatureProps; + +export type Property = LegacyPhotooverlayAppearance; + +export default function PhotoOverlay({ + id, + isVisible, + property, + geometry, + isSelected, + layer, + feature, +}: Props) { + const coordinates = useMemo( + () => + geometry?.type === "Point" + ? geometry.coordinates + : property?.location + ? [property.location.lng, property.location.lat, property.height ?? 0] + : undefined, + [geometry?.coordinates, geometry?.type, property?.height, property?.location], + ); + + const { + image, + imageSize, + imageHorizontalOrigin, + imageVerticalOrigin, + imageCrop, + imageShadow, + imageShadowColor, + imageShadowBlur, + imageShadowPositionX, + imageShadowPositionY, + heightReference: hr, + camera, + photoOverlayImage, + photoOverlayDescription, + } = property ?? {}; + + const [canvas] = useIcon({ + image: image || defaultImage, + imageSize: image ? imageSize : undefined, + crop: image ? imageCrop : undefined, + shadow: image ? imageShadow : undefined, + shadowColor: image ? imageShadowColor : undefined, + shadowBlur: image ? imageShadowBlur : undefined, + shadowOffsetX: image ? imageShadowPositionX : undefined, + shadowOffsetY: image ? imageShadowPositionY : undefined, + }); + + const theme = useTheme(); + + const pos = useMemo( + () => + coordinates + ? Cartesian3.fromDegrees(coordinates[0], coordinates[1], coordinates[2]) + : undefined, + [coordinates], + ); + + const { photoOverlayImageTransiton, exitPhotoOverlay } = useHooks({ + camera, + isSelected: isSelected && !!photoOverlayImage, + }); + + return !isVisible ? null : ( + <> + + + + {photoOverlayImageTransiton === "unmounted" ? null : ( + + + {photoOverlayDescription && ( + + {nl2br(photoOverlayDescription)} + + )} + + )} + + ); +} + +const PhotoWrapper = styled.div<{ transition: TransitionStatus }>` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: rgba(0, 0, 0, 0.5); + display: flex; + justify-content: center; + align-items: center; + transition: ${({ transition }) => + transition === "entering" || transition === "exiting" + ? `all ${transition === "exiting" ? photoExitDuration : photoDuration}s ease` + : null}; + opacity: ${({ transition }) => (transition === "entering" || transition === "entered" ? 1 : 0)}; +`; + +const Photo = styled.img` + max-width: 95%; + max-height: 80%; + box-shadow: 0 0 15px rgba(0, 0, 0, 1); +`; + +const Description = styled(Text)` + position: absolute; + bottom: 10px; + left: 20px; + right: 20px; + text-align: left; +`; + +export const config: FeatureComponentConfig = { + noFeature: true, +}; diff --git a/src/core/engines/Cesium/Feature/Polygon/index.tsx b/src/core/engines/Cesium/Feature/Polygon/index.tsx new file mode 100644 index 000000000..8bfe2222b --- /dev/null +++ b/src/core/engines/Cesium/Feature/Polygon/index.tsx @@ -0,0 +1,79 @@ +/* eslint-disable react-hooks/exhaustive-deps */ +import { Cartesian3, PolygonHierarchy } from "cesium"; +import { isEqual } from "lodash-es"; +import { useMemo } from "react"; +import { PolygonGraphics } from "resium"; +import { useCustomCompareMemo } from "use-custom-compare"; + +import { Polygon as PolygonValue, toColor } from "@reearth/util/value"; + +import type { PolygonAppearance } from "../../.."; +import { heightReference, shadowMode } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; + +export type Property = PolygonAppearance & { + polygon?: PolygonValue; +}; + +export default function Polygon({ id, isVisible, property, geometry, layer, feature }: Props) { + const coordiantes = useMemo( + () => + geometry?.type === "Polygon" + ? geometry.coordinates + : property?.polygon + ? property.polygon.map(p => p.map(q => [q.lng, q.lat, q.height])) + : undefined, + [geometry?.coordinates, geometry?.type, property?.polygon], + ); + + const { + fill = true, + stroke, + fillColor, + strokeColor, + strokeWidth = 1, + heightReference: hr, + shadows, + } = property ?? {}; + + const hierarchy = useCustomCompareMemo( + () => + coordiantes?.[0] + ? new PolygonHierarchy( + coordiantes[0].map(c => Cartesian3.fromDegrees(c[0], c[1], c[2])), + coordiantes + .slice(1) + .map(p => new PolygonHierarchy(p.map(c => Cartesian3.fromDegrees(c[0], c[1], c[2])))), + ) + : undefined, + [coordiantes ?? []], + isEqual, + ); + + const memoStrokeColor = useMemo( + () => (stroke ? toColor(strokeColor) : undefined), + [stroke, strokeColor], + ); + const memoFillColor = useMemo(() => (fill ? toColor(fillColor) : undefined), [fill, fillColor]); + + return !isVisible ? null : ( + + + + ); +} + +export const config: FeatureComponentConfig = { + noLayer: true, +}; diff --git a/src/core/engines/Cesium/Feature/Polyline/index.tsx b/src/core/engines/Cesium/Feature/Polyline/index.tsx new file mode 100644 index 000000000..ffac67b08 --- /dev/null +++ b/src/core/engines/Cesium/Feature/Polyline/index.tsx @@ -0,0 +1,54 @@ +import { Cartesian3 } from "cesium"; +import { isEqual } from "lodash-es"; +import { useMemo } from "react"; +import { PolylineGraphics } from "resium"; +import { useCustomCompareMemo } from "use-custom-compare"; + +import { Coordinates, toColor } from "@reearth/util/value"; + +import type { PolylineAppearance } from "../../.."; +import { shadowMode } from "../../common"; +import { EntityExt, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; + +export type Property = PolylineAppearance & { + coordinates?: Coordinates; +}; + +export default function Polyline({ id, isVisible, property, geometry, layer, feature }: Props) { + const coordinates = useMemo( + () => + geometry?.type === "LineString" + ? geometry.coordinates + : property?.coordinates + ? property.coordinates.map(p => [p.lng, p.lat, p.height]) + : undefined, + [geometry?.coordinates, geometry?.type, property?.coordinates], + ); + + const { clampToGround, strokeColor, strokeWidth = 1, shadows } = property ?? {}; + + const positions = useCustomCompareMemo( + () => coordinates?.map(c => Cartesian3.fromDegrees(c[0], c[1], c[2])), + [coordinates ?? []], + isEqual, + ); + const material = useMemo(() => toColor(strokeColor), [strokeColor]); + + return !isVisible ? null : ( + + + + ); +} + +export const config: FeatureComponentConfig = { + noLayer: true, +}; diff --git a/src/core/engines/Cesium/Feature/Resource/index.tsx b/src/core/engines/Cesium/Feature/Resource/index.tsx new file mode 100644 index 000000000..933b39af6 --- /dev/null +++ b/src/core/engines/Cesium/Feature/Resource/index.tsx @@ -0,0 +1,35 @@ +import { useMemo } from "react"; +import { KmlDataSource, CzmlDataSource, GeoJsonDataSource } from "resium"; + +import type { LegacyResourceAppearance } from "../../.."; +import { type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; +export type Property = LegacyResourceAppearance; +const types: Record = { + kml: "kml", + geojson: "geojson", + czml: "czml", +}; + +const comps = { + kml: KmlDataSource, + czml: CzmlDataSource, + geojson: GeoJsonDataSource, +}; + +export default function Resource({ isVisible, property }: Props) { + const { url, type, clampToGround } = property ?? {}; + const ext = useMemo( + () => (!type || type === "auto" ? url?.match(/\.([a-z]+?)(?:\?.*?)?$/) : undefined), + [type, url], + ); + const actualType = ext ? types[ext[1]] : type !== "auto" ? type : undefined; + const Component = actualType ? comps[actualType] : undefined; + + if (!isVisible || !Component || !url) return null; + + return ; +} + +export const config: FeatureComponentConfig = {}; diff --git a/src/core/engines/Cesium/Feature/Tileset/index.tsx b/src/core/engines/Cesium/Feature/Tileset/index.tsx new file mode 100644 index 000000000..c39956724 --- /dev/null +++ b/src/core/engines/Cesium/Feature/Tileset/index.tsx @@ -0,0 +1,59 @@ +import { Cesium3DTileset as Cesium3DTilesetType, Cesium3DTileStyle, IonResource } from "cesium"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Cesium3DTileset, CesiumComponentRef } from "resium"; + +import type { Cesium3DTilesAppearance } from "../../.."; +import { shadowMode } from "../../common"; +import { attachTag, type FeatureComponentConfig, type FeatureProps } from "../utils"; + +export type Props = FeatureProps; + +export type Property = Cesium3DTilesAppearance; + +export default function Tileset({ + id, + isVisible, + property, + layer, + feature, +}: Props): JSX.Element | null { + const { sourceType, tileset, styleUrl, shadows } = property ?? {}; + const [style, setStyle] = useState(); + + const ref = useCallback( + (tileset: CesiumComponentRef | null) => { + if (tileset?.cesiumElement) { + attachTag(tileset.cesiumElement, { layerId: layer?.id || id, featureId: feature?.id }); + } + }, + [feature?.id, id, layer?.id], + ); + + useEffect(() => { + if (!styleUrl) { + setStyle(undefined); + return; + } + (async () => { + const res = await fetch(styleUrl); + if (!res.ok) return; + setStyle(new Cesium3DTileStyle(await res.json())); + })(); + }, [styleUrl]); + + const tilesetUrl = useMemo(() => { + return sourceType === "osm" && isVisible + ? IonResource.fromAssetId(96188) // https://github.com/CesiumGS/cesium/blob/1.69/Source/Scene/createOsmBuildings.js#L50 + : isVisible + ? tileset + : null; + }, [isVisible, sourceType, tileset]); + + return !isVisible || (!tileset && !sourceType) || !tilesetUrl ? null : ( + + ); +} + +export const config: FeatureComponentConfig = { + noFeature: true, +}; diff --git a/src/core/engines/Cesium/Feature/context.ts b/src/core/engines/Cesium/Feature/context.ts new file mode 100644 index 000000000..5d4ed9613 --- /dev/null +++ b/src/core/engines/Cesium/Feature/context.ts @@ -0,0 +1,13 @@ +import { createContext, useContext as useReactContext } from "react"; + +import type { Camera, FlyToDestination, CameraOptions } from "../.."; + +export type Context = { + selectionReason?: string; + getCamera?: () => Camera | undefined; + flyTo?: (camera: FlyToDestination, options?: CameraOptions) => void; +}; + +export const context = createContext({}); + +export const useContext = () => useReactContext(context); diff --git a/src/core/engines/Cesium/Feature/index.tsx b/src/core/engines/Cesium/Feature/index.tsx new file mode 100644 index 000000000..96015813a --- /dev/null +++ b/src/core/engines/Cesium/Feature/index.tsx @@ -0,0 +1,64 @@ +import type { AppearanceTypes, FeatureComponentProps } from "../.."; + +import Ellipsoid, { config as ellipsoidConfig } from "./Ellipsoid"; +import Marker, { config as markerConfig } from "./Marker"; +import Model, { config as modelConfig } from "./Model"; +import PhotoOverlay, { config as photoOverlayConfig } from "./PhotoOverlay"; +import Polygon, { config as polygonConfig } from "./Polygon"; +import Polyline, { config as polylineConfig } from "./Polyline"; +import Resource, { config as resourceConfig } from "./Resource"; +import Tileset, { config as tilesetConfig } from "./Tileset"; +import type { FeatureComponent, FeatureComponentConfig } from "./utils"; + +export * from "./utils"; +export { context, type Context } from "./context"; +export { getTag } from "./utils"; + +const components: Record = { + marker: [Marker, markerConfig], + polyline: [Polyline, polylineConfig], + polygon: [Polygon, polygonConfig], + ellipsoid: [Ellipsoid, ellipsoidConfig], + model: [Model, modelConfig], + "3dtiles": [Tileset, tilesetConfig], + photooverlay: [PhotoOverlay, photoOverlayConfig], + legacy_resource: [Resource, resourceConfig], +}; + +export default function Feature({ + layer, + isHidden, + ...props +}: FeatureComponentProps): JSX.Element | null { + return ( + <> + {[undefined, ...layer.features].flatMap(f => + (Object.keys(components) as (keyof AppearanceTypes)[]).map(k => { + const [C, config] = components[k] ?? []; + if ( + !C || + (f && !f[k]) || + !layer[k] || + (config.noLayer && !f) || + (config.noFeature && f) + ) { + return null; + } + + return ( + + ); + }), + )} + + ); +} diff --git a/src/core/engines/Cesium/Feature/utils.tsx b/src/core/engines/Cesium/Feature/utils.tsx new file mode 100644 index 000000000..fa86d92c7 --- /dev/null +++ b/src/core/engines/Cesium/Feature/utils.tsx @@ -0,0 +1,112 @@ +import composeRefs from "@seznam/compose-react-refs"; +import { Cesium3DTileset, Entity as CesiumEntity, PropertyBag } from "cesium"; +import { + ComponentProps, + ComponentType, + ForwardedRef, + forwardRef, + useLayoutEffect, + useRef, +} from "react"; +import { type CesiumComponentRef, Entity } from "resium"; + +import type { ComputedFeature, ComputedLayer, FeatureComponentProps, Geometry } from "../.."; + +export type FeatureProps

= { + id: string; + property?: P; + isVisible?: boolean; + layer?: ComputedLayer; + feature?: ComputedFeature; + geometry?: Geometry; +} & Omit; + +export type FeatureComponent = ComponentType; + +export type FeatureComponentConfig = { + noLayer?: boolean; + noFeature?: boolean; +}; + +export type Tag = { + layerId?: string; + featureId?: string; + draggable?: boolean; + unselectable?: boolean; + legacyLocationPropertyKey?: string; +}; + +export const EntityExt = forwardRef(EntityExtComponent); + +function EntityExtComponent( + { + layerId, + featureId, + draggable, + unselectable, + legacyLocationPropertyKey, + ...props + }: ComponentProps & Tag, + ref: ForwardedRef>, +) { + const r = useRef>(null); + + useLayoutEffect(() => { + attachTag(r.current?.cesiumElement, { + layerId: layerId || props.id, + featureId, + draggable, + unselectable, + legacyLocationPropertyKey, + }); + }, [draggable, featureId, layerId, legacyLocationPropertyKey, props.id, unselectable]); + + return ; +} + +export function attachTag(entity: CesiumEntity | Cesium3DTileset | null | undefined, tag: Tag) { + if (!entity) return; + + if (entity instanceof Cesium3DTileset) { + (entity as any)[tagKey] = tag; + return; + } + + if (!entity.properties) { + entity.properties = new PropertyBag(); + } + for (const k of tagKeys) { + if (!(k in tag)) entity.properties.removeProperty(`reearth_${k}`); + else entity.properties[`reearth_${k}`] = tag[k]; + } +} + +export function getTag(entity: CesiumEntity | Cesium3DTileset | null | undefined): Tag | undefined { + if (!entity) return; + + if (entity instanceof Cesium3DTileset) { + return (entity as any)[tagKey]; + } + + if (!entity.properties) return; + + return Object.fromEntries( + Object.entries(entity.properties) + .map(([k, v]): readonly [PropertyKey, any] | null => { + if (!tagKeys.includes(k.replace("reearth_", "") as keyof Tag)) return null; + return [k.replace("reearth_", ""), v]; + }) + .filter((e): e is readonly [PropertyKey, any] => !!e), + ); +} + +const tagObj: { [k in keyof Tag]: 1 } = { + draggable: 1, + featureId: 1, + layerId: 1, + unselectable: 1, +}; + +const tagKeys = Object.keys(tagObj) as (keyof Tag)[]; + +const tagKey = "__reearth_tag"; diff --git a/src/core/engines/Cesium/cameraLimiter.ts b/src/core/engines/Cesium/cameraLimiter.ts new file mode 100644 index 000000000..93d14d5e5 --- /dev/null +++ b/src/core/engines/Cesium/cameraLimiter.ts @@ -0,0 +1,257 @@ +import { + Cartesian3, + Cartographic, + Color, + EllipsoidGeodesic, + PolylineDashMaterialProperty, + Math, + Camera as CesiumCamera, + Rectangle, +} from "cesium"; +import type { Viewer as CesiumViewer } from "cesium"; +import { useEffect, useMemo, useState, RefObject } from "react"; +import { CesiumComponentRef } from "resium"; + +import { Camera } from "@reearth/util/value"; + +import type { SceneProperty } from ".."; + +import { getCamera } from "./common"; + +const targetWidth = 1000000; +const targetLength = 1000000; + +export function useCameraLimiter( + cesium: RefObject>, + camera: Camera | undefined, + property: SceneProperty["cameraLimiter"] | undefined, +) { + const geodesic = useMemo(() => { + const viewer = cesium.current?.cesiumElement; + if ( + !viewer || + viewer.isDestroyed() || + !property?.cameraLimitterEnabled || + !property.cameraLimitterTargetArea?.lng || + !property.cameraLimitterTargetArea.lat + ) { + return undefined; + } + + return getGeodesic( + viewer, + property.cameraLimitterTargetArea.lng, + property.cameraLimitterTargetArea.lat, + ); + }, [ + cesium, + property?.cameraLimitterEnabled, + property?.cameraLimitterTargetArea?.lng, + property?.cameraLimitterTargetArea?.lat, + ]); + + // calculate inner limiter dimensions + const limiterDimensions = useMemo((): InnerLimiterDimensions | undefined => { + if (!geodesic) return undefined; + + const width = + typeof property?.cameraLimitterTargetWidth === "number" + ? property.cameraLimitterTargetWidth + : targetWidth; + const length = + typeof property?.cameraLimitterTargetLength === "number" + ? property.cameraLimitterTargetLength + : targetLength; + + const { cartesianArray, cartographicDimensions } = calcBoundaryBox( + geodesic, + length / 2, + width / 2, + ); + + return { + cartographicDimensions, + cartesianArray, + }; + }, [property?.cameraLimitterTargetWidth, property?.cameraLimitterTargetLength, geodesic]); + + // calculate maximum camera view (outer boundaries) + const [cameraViewOuterBoundaries, setCameraViewOuterBoundaries] = useState< + Cartesian3[] | undefined + >(); + + useEffect(() => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !property?.cameraLimitterTargetArea || !geodesic) return; + + const camera = new CesiumCamera(viewer.scene); + camera.setView({ + destination: Cartesian3.fromDegrees( + property.cameraLimitterTargetArea.lng, + property.cameraLimitterTargetArea.lat, + property.cameraLimitterTargetArea.height, + ), + orientation: { + heading: property?.cameraLimitterTargetArea.heading, + pitch: property?.cameraLimitterTargetArea.pitch, + roll: property?.cameraLimitterTargetArea.roll, + up: camera.up, + }, + }); + + const computedViewRectangle = camera.computeViewRectangle(); + if (!computedViewRectangle) return; + const rectangleHalfWidth = Rectangle.computeWidth(computedViewRectangle) * Math.PI * 1000000; + const rectangleHalfHeight = Rectangle.computeHeight(computedViewRectangle) * Math.PI * 1000000; + + const { + cameraLimitterTargetWidth: width = targetWidth, + cameraLimitterTargetLength: length = targetLength, + } = property ?? {}; + + const { cartesianArray } = calcBoundaryBox( + geodesic, + length / 2 + rectangleHalfHeight, + width / 2 + rectangleHalfWidth, + ); + + setCameraViewOuterBoundaries(cartesianArray); + }, [cesium, property, geodesic]); + + // Manage camera limiter conditions + useEffect(() => { + const viewer = cesium?.current?.cesiumElement; + const camera = getCamera(cesium?.current?.cesiumElement); + if ( + !viewer || + viewer.isDestroyed() || + !camera || + !property?.cameraLimitterEnabled || + !limiterDimensions + ) + return; + viewer.camera.setView({ + destination: getAllowedCameraDestination(camera, limiterDimensions), + orientation: { + heading: viewer.camera.heading, + pitch: viewer.camera.pitch, + roll: viewer.camera.roll, + up: viewer.camera.up, + }, + }); + }, [camera, cesium, property, limiterDimensions]); + + return { + cameraViewBoundaries: limiterDimensions?.cartesianArray, + cameraViewOuterBoundaries, + cameraViewBoundariesMaterial, + }; +} + +export const cameraViewBoundariesMaterial = new PolylineDashMaterialProperty({ + color: Color.RED, +}); + +export type InnerLimiterDimensions = { + cartographicDimensions: { + rightDimension: Cartographic; + leftDimension: Cartographic; + topDimension: Cartographic; + bottomDimension: Cartographic; + }; + cartesianArray: Cartesian3[]; +}; + +export const getGeodesic = ( + viewer: CesiumViewer, + lng: number, + lat: number, +): { vertical: EllipsoidGeodesic; horizontal: EllipsoidGeodesic } | undefined => { + const ellipsoid = viewer.scene.globe.ellipsoid; + + const centerPoint = Cartesian3.fromDegrees(lng, lat, 0); + + const cartographicCenterPoint = Cartographic.fromCartesian(centerPoint); + const normal = ellipsoid.geodeticSurfaceNormal(centerPoint); + const east = Cartesian3.normalize( + Cartesian3.cross(Cartesian3.UNIT_Z, normal, new Cartesian3()), + new Cartesian3(), + ); + const north = Cartesian3.normalize( + Cartesian3.cross(normal, east, new Cartesian3()), + new Cartesian3(), + ); + + const vertical = new EllipsoidGeodesic( + cartographicCenterPoint, + Cartographic.fromCartesian(north), + ellipsoid, + ); + const horizontal = new EllipsoidGeodesic( + cartographicCenterPoint, + Cartographic.fromCartesian(east), + ellipsoid, + ); + return { vertical, horizontal }; +}; + +export const calcBoundaryBox = ( + geodesic: { vertical: EllipsoidGeodesic; horizontal: EllipsoidGeodesic }, + halfLength: number, + halfWidth: number, +): { + cartographicDimensions: { + rightDimension: Cartographic; + leftDimension: Cartographic; + topDimension: Cartographic; + bottomDimension: Cartographic; + }; + cartesianArray: Cartesian3[]; +} => { + const topDimension = geodesic.vertical.interpolateUsingSurfaceDistance(halfLength); + const bottomDimension = geodesic.vertical.interpolateUsingSurfaceDistance(-halfLength); + const rightDimension = geodesic.horizontal.interpolateUsingSurfaceDistance(halfWidth); + const leftDimension = geodesic.horizontal.interpolateUsingSurfaceDistance(-halfWidth); + + const rightTop = new Cartographic(rightDimension.longitude, topDimension.latitude, 0); + const leftTop = new Cartographic(leftDimension.longitude, topDimension.latitude, 0); + const rightBottom = new Cartographic(rightDimension.longitude, bottomDimension.latitude, 0); + const leftBottom = new Cartographic(leftDimension.longitude, bottomDimension.latitude, 0); + return { + cartographicDimensions: { + rightDimension, + leftDimension, + topDimension, + bottomDimension, + }, + cartesianArray: [ + Cartographic.toCartesian(rightTop), + Cartographic.toCartesian(leftTop), + Cartographic.toCartesian(leftBottom), + Cartographic.toCartesian(rightBottom), + Cartographic.toCartesian(rightTop), + ], + }; +}; + +export const getAllowedCameraDestination = ( + camera: Camera, + limiterDimensions: InnerLimiterDimensions, +): Cartesian3 => { + const cameraPosition = Cartographic.fromDegrees(camera?.lng, camera?.lat, camera?.height); + + const destination = new Cartographic( + Math.clamp( + cameraPosition.longitude, + limiterDimensions.cartographicDimensions.leftDimension.longitude, + limiterDimensions.cartographicDimensions.rightDimension.longitude, + ), + Math.clamp( + cameraPosition.latitude, + limiterDimensions.cartographicDimensions.bottomDimension.latitude, + limiterDimensions.cartographicDimensions.topDimension.latitude, + ), + cameraPosition.height, + ); + return Cartographic.toCartesian(destination); +}; diff --git a/src/core/engines/Cesium/common.test.ts b/src/core/engines/Cesium/common.test.ts new file mode 100644 index 000000000..02595d886 --- /dev/null +++ b/src/core/engines/Cesium/common.test.ts @@ -0,0 +1,30 @@ +import { Entity, JulianDate } from "cesium"; +import { expect, test } from "vitest"; + +import { attachTag } from "./common"; + +test("attachTag", () => { + const entity = new Entity(); + const time = new JulianDate(); + + expect(entity.properties?.hasProperty("tag_a")).toBe(undefined); + + attachTag(entity, "tag_a", "value_a"); + expect(entity.properties?.hasProperty("tag_a")).toBe(true); + expect(entity.properties?.hasProperty("tag_b")).toBe(false); + const result1 = entity.properties?.getValue(time); + expect(result1["tag_a"]).toBe("value_a"); + + attachTag(entity, "tag_b", "value_b"); + expect(entity.properties?.hasProperty("tag_b")).toBe(true); + const result2 = entity.properties?.getValue(time); + expect(result2["tag_b"]).toBe("value_b"); + + attachTag(entity, "tag_a", "value_a2"); + expect(entity.properties?.hasProperty("tag_a")).toBe(true); + const result3 = entity.properties?.getValue(time); + expect(result3["tag_a"]).toBe("value_a2"); + + attachTag(entity, "tag_a", undefined); + expect(entity.properties?.hasProperty("tag_a")).toBe(false); +}); diff --git a/src/core/engines/Cesium/common.ts b/src/core/engines/Cesium/common.ts new file mode 100644 index 000000000..034f03b22 --- /dev/null +++ b/src/core/engines/Cesium/common.ts @@ -0,0 +1,673 @@ +import { + ColorBlendMode, + BoundingSphere, + HeadingPitchRange, + HorizontalOrigin, + VerticalOrigin, + Camera as CesiumCamera, + Math as CesiumMath, + Scene, + Cartesian2, + Cartesian3, + CesiumWidget, + PerspectiveFrustum, + OrthographicOffCenterFrustum, + Viewer, + HeightReference, + ShadowMode, + Entity, + PropertyBag, + Clock as CesiumClock, + JulianDate, + Ellipsoid, + Quaternion, + Matrix3, + Cartographic, + EllipsoidTerrainProvider, + sampleTerrainMostDetailed, + Ray, + IntersectionTests, + Matrix4, + Color, +} from "cesium"; +import { useCallback, MutableRefObject } from "react"; + +import { useCanvas, useImage } from "@reearth/util/image"; +import { tweenInterval } from "@reearth/util/raf"; + +import type { Camera, CameraOptions, Clock, FlyToDestination } from ".."; + +export const layerIdField = `__reearth_layer_id`; + +const defaultImageSize = 50; + +export const drawIcon = ( + c: HTMLCanvasElement, + image: HTMLImageElement | undefined, + w: number, + h: number, + crop: "circle" | "rounded" | "none" = "none", + shadow = false, + shadowColor = "rgba(0, 0, 0, 0.7)", + shadowBlur = 3, + shadowOffsetX = 0, + shadowOffsetY = 0, +) => { + const ctx = c.getContext("2d"); + if (!image || !ctx) return; + + ctx.save(); + + c.width = w + shadowBlur; + c.height = h + shadowBlur; + ctx.shadowBlur = shadowBlur; + ctx.shadowOffsetX = shadowOffsetX; + ctx.shadowOffsetY = shadowOffsetY; + ctx.globalCompositeOperation = "source-over"; + ctx.clearRect(0, 0, c.width, c.height); + ctx.drawImage(image, (c.width - w) / 2, (c.height - h) / 2, w, h); + + if (crop === "circle") { + ctx.fillStyle = "black"; + ctx.globalCompositeOperation = "destination-in"; + ctx.arc(w / 2, h / 2, Math.min(w, h) / 2, 0, 2 * Math.PI); + ctx.fill(); + + if (shadow) { + ctx.shadowColor = shadowColor; + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = "black"; + ctx.arc(w / 2, h / 2, Math.min(w, h) / 2, 0, 2 * Math.PI); + ctx.fill(); + } + } else if (shadow) { + ctx.shadowColor = shadowColor; + ctx.globalCompositeOperation = "destination-over"; + ctx.fillStyle = "black"; + ctx.rect((c.width - w) / 2, (c.height - h) / 2, w, h); + ctx.fill(); + } + + ctx.restore(); +}; + +export const useIcon = ({ + image, + imageSize, + crop, + shadow, + shadowColor, + shadowBlur, + shadowOffsetX, + shadowOffsetY, +}: { + image?: string; + imageSize?: number; + crop?: "circle" | "rounded" | "none"; + shadow?: boolean; + shadowColor?: string; + shadowBlur?: number; + shadowOffsetX?: number; + shadowOffsetY?: number; +}): [string, number, number] => { + const img = useImage(image); + + const w = !img + ? 0 + : typeof imageSize === "number" + ? Math.floor(img.width * imageSize) + : Math.min(defaultImageSize, img.width); + const h = !img + ? 0 + : typeof imageSize === "number" + ? Math.floor(img.height * imageSize) + : Math.floor((w / img.width) * img.height); + + const draw = useCallback( + (can: HTMLCanvasElement) => + drawIcon(can, img, w, h, crop, shadow, shadowColor, shadowBlur, shadowOffsetX, shadowOffsetY), + [crop, h, img, shadow, shadowBlur, shadowColor, shadowOffsetX, shadowOffsetY, w], + ); + const canvas = useCanvas(draw); + return [canvas, w, h]; +}; + +export const ho = (o: "left" | "center" | "right" | undefined): HorizontalOrigin | undefined => + ({ + left: HorizontalOrigin.LEFT, + center: HorizontalOrigin.CENTER, + right: HorizontalOrigin.RIGHT, + [""]: undefined, + }[o || ""]); + +export const vo = ( + o: "top" | "center" | "baseline" | "bottom" | undefined, +): VerticalOrigin | undefined => + ({ + top: VerticalOrigin.TOP, + center: VerticalOrigin.CENTER, + baseline: VerticalOrigin.BASELINE, + bottom: VerticalOrigin.BOTTOM, + [""]: undefined, + }[o || ""]); + +export const getLocationFromScreen = ( + scene: Scene | undefined | null, + x: number, + y: number, + withTerrain = false, +) => { + if (!scene) return undefined; + const camera = scene.camera; + const ellipsoid = scene.globe.ellipsoid; + let cartesian; + if (withTerrain) { + const ray = camera.getPickRay(new Cartesian2(x, y)); + if (ray) { + cartesian = scene.globe.pick(ray, scene); + } + } + if (!cartesian) { + cartesian = camera?.pickEllipsoid(new Cartesian2(x, y), ellipsoid); + } + if (!cartesian) return undefined; + const { latitude, longitude, height } = ellipsoid.cartesianToCartographic(cartesian); + return { + lat: CesiumMath.toDegrees(latitude), + lng: CesiumMath.toDegrees(longitude), + height, + }; +}; + +export const flyTo = ( + cesiumCamera?: CesiumCamera, + camera?: { + /** degrees */ + lat?: number; + /** degrees */ + lng?: number; + /** meters */ + height?: number; + /** radians */ + heading?: number; + /** radians */ + pitch?: number; + /** radians */ + roll?: number; + /** Field of view expressed in radians */ + fov?: number; + }, + options?: { + /** Seconds */ + duration?: number; + easing?: (time: number) => number; + }, +) => { + if (!cesiumCamera || !camera) return () => {}; + + const cancelFov = animateFOV({ + fov: camera.fov, + camera: cesiumCamera, + duration: options?.duration, + easing: options?.easing, + }); + + const position = + typeof camera.lat === "number" && + typeof camera.lng === "number" && + typeof camera.height === "number" + ? Cartesian3.fromDegrees(camera.lng, camera.lat, camera.height) + : undefined; + + if (position) { + cesiumCamera.flyTo({ + destination: position, + orientation: { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }, + duration: options?.duration ?? 0, + easingFunction: options?.easing, + }); + } + + return () => { + cancelFov?.(); + cesiumCamera?.cancelFlight(); + }; +}; + +export const lookAt = ( + cesiumCamera?: CesiumCamera, + camera?: { + /** degrees */ + lat?: number; + /** degrees */ + lng?: number; + /** meters */ + height?: number; + /** radians */ + heading?: number; + /** radians */ + pitch?: number; + /** radians */ + range?: number; + /** Field of view expressed in radians */ + fov?: number; + }, + options?: { + /** Seconds */ + duration?: number; + easing?: (time: number) => number; + }, +) => { + if (!cesiumCamera || !camera) return () => {}; + + const cancelFov = animateFOV({ + fov: camera.fov, + camera: cesiumCamera, + duration: options?.duration, + easing: options?.easing, + }); + + const position = + typeof camera.lat === "number" && + typeof camera.lng === "number" && + typeof camera.height === "number" + ? Cartesian3.fromDegrees(camera.lng, camera.lat, camera.height) + : undefined; + + if (position) { + cesiumCamera.flyToBoundingSphere(new BoundingSphere(position), { + offset: new HeadingPitchRange(camera.heading, camera.pitch, camera.range), + duration: options?.duration, + easingFunction: options?.easing, + }); + } + + return () => { + cancelFov?.(); + cesiumCamera?.cancelFlight(); + }; +}; + +export const lookAtWithoutAnimation = ( + scene: Scene, + camera?: { + /** degrees */ + lat?: number; + /** degrees */ + lng?: number; + /** meters */ + height?: number; + /** radians */ + heading?: number; + /** radians */ + pitch?: number; + /** radians */ + range?: number; + /** Field of view expressed in radians */ + fov?: number; + }, +) => { + if (!camera) { + return; + } + + const position = + typeof camera.lat === "number" && + typeof camera.lng === "number" && + typeof camera.height === "number" + ? Cartesian3.fromDegrees(camera.lng, camera.lat, camera.height) + : undefined; + + if (position) { + scene.camera.lookAtTransform(Matrix4.fromTranslation(position)); + } +}; + +export const animateFOV = ({ + fov, + camera, + easing, + duration, +}: { + fov?: number; + camera: CesiumCamera; + easing?: (t: number) => number; + duration?: number; +}): (() => void) | undefined => { + // fov animation + if ( + typeof fov === "number" && + camera.frustum instanceof PerspectiveFrustum && + typeof camera.frustum.fov === "number" && + camera.frustum.fov !== fov + ) { + const fromFov = camera.frustum.fov; + return tweenInterval( + t => { + if (!(camera.frustum instanceof PerspectiveFrustum)) return; + camera.frustum.fov = (fov - fromFov) * t + fromFov; + }, + easing || "inOutCubic", + (duration ?? 0) * 1000, + ); + } + return undefined; +}; + +/** + * Get the center of globe. + */ +export const getCenterCamera = ({ + camera, + scene, +}: { + camera: CesiumCamera; + scene: Scene; +}): Cartesian3 | void => { + const result = new Cartesian3(); + const ray = camera.getPickRay(camera.positionWC); + if (ray) { + ray.origin = camera.positionWC; + ray.direction = camera.directionWC; + return scene.globe.pick(ray, scene, result); + } +}; + +export const zoom = ( + { camera, scene, relativeAmount }: { camera: CesiumCamera; scene: Scene; relativeAmount: number }, + options?: CameraOptions, +) => { + const center = getCenterCamera({ camera, scene }); + const target = + center || + IntersectionTests.grazingAltitudeLocation( + // Get the ray from cartographic to the camera direction + new Ray( + // Get the cartographic position of camera on 3D space. + scene.globe.ellipsoid.cartographicToCartesian(camera.positionCartographic), + // Get the camera direction. + camera.directionWC, + ), + scene.globe.ellipsoid, + ); + + if (!target) { + return; + } + + const orientation = { + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }; + + const cartesian3Scratch = new Cartesian3(); + const direction = Cartesian3.subtract(camera.position, target, cartesian3Scratch); + const movementVector = Cartesian3.multiplyByScalar(direction, relativeAmount, direction); + const endPosition = Cartesian3.add(target, movementVector, target); + + camera.flyTo({ + destination: endPosition, + orientation: orientation, + duration: options?.duration || 0.5, + convert: false, + }); +}; + +export const getCamera = (viewer: Viewer | CesiumWidget | undefined): Camera | undefined => { + if (!viewer || viewer.isDestroyed() || !viewer.camera || !viewer.scene) return undefined; + const { camera } = viewer; + if ( + !( + camera.frustum instanceof PerspectiveFrustum || + camera.frustum instanceof OrthographicOffCenterFrustum + ) + ) + return; + const { latitude, longitude, height } = camera.positionCartographic; + const lat = CesiumMath.toDegrees(latitude); + const lng = CesiumMath.toDegrees(longitude); + const { heading, pitch, roll } = camera; + const fov = camera.frustum instanceof PerspectiveFrustum ? camera.frustum.fov : 1; + return { lng, lat, height, heading, pitch, roll, fov }; +}; + +export const getClock = (clock: CesiumClock | undefined): Clock | undefined => { + if (!clock) return undefined; + return { + get start() { + return JulianDate.toDate(clock.startTime); + }, + get stop() { + return JulianDate.toDate(clock.stopTime); + }, + get current() { + return JulianDate.toDate(clock.currentTime); + }, + get playing() { + return clock.shouldAnimate; + }, + get speed() { + return clock.multiplier; + }, + }; +}; + +export const colorBlendMode = (colorBlendMode?: "highlight" | "replace" | "mix" | "none") => + (( + { + highlight: ColorBlendMode.HIGHLIGHT, + replace: ColorBlendMode.REPLACE, + mix: ColorBlendMode.MIX, + } as { [key in string]?: ColorBlendMode } + )[colorBlendMode || ""]); + +export const heightReference = ( + heightReference?: "none" | "clamp" | "relative", +): HeightReference | undefined => + (( + { clamp: HeightReference.CLAMP_TO_GROUND, relative: HeightReference.RELATIVE_TO_GROUND } as { + [key in string]?: HeightReference; + } + )[heightReference || ""]); + +export const shadowMode = ( + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only", +): ShadowMode | undefined => + (( + { + enabled: ShadowMode.ENABLED, + cast_only: ShadowMode.CAST_ONLY, + receive_only: ShadowMode.RECEIVE_ONLY, + } as { + [key in string]?: ShadowMode; + } + )[shadows || ""]); + +export const unselectableTag = "reearth_unselectable"; +export const draggableTag = "reearth_draggable"; + +export function isSelectable(e: Entity | undefined): boolean { + return !e?.properties?.hasProperty(unselectableTag); +} + +export function isDraggable(e: Entity): string | undefined { + return e.properties?.getValue(new JulianDate())?.[draggableTag]; +} + +export function attachTag(entity: Entity | undefined, tag: string, value: any) { + if (!entity) return; + if (typeof value !== "undefined" && !entity.properties) { + entity.properties = new PropertyBag({ [tag]: value }); + } else if (typeof value === "undefined") { + entity.properties?.removeProperty(tag); + } else if (entity.properties?.hasProperty(tag)) { + entity.properties?.removeProperty(tag); + entity.properties?.addProperty(tag, value); + } else { + entity.properties?.addProperty(tag, value); + } +} + +export function lookHorizontal(scene: Scene, amount: number) { + const camera = scene.camera; + const ellipsoid = scene.globe.ellipsoid; + const surfaceNormal = ellipsoid.geodeticSurfaceNormal(camera.position, new Cartesian3()); + camera.look(surfaceNormal, amount); +} + +export function lookVertical(scene: Scene, amount: number) { + const camera = scene.camera; + const ellipsoid = scene.globe.ellipsoid; + const lookAxis = projectVectorToSurface(camera.right, camera.position, ellipsoid); + const surfaceNormal = ellipsoid.geodeticSurfaceNormal(camera.position, new Cartesian3()); + const currentAngle = CesiumMath.toDegrees(Cartesian3.angleBetween(surfaceNormal, camera.up)); + const upAfterLook = rotateVectorAboutAxis(camera.up, lookAxis, amount); + const angleAfterLook = CesiumMath.toDegrees(Cartesian3.angleBetween(surfaceNormal, upAfterLook)); + const friction = angleAfterLook < currentAngle ? 1 : (90 - currentAngle) / 90; + camera.look(lookAxis, amount * friction); +} + +export function moveForward(scene: Scene, amount: number) { + const direction = projectVectorToSurface( + scene.camera.direction, + scene.camera.position, + scene.globe.ellipsoid, + ); + scene.camera.move(direction, amount); +} + +export function moveBackward(scene: Scene, amount: number) { + const direction = projectVectorToSurface( + scene.camera.direction, + scene.camera.position, + scene.globe.ellipsoid, + ); + scene.camera.move(direction, -amount); +} + +export function moveUp(scene: Scene, amount: number) { + const surfaceNormal = scene.globe.ellipsoid.geodeticSurfaceNormal( + scene.camera.position, + new Cartesian3(), + ); + scene.camera.move(surfaceNormal, amount); +} + +export function moveDown(scene: Scene, amount: number) { + const surfaceNormal = scene.globe.ellipsoid.geodeticSurfaceNormal( + scene.camera.position, + new Cartesian3(), + ); + scene.camera.move(surfaceNormal, -amount); +} + +export function moveLeft(scene: Scene, amount: number) { + const direction = projectVectorToSurface( + scene.camera.right, + scene.camera.position, + scene.globe.ellipsoid, + ); + scene.camera.move(direction, -amount); +} + +export function moveRight(scene: Scene, amount: number) { + const direction = projectVectorToSurface( + scene.camera.right, + scene.camera.position, + scene.globe.ellipsoid, + ); + scene.camera.move(direction, amount); +} + +export async function moveOverTerrain(viewer: Viewer, offset = 0) { + const camera = getCamera(viewer); + if (camera?.lng === undefined || camera?.lat === undefined) return; + const height = await sampleTerrainHeight(viewer.scene, camera.lng, camera.lat); + if (height && height !== 0) { + const innerCamera = getCamera(viewer); + if (innerCamera && innerCamera?.height < height + offset) { + viewer.scene.camera.moveUp(height + offset - innerCamera.height); + } + } +} + +export async function flyToGround( + viewer: Viewer, + cancelCameraFlight: MutableRefObject<(() => void) | undefined>, + camera: FlyToDestination, + options?: { + duration?: number; + easing?: (time: number) => number; + }, + offset = 0, +) { + if (camera.lng === undefined || camera.lat === undefined) return; + const height = await sampleTerrainHeight(viewer.scene, camera.lng, camera.lat); + const tarHeight = height ? height + offset : offset; + cancelCameraFlight.current?.(); + cancelCameraFlight.current = flyTo( + viewer.scene?.camera, + { ...getCamera(viewer), ...camera, height: tarHeight }, + options, + ); +} + +function projectVectorToSurface(vector: Cartesian3, position: Cartesian3, ellipsoid: Ellipsoid) { + const surfaceNormal = ellipsoid.geodeticSurfaceNormal(position, new Cartesian3()); + const magnitudeOfProjectionOnSurfaceNormal = Cartesian3.dot(vector, surfaceNormal); + const projectionOnSurfaceNormal = Cartesian3.multiplyByScalar( + surfaceNormal, + magnitudeOfProjectionOnSurfaceNormal, + new Cartesian3(), + ); + return Cartesian3.subtract(vector, projectionOnSurfaceNormal, new Cartesian3()); +} + +function rotateVectorAboutAxis(vector: Cartesian3, rotateAxis: Cartesian3, rotateAmount: number) { + const quaternion = Quaternion.fromAxisAngle(rotateAxis, -rotateAmount, new Quaternion()); + const rotation = Matrix3.fromQuaternion(quaternion, new Matrix3()); + const rotatedVector = Matrix3.multiplyByVector(rotation, vector, vector.clone()); + return rotatedVector; +} + +export async function sampleTerrainHeight( + scene: Scene, + lng: number, + lat: number, +): Promise { + const terrainProvider = scene.terrainProvider; + if (terrainProvider instanceof EllipsoidTerrainProvider) return 0; + + const [sample] = await sampleTerrainMostDetailed(terrainProvider, [ + Cartographic.fromDegrees(lng, lat), + ]); + return sample.height; +} + +export async function sampleTerrainHeightFromCartesian(scene: Scene, translation: Cartesian3) { + const cart = Cartographic.fromCartesian(translation); + const [lng, lat] = [ + CesiumMath.toDegrees(cart?.longitude || 0), + CesiumMath.toDegrees(cart?.latitude || 0), + ]; + if (!lng || !lat) { + return; + } + return await sampleTerrainHeight(scene, lng, lat); +} + +export const toColor = (c?: string) => { + if (!c || typeof c !== "string") return undefined; + + // support alpha + const m = c.match(/^#([A-Fa-f0-9]{6})([A-Fa-f0-9]{2})$|^#([A-Fa-f0-9]{3})([A-Fa-f0-9])$/); + if (!m) return Color.fromCssColorString(c); + + const alpha = parseInt(m[4] ? m[4].repeat(2) : m[2], 16) / 255; + return Color.fromCssColorString(`#${m[1] ?? m[3]}`).withAlpha(alpha); +}; diff --git a/src/core/engines/Cesium/core/Clock.tsx b/src/core/engines/Cesium/core/Clock.tsx new file mode 100644 index 000000000..723954da5 --- /dev/null +++ b/src/core/engines/Cesium/core/Clock.tsx @@ -0,0 +1,67 @@ +import { Clock as CesiumClock, ClockRange, ClockStep, JulianDate } from "cesium"; +import { useCallback, useEffect, useMemo } from "react"; +import { Clock, useCesium } from "resium"; + +import type { SceneProperty } from "../.."; + +export type Props = { + property?: SceneProperty; + onTick?: (clock: Date) => void; +}; + +export default function ReearthClock({ property, onTick }: Props): JSX.Element | null { + const { animation, visible, start, stop, current, stepType, rangeType, multiplier, step } = + property?.timeline ?? {}; + const startTime = useMemo(() => (start ? JulianDate.fromIso8601(start) : undefined), [start]); + const stopTime = useMemo(() => (stop ? JulianDate.fromIso8601(stop) : undefined), [stop]); + const currentTime = useMemo( + () => (current ? JulianDate.fromIso8601(current) : undefined), + [current], + ); + const clockStep = + stepType === "fixed" ? ClockStep.TICK_DEPENDENT : ClockStep.SYSTEM_CLOCK_MULTIPLIER; + const clockMultiplier = stepType === "fixed" ? step ?? 1 : multiplier ?? 1; + + const handleTick = useCallback( + (clock: CesiumClock) => { + if (!clock.shouldAnimate) return; + onTick?.(JulianDate.toDate(clock.currentTime)); + }, + [onTick], + ); + + const { viewer } = useCesium(); + useEffect(() => { + if (!viewer) return; + if (viewer.animation?.container) { + (viewer.animation.container as HTMLDivElement).style.visibility = visible + ? "visible" + : "hidden"; + } + if (viewer.timeline?.container) { + (viewer.timeline.container as HTMLDivElement).style.visibility = visible + ? "visible" + : "hidden"; + } + viewer.forceResize(); + }, [viewer, visible]); + + return ( + + ); +} + +const rangeTypes = { + unbounded: ClockRange.UNBOUNDED, + clamped: ClockRange.CLAMPED, + bounced: ClockRange.LOOP_STOP, +}; diff --git a/src/core/engines/Cesium/core/Globe.tsx b/src/core/engines/Cesium/core/Globe.tsx new file mode 100644 index 000000000..dfd4c284b --- /dev/null +++ b/src/core/engines/Cesium/core/Globe.tsx @@ -0,0 +1,113 @@ +import { + ArcGISTiledElevationTerrainProvider, + CesiumTerrainProvider, + EllipsoidTerrainProvider, + IonResource, + TerrainProvider, +} from "cesium"; +import { pick } from "lodash-es"; +import { useMemo } from "react"; +import { Globe as CesiumGlobe } from "resium"; + +import { objKeys } from "@reearth/util/util"; + +import type { SceneProperty, TerrainProperty } from "../.."; + +export type Props = { + property?: SceneProperty; + cesiumIonAccessToken?: string; +}; + +export default function Globe({ property, cesiumIonAccessToken }: Props): JSX.Element | null { + const terrainProperty = useMemo( + (): TerrainProperty => ({ + ...property?.terrain, + ...pick(property?.default, terrainPropertyKeys), + }), + [property?.terrain, property?.default], + ); + + const terrainProvider = useMemo((): TerrainProvider | undefined => { + const opts = { + terrain: terrainProperty?.terrain, + terrainType: terrainProperty?.terrainType, + terrainCesiumIonAccessToken: + terrainProperty?.terrainCesiumIonAccessToken || cesiumIonAccessToken, + terrainCesiumIonAsset: terrainProperty?.terrainCesiumIonAsset, + terrainCesiumIonUrl: terrainProperty?.terrainCesiumIonUrl, + }; + const provider = opts.terrain ? terrainProviders[opts.terrainType || "cesium"] : undefined; + return (typeof provider === "function" ? provider(opts) : provider) ?? defaultTerrainProvider; + }, [ + terrainProperty?.terrain, + terrainProperty?.terrainType, + terrainProperty?.terrainCesiumIonAccessToken, + terrainProperty?.terrainCesiumIonAsset, + terrainProperty?.terrainCesiumIonUrl, + cesiumIonAccessToken, + ]); + + return ( + + ); +} + +const terrainPropertyKeys = objKeys({ + terrain: 0, + terrainType: 0, + terrainExaggeration: 0, + terrainExaggerationRelativeHeight: 0, + depthTestAgainstTerrain: 0, + terrainCesiumIonAsset: 0, + terrainCesiumIonAccessToken: 0, + terrainCesiumIonUrl: 0, + terrainUrl: 0, +}); + +const defaultTerrainProvider = new EllipsoidTerrainProvider(); + +const terrainProviders: { + [k in NonNullable]: + | TerrainProvider + | (( + opts: Pick< + TerrainProperty, + "terrainCesiumIonAccessToken" | "terrainCesiumIonAsset" | "terrainCesiumIonUrl" + >, + ) => TerrainProvider | null); +} = { + cesium: ({ terrainCesiumIonAccessToken }) => + // https://github.com/CesiumGS/cesium/blob/main/Source/Core/createWorldTerrain.js + new CesiumTerrainProvider({ + url: IonResource.fromAssetId(1, { + accessToken: terrainCesiumIonAccessToken, + }), + requestVertexNormals: false, + requestWaterMask: false, + }), + arcgis: () => + new ArcGISTiledElevationTerrainProvider({ + url: "https://elevation3d.arcgis.com/arcgis/rest/services/WorldElevation3D/Terrain3D/ImageServer", + }), + cesiumion: ({ terrainCesiumIonAccessToken, terrainCesiumIonAsset, terrainCesiumIonUrl }) => + terrainCesiumIonAsset + ? new CesiumTerrainProvider({ + url: + terrainCesiumIonUrl || + IonResource.fromAssetId(parseInt(terrainCesiumIonAsset, 10), { + accessToken: terrainCesiumIonAccessToken, + }), + requestVertexNormals: true, + }) + : null, +}; diff --git a/src/core/engines/Cesium/core/Imagery.test.ts b/src/core/engines/Cesium/core/Imagery.test.ts new file mode 100644 index 000000000..60f67528b --- /dev/null +++ b/src/core/engines/Cesium/core/Imagery.test.ts @@ -0,0 +1,103 @@ +import { renderHook } from "@testing-library/react"; +import { expect, test, vi } from "vitest"; + +import { type Tile, useImageryProviders } from "./Imagery"; + +test("useImageryProviders", () => { + const provider = vi.fn(({ url }: { url?: string } = {}): any => ({ hoge: url })); + const provider2 = vi.fn(({ url }: { url?: string } = {}): any => ({ hoge2: url })); + const presets = { default: provider, foobar: provider2 }; + const { result, rerender } = renderHook( + ({ tiles, cesiumIonAccessToken }: { tiles: Tile[]; cesiumIonAccessToken?: string }) => + useImageryProviders({ + tiles, + presets, + cesiumIonAccessToken, + }), + { initialProps: { tiles: [{ id: "1", tile_type: "default" }] } }, + ); + + expect(result.current.providers).toEqual({ "1": ["default", undefined, { hoge: undefined }] }); + expect(result.current.updated).toBe(true); + expect(provider).toBeCalledTimes(1); + const prevImageryProvider = result.current.providers["1"][2]; + + // re-render with same tiles + rerender({ tiles: [{ id: "1", tile_type: "default" }] }); + + expect(result.current.providers).toEqual({ "1": ["default", undefined, { hoge: undefined }] }); + expect(result.current.updated).toBe(false); + expect(result.current.providers["1"][2]).toBe(prevImageryProvider); // 1's provider should be reused + expect(provider).toBeCalledTimes(1); + + // update a tile URL + rerender({ tiles: [{ id: "1", tile_type: "default", tile_url: "a" }] }); + + expect(result.current.providers).toEqual({ "1": ["default", "a", { hoge: "a" }] }); + expect(result.current.providers["1"][2]).not.toBe(prevImageryProvider); + expect(result.current.updated).toBe(true); + expect(provider).toBeCalledTimes(2); + expect(provider).toBeCalledWith({ url: "a" }); + const prevImageryProvider2 = result.current.providers["1"][2]; + + // add a tile with URL + rerender({ + tiles: [ + { id: "2", tile_type: "default" }, + { id: "1", tile_type: "default", tile_url: "a" }, + ], + }); + + expect(result.current.providers).toEqual({ + "2": ["default", undefined, { hoge: undefined }], + "1": ["default", "a", { hoge: "a" }], + }); + expect(result.current.updated).toBe(true); + expect(result.current.providers["1"][2]).toBe(prevImageryProvider2); // 1's provider should be reused + expect(provider).toBeCalledTimes(3); + + // sort tiles + rerender({ + tiles: [ + { id: "1", tile_type: "default", tile_url: "a" }, + { id: "2", tile_type: "default" }, + ], + }); + + expect(result.current.providers).toEqual({ + "1": ["default", "a", { hoge: "a" }], + "2": ["default", undefined, { hoge: undefined }], + }); + expect(result.current.updated).toBe(true); + expect(result.current.providers["1"][2]).toBe(prevImageryProvider2); // 1's provider should be reused + expect(provider).toBeCalledTimes(3); + + // delete a tile + rerender({ + tiles: [{ id: "1", tile_type: "default", tile_url: "a" }], + cesiumIonAccessToken: "a", + }); + + expect(result.current.providers).toEqual({ + "1": ["default", "a", { hoge: "a" }], + }); + expect(result.current.updated).toBe(true); + expect(result.current.providers["1"][2]).not.toBe(prevImageryProvider2); + expect(provider).toBeCalledTimes(4); + + // update a tile type + rerender({ + tiles: [{ id: "1", tile_type: "foobar", tile_url: "u" }], + cesiumIonAccessToken: "a", + }); + + expect(result.current.providers).toEqual({ + "1": ["foobar", "u", { hoge2: "u" }], + }); + expect(result.current.updated).toBe(true); + expect(provider).toBeCalledTimes(4); + expect(provider2).toBeCalledTimes(1); + + rerender({ tiles: [] }); + expect(result.current.providers).toEqual({}); +}); diff --git a/src/core/engines/Cesium/core/Imagery.tsx b/src/core/engines/Cesium/core/Imagery.tsx new file mode 100644 index 000000000..3d6c52c07 --- /dev/null +++ b/src/core/engines/Cesium/core/Imagery.tsx @@ -0,0 +1,155 @@ +import { ImageryProvider } from "cesium"; +import { isEqual } from "lodash-es"; +import { useCallback, useMemo, useRef, useLayoutEffect } from "react"; +import { ImageryLayer } from "resium"; + +import { tiles as tilePresets } from "./presets"; + +export type ImageryLayerData = { + id: string; + provider: ImageryProvider; + min?: number; + max?: number; + opacity?: number; +}; + +export type Tile = { + id: string; + tile_url?: string; + tile_type?: string; + tile_opacity?: number; + tile_minLevel?: number; + tile_maxLevel?: number; +}; + +export type Props = { + tiles?: Tile[]; + cesiumIonAccessToken?: string; +}; + +export default function ImageryLayers({ tiles, cesiumIonAccessToken }: Props) { + const { providers, updated } = useImageryProviders({ + tiles, + cesiumIonAccessToken, + presets: tilePresets, + }); + + // force rerendering all layers when any provider is updated + // since Resium does not sort layers according to ImageryLayer component order + const counter = useRef(0); + useLayoutEffect(() => { + if (updated) counter.current++; + }, [providers, updated]); + + return ( + <> + {tiles + ?.map(({ id, ...tile }) => ({ ...tile, id, provider: providers[id]?.[2] })) + .map(({ id, tile_opacity: opacity, tile_minLevel: min, tile_maxLevel: max, provider }, i) => + provider ? ( + + ) : null, + )} + + ); +} + +type Providers = { [id: string]: [string | undefined, string | undefined, ImageryProvider] }; + +export function useImageryProviders({ + tiles = [], + cesiumIonAccessToken, + presets, +}: { + tiles?: Tile[]; + cesiumIonAccessToken?: string; + presets: { + [key: string]: (opts?: { + url?: string; + cesiumIonAccessToken?: string; + }) => ImageryProvider | null; + }; +}): { providers: Providers; updated: boolean } { + const newTile = useCallback( + (t: Tile, ciat?: string) => + presets[t.tile_type || "default"]({ url: t.tile_url, cesiumIonAccessToken: ciat }), + [presets], + ); + + const prevCesiumIonAccessToken = useRef(cesiumIonAccessToken); + const tileKeys = tiles.map(t => t.id).join(","); + const prevTileKeys = useRef(tileKeys); + const prevProviders = useRef({}); + + // Manage TileProviders so that TileProvider does not need to be recreated each time tiles are updated. + const { providers, updated } = useMemo(() => { + const isCesiumAccessTokenUpdated = prevCesiumIonAccessToken.current !== cesiumIonAccessToken; + const prevProvidersKeys = Object.keys(prevProviders.current); + const added = tiles.map(t => t.id).filter(t => t && !prevProvidersKeys.includes(t)); + + const rawProviders = [ + ...Object.entries(prevProviders.current), + ...added.map(a => [a, undefined] as const), + ].map(([k, v]) => ({ + key: k, + added: added.includes(k), + prevType: v?.[0], + prevUrl: v?.[1], + prevProvider: v?.[2], + tile: tiles.find(t => t.id === k), + })); + + const providers = Object.fromEntries( + rawProviders + .map( + ({ + key, + added, + prevType, + prevUrl, + prevProvider, + tile, + }): + | [string, [string | undefined, string | undefined, ImageryProvider | null | undefined]] + | null => + !tile + ? null + : [ + key, + added || + prevType !== tile.tile_type || + prevUrl !== tile.tile_url || + (isCesiumAccessTokenUpdated && (!tile.tile_type || tile.tile_type === "default")) + ? [tile.tile_type, tile.tile_url, newTile(tile, cesiumIonAccessToken)] + : [prevType, prevUrl, prevProvider], + ], + ) + .filter( + (e): e is [string, [string | undefined, string | undefined, ImageryProvider]] => + !!e?.[1][2], + ), + ); + + const updated = + !!added.length || + !!isCesiumAccessTokenUpdated || + !isEqual(prevTileKeys.current, tileKeys) || + rawProviders.some( + p => p.tile && (p.prevType !== p.tile.tile_type || p.prevUrl !== p.tile.tile_url), + ); + + prevTileKeys.current = tileKeys; + prevCesiumIonAccessToken.current = cesiumIonAccessToken; + + return { providers, updated }; + }, [cesiumIonAccessToken, tiles, tileKeys, newTile]); + + prevProviders.current = providers; + return { providers, updated }; +} diff --git a/src/core/engines/Cesium/core/Indicator.tsx b/src/core/engines/Cesium/core/Indicator.tsx new file mode 100644 index 000000000..7ccb5b536 --- /dev/null +++ b/src/core/engines/Cesium/core/Indicator.tsx @@ -0,0 +1,132 @@ +import useTransition, { TransitionStatus } from "@rot1024/use-transition"; +import { BoundingSphere, Cartesian3, SceneTransforms, Cartesian2 } from "cesium"; +import { useEffect, useState } from "react"; +import { useCesium } from "resium"; + +import Icon from "@reearth/components/atoms/Icon"; +import { styled } from "@reearth/theme"; + +import type { SceneProperty } from "../.."; +import { useIcon } from "../common"; + +export type Props = { + className?: string; + property?: SceneProperty; +}; + +export default function Indicator({ className, property }: Props): JSX.Element | null { + const { viewer } = useCesium(); + const [isVisible, setIsVisible] = useState(true); + const [pos, setPos] = useState(); + + const transiton = useTransition(!!pos && isVisible, 500, { + mountOnEnter: true, + unmountOnExit: true, + }); + const { indicator_type, indicator_image, indicator_image_scale } = property?.indicator ?? {}; + const [img, w, h] = useIcon({ image: indicator_image, imageSize: indicator_image_scale }); + + useEffect(() => { + !(!indicator_type || indicator_type === "default") + ? viewer?.selectionIndicator.viewModel.selectionIndicatorElement.setAttribute( + "hidden", + "true", + ) + : viewer?.selectionIndicator.viewModel.selectionIndicatorElement.removeAttribute("hidden"); + }, [indicator_type, viewer, viewer?.selectionIndicator]); + + useEffect(() => { + if (!viewer) return; + const handleTick = () => { + if (viewer.isDestroyed()) return; + const selected = viewer.selectedEntity; + if ( + !selected || + !selected.isShowing || + !selected.isAvailable(viewer.clock.currentTime) || + !selected.position + ) { + setIsVisible(false); + return; + } + + // https://github.com/CesiumGS/cesium/blob/1.94/Source/Widgets/Viewer/Viewer.js#L1839 + let position: Cartesian3 | undefined = undefined; + const boundingSphere = new BoundingSphere(); + const state = (viewer.dataSourceDisplay as any).getBoundingSphere( + selected, + true, + boundingSphere, + ); + // https://github.com/CesiumGS/cesium/blob/main/Source/DataSources/BoundingSphereState.js#L24 + if (state !== 2 /* BoundingSphereState.FAILED */) { + position = boundingSphere.center; + } else if (selected.position) { + position = selected.position.getValue(viewer.clock.currentTime, position); + } + + if (position) { + const pos = SceneTransforms.wgs84ToWindowCoordinates(viewer.scene, position); + setPos(pos); + setIsVisible(true); + } else { + setIsVisible(false); + } + }; + + viewer.clock.onTick.addEventListener(handleTick); + return () => { + if (viewer.isDestroyed()) return; + viewer.clock.onTick.removeEventListener(handleTick); + }; + }, [viewer]); + + return transiton !== "unmounted" && pos ? ( + indicator_type === "crosshair" ? ( + + ) : indicator_type === "custom" ? ( + + ) : ( + + ) + ) : null; +} + +const StyledIndicator = styled.div<{ transition: TransitionStatus }>` + position: absolute; + transform: translate(-50%, -50%); + transition: ${({ transition }) => + transition === "entering" || transition === "exiting" ? "all 0.5s ease" : ""}; + opacity: ${({ transition }) => (transition === "entering" || transition === "entered" ? 1 : 0)}; + pointer-events: none; +`; + +const StyledIcon = styled(Icon)<{ transition: TransitionStatus }>` + position: absolute; + transform: translate(-50%, -50%); + transition: ${({ transition }) => + transition === "entering" || transition === "exiting" ? "all 0.5s ease" : ""}; + opacity: ${({ transition }) => (transition === "entering" || transition === "entered" ? 1 : 0)}; + pointer-events: none; +`; + +const Image = styled.img<{ transition: TransitionStatus }>` + position: absolute; + transform: translate(-50%, -50%); + transition: ${({ transition }) => + transition === "entering" || transition === "exiting" ? "all 0.5s ease" : ""}; + opacity: ${({ transition }) => (transition === "entering" || transition === "entered" ? 1 : 0)}; + pointer-events: none; +`; diff --git a/src/core/engines/Cesium/core/presets.ts b/src/core/engines/Cesium/core/presets.ts new file mode 100644 index 000000000..aa8cecd41 --- /dev/null +++ b/src/core/engines/Cesium/core/presets.ts @@ -0,0 +1,58 @@ +import { + ImageryProvider, + ArcGisMapServerImageryProvider, + IonImageryProvider, + OpenStreetMapImageryProvider, + IonWorldImageryStyle, + UrlTemplateImageryProvider, +} from "cesium"; + +export const tiles = { + default: ({ cesiumIonAccessToken } = {}) => + new IonImageryProvider({ + assetId: IonWorldImageryStyle.AERIAL, + accessToken: cesiumIonAccessToken, + }), + default_label: ({ cesiumIonAccessToken } = {}) => + new IonImageryProvider({ + assetId: IonWorldImageryStyle.AERIAL_WITH_LABELS, + accessToken: cesiumIonAccessToken, + }), + default_road: ({ cesiumIonAccessToken } = {}) => + new IonImageryProvider({ + assetId: IonWorldImageryStyle.ROAD, + accessToken: cesiumIonAccessToken, + }), + stamen_watercolor: () => + new OpenStreetMapImageryProvider({ + url: "https://stamen-tiles.a.ssl.fastly.net/watercolor/", + credit: "Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under CC BY SA.", + }), + stamen_toner: () => + new OpenStreetMapImageryProvider({ + url: "https://stamen-tiles.a.ssl.fastly.net/toner/", + credit: "Map tiles by Stamen Design, under CC BY 3.0. Data by OpenStreetMap, under CC BY SA.", + }), + open_street_map: () => + new OpenStreetMapImageryProvider({ + url: "https://a.tile.openstreetmap.org/", + credit: + "Copyright: Tiles © Esri — Source: Esri, DeLorme, NAVTEQ, USGS, Intermap, iPC, NRCAN, Esri Japan, METI, Esri China (Hong Kong), Esri (Thailand), TomTom, 2012", + }), + esri_world_topo: () => + new ArcGisMapServerImageryProvider({ + url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer", + credit: + "Copyright: Tiles © Esri — Esri, DeLorme, NAVTEQ, TomTom, Intermap, iPC, USGS, FAO, NPS, NRCAN, GeoBase, Kadaster NL, Ordnance Survey, Esri Japan, METI, Esri China (Hong Kong), and the GIS User Communit", + enablePickFeatures: false, + }), + black_marble: ({ cesiumIonAccessToken } = {}) => + new IonImageryProvider({ assetId: 3812, accessToken: cesiumIonAccessToken }), + japan_gsi_standard: () => + new OpenStreetMapImageryProvider({ + url: "https://cyberjapandata.gsi.go.jp/xyz/std/", + }), + url: ({ url } = {}) => (url ? new UrlTemplateImageryProvider({ url }) : null), +} as { + [key: string]: (opts?: { url?: string; cesiumIonAccessToken?: string }) => ImageryProvider | null; +}; diff --git a/src/core/engines/Cesium/hooks.ts b/src/core/engines/Cesium/hooks.ts new file mode 100644 index 000000000..e5d30eaba --- /dev/null +++ b/src/core/engines/Cesium/hooks.ts @@ -0,0 +1,385 @@ +import { Color, Entity, Cesium3DTileFeature, Cartesian3, Ion } from "cesium"; +import type { Viewer as CesiumViewer } from "cesium"; +import CesiumDnD, { Context } from "cesium-dnd"; +import { isEqual } from "lodash-es"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import type { CesiumComponentRef, CesiumMovementEvent, RootEventTarget } from "resium"; +import { useCustomCompareCallback } from "use-custom-compare"; + +import { e2eAccessToken, setE2ECesiumViewer } from "@reearth/config"; + +import type { + Camera, + LatLng, + SelectLayerOptions, + EngineRef, + SceneProperty, + MouseEvent, + MouseEvents, +} from ".."; + +import { useCameraLimiter } from "./cameraLimiter"; +import { + getCamera, + isDraggable, + isSelectable, + layerIdField, + getLocationFromScreen, + getClock, +} from "./common"; +import { getTag, type Context as FeatureContext } from "./Feature"; +import useEngineRef from "./useEngineRef"; +import { convertCartesian3ToPosition } from "./utils"; + +export default ({ + ref, + property, + camera, + selectedLayerId, + selectionReason, + isLayerDraggable, + meta, + onLayerSelect, + onCameraChange, + onLayerDrag, + onLayerDrop, + onTick, +}: { + ref: React.ForwardedRef; + property?: SceneProperty; + camera?: Camera; + selectedLayerId?: string; + selectionReason?: string; + isLayerDraggable?: boolean; + meta?: Record; + onLayerSelect?: (id?: string, options?: SelectLayerOptions) => void; + onCameraChange?: (camera: Camera) => void; + onLayerDrag?: (layerId: string, position: LatLng) => void; + onLayerDrop?: (layerId: string, propertyKey: string, position: LatLng | undefined) => void; + onTick?: (time: Date) => void; +}) => { + const cesium = useRef>(null); + const cesiumIonDefaultAccessToken = + typeof meta?.cesiumIonAccessToken === "string" + ? meta.cesiumIonAccessToken + : Ion.defaultAccessToken; + const cesiumIonAccessToken = property?.default?.ion || cesiumIonDefaultAccessToken; + + // expose ref + const engineAPI = useEngineRef(ref, cesium); + + const backgroundColor = useMemo( + () => + property?.default?.bgcolor ? Color.fromCssColorString(property.default.bgcolor) : undefined, + [property?.default?.bgcolor], + ); + + useEffect(() => { + engineAPI.changeSceneMode(property?.default?.sceneMode, 0); + }, [property?.default?.sceneMode, engineAPI]); + + // move to initial position at startup + const initialCameraFlight = useRef(false); + + const handleMount = useCustomCompareCallback( + () => { + if (initialCameraFlight.current) return; + initialCameraFlight.current = true; + if ( + property?.cameraLimiter?.cameraLimitterEnabled && + property?.cameraLimiter?.cameraLimitterTargetArea + ) { + engineAPI.flyTo(property?.cameraLimiter?.cameraLimitterTargetArea, { duration: 0 }); + } else if (property?.default?.camera) { + engineAPI.flyTo(property.default.camera, { duration: 0 }); + } + const camera = getCamera(cesium?.current?.cesiumElement); + if (camera) { + onCameraChange?.(camera); + } + const clock = getClock(cesium?.current?.cesiumElement?.clock); + if (clock) { + onTick?.(clock.current); + } + }, + [ + engineAPI, + onCameraChange, + onTick, + property?.default?.camera, + property?.cameraLimiter?.cameraLimitterEnabled, + ], + (prevDeps, nextDeps) => + prevDeps[0] === nextDeps[0] && + prevDeps[1] === nextDeps[1] && + prevDeps[2] === nextDeps[2] && + isEqual(prevDeps[3], nextDeps[3]) && + prevDeps[4] === nextDeps[4], + ); + + const handleUnmount = useCallback(() => { + initialCameraFlight.current = false; + }, []); + + // cache the camera data emitted from viewer camera change + const emittedCamera = useRef([]); + const updateCamera = useCallback(() => { + const viewer = cesium?.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !onCameraChange) return; + + const c = getCamera(viewer); + if (c && !isEqual(c, camera)) { + emittedCamera.current.push(c); + // The state change is not sync now. This number is how many state updates can actually happen to be merged within one re-render. + if (emittedCamera.current.length > 10) { + emittedCamera.current.shift(); + } + onCameraChange?.(c); + } + }, [camera, onCameraChange]); + + const handleCameraChange = useCallback(() => { + updateCamera(); + }, [updateCamera]); + + const handleCameraMoveEnd = useCallback(() => { + updateCamera(); + }, [updateCamera]); + + useEffect(() => { + if (camera && !emittedCamera.current.includes(camera)) { + engineAPI.flyTo(camera, { duration: 0 }); + emittedCamera.current = []; + } + }, [camera, engineAPI]); + + // manage layer selection + useEffect(() => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + + const entity = findEntity(viewer, selectedLayerId); + if (viewer.selectedEntity === entity) return; + + const tag = getTag(entity); + if (tag?.unselectable) return; + + viewer.selectedEntity = entity; + }, [cesium, selectedLayerId]); + + const handleMouseEvent = useCallback( + (type: keyof MouseEvents, e: CesiumMovementEvent, target: RootEventTarget) => { + if (engineAPI.mouseEventCallbacks[type]) { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const position = e.position || e.startPosition; + const props: MouseEvent = { + x: position?.x, + y: position?.y, + ...(position + ? getLocationFromScreen(viewer.scene, position.x, position.y, true) ?? {} + : {}), + }; + const layerId = getLayerId(target); + if (layerId) props.layerId = layerId; + engineAPI.mouseEventCallbacks[type]?.(props); + } + }, + [engineAPI], + ); + + const handleMouseWheel = useCallback( + (delta: number) => { + engineAPI.mouseEventCallbacks.wheel?.({ delta }); + }, + [engineAPI], + ); + + const mouseEventHandles = useMemo(() => { + const mouseEvents: { [index in keyof MouseEvents]: undefined | any } = { + click: undefined, + doubleclick: undefined, + mousedown: undefined, + mouseup: undefined, + rightclick: undefined, + rightdown: undefined, + rightup: undefined, + middleclick: undefined, + middledown: undefined, + middleup: undefined, + mousemove: undefined, + mouseenter: undefined, + mouseleave: undefined, + wheel: undefined, + }; + (Object.keys(mouseEvents) as (keyof MouseEvents)[]).forEach(type => { + mouseEvents[type] = + type === "wheel" + ? (delta: number) => { + handleMouseWheel(delta); + } + : (e: CesiumMovementEvent, target: RootEventTarget) => { + handleMouseEvent(type as keyof MouseEvents, e, target); + }; + }); + return mouseEvents; + }, [handleMouseEvent, handleMouseWheel]); + + const handleClick = useCallback( + (_: CesiumMovementEvent, target: RootEventTarget) => { + mouseEventHandles.click?.(_, target); + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + + if (target && "id" in target && target.id instanceof Entity && isSelectable(target.id)) { + onLayerSelect?.(target.id.id); + return; + } + + if (target && target instanceof Cesium3DTileFeature) { + const layerId: string | undefined = (target.primitive as any)?.[layerIdField]; + if (layerId) { + onLayerSelect?.(layerId, { + overriddenInfobox: { + title: target.getProperty("name"), + content: tileProperties(target), + }, + }); + } + return; + } + + onLayerSelect?.(); + }, + [onLayerSelect, mouseEventHandles], + ); + + // E2E test + useEffect(() => { + if (e2eAccessToken()) { + setE2ECesiumViewer(cesium.current?.cesiumElement); + return () => { + setE2ECesiumViewer(undefined); + }; + } + return; + }, [cesium.current?.cesiumElement]); + + // update + useEffect(() => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + viewer.scene.requestRender(); + }); + + // enable Drag and Drop Layers + const handleLayerDrag = useCallback( + (e: Entity, position: Cartesian3 | undefined, _context: Context): boolean | void => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !isSelectable(e) || !isDraggable(e)) return false; + + const pos = convertCartesian3ToPosition(cesium.current?.cesiumElement, position); + if (!pos) return false; + + onLayerDrag?.(e.id, pos); + }, + [onLayerDrag], + ); + + const handleLayerDrop = useCallback( + (e: Entity, position: Cartesian3 | undefined): boolean | void => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return false; + + const key = isDraggable(e); + const pos = convertCartesian3ToPosition(cesium.current?.cesiumElement, position); + onLayerDrop?.(e.id, key || "", pos); + + return false; // let apollo-client handle optimistic updates + }, + [onLayerDrop], + ); + + const cesiumDnD = useRef(); + useEffect(() => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + cesiumDnD.current = new CesiumDnD(viewer, { + onDrag: handleLayerDrag, + onDrop: handleLayerDrop, + dragDelay: 1000, + initialDisabled: !isLayerDraggable, + }); + return () => { + if (!viewer || viewer.isDestroyed()) return; + cesiumDnD.current?.disable(); + }; + }, [handleLayerDrag, handleLayerDrop, isLayerDraggable]); + const { cameraViewBoundaries, cameraViewOuterBoundaries, cameraViewBoundariesMaterial } = + useCameraLimiter(cesium, camera, property?.cameraLimiter); + + const context = useMemo( + () => ({ + selectionReason, + flyTo: engineAPI.flyTo, + getCamera: engineAPI.getCamera, + }), + [selectionReason, engineAPI], + ); + + return { + backgroundColor, + cesium, + cameraViewBoundaries, + cameraViewOuterBoundaries, + cameraViewBoundariesMaterial, + cesiumIonAccessToken, + mouseEventHandles, + handleMount, + handleUnmount, + handleClick, + handleCameraChange, + handleCameraMoveEnd, + context, + }; +}; + +function tileProperties(t: Cesium3DTileFeature): { key: string; value: any }[] { + return t + .getPropertyIds() + .reduce<{ key: string; value: any }[]>( + (a, b) => [...a, { key: b, value: t.getProperty(b) }], + [], + ); +} + +function findEntity(viewer: CesiumViewer, layerId: string | undefined): Entity | undefined { + if (!layerId) return; + + let entity = viewer.entities.getById(layerId); + if (entity) return entity; + + entity = viewer.entities.values.find(e => getTag(e)?.layerId === layerId); + if (entity) return entity; + + for (const ds of [viewer.dataSourceDisplay.dataSources, viewer.dataSources]) { + for (let i = 0; i < ds.length; i++) { + const entities = ds.get(i).entities.values; + const e = entities.find(e => getTag(e)?.layerId === layerId); + if (e) { + return e; + } + } + } + + return; +} + +function getLayerId(target: RootEventTarget): string | undefined { + if (target && "id" in target && target.id instanceof Entity) { + return getTag(target.id)?.layerId; + } else if (target && target instanceof Cesium3DTileFeature) { + return getTag(target.tileset)?.layerId; + } + return undefined; +} diff --git a/src/core/engines/Cesium/index.stories.tsx b/src/core/engines/Cesium/index.stories.tsx new file mode 100644 index 000000000..177cb84c2 --- /dev/null +++ b/src/core/engines/Cesium/index.stories.tsx @@ -0,0 +1,43 @@ +import { Meta, Story } from "@storybook/react"; + +import Component, { Props } from "../../Map"; + +import { engine } from "."; + +export default { + title: "core/engines/Cesium", + component: Component, + parameters: { actions: { argTypesRegex: "^on.*" } }, +} as Meta; + +const Template: Story = args => ; + +export const Default = Template.bind([]); +Default.args = { + engine: "cesium", + engines: { + cesium: engine, + }, + ready: true, + layers: [ + { + id: "l", + type: "simple", + data: { + type: "geojson", + value: { type: "Feature", geometry: { type: "Point", coordinates: [0, 0] } }, + }, + marker: { + imageColor: "#fff", + }, + }, + ], + property: { + tiles: [ + { + id: "default", + tile_type: "default", + }, + ], + }, +}; diff --git a/src/core/engines/Cesium/index.tsx b/src/core/engines/Cesium/index.tsx new file mode 100644 index 000000000..200e39b01 --- /dev/null +++ b/src/core/engines/Cesium/index.tsx @@ -0,0 +1,197 @@ +import { ArcType, Color, ScreenSpaceEventType } from "cesium"; +import React, { forwardRef } from "react"; +import { + Viewer, + Fog, + Sun, + SkyAtmosphere, + Scene, + SkyBox, + Camera, + ScreenSpaceEventHandler, + ScreenSpaceEvent, + ScreenSpaceCameraController, + Entity, + PolylineGraphics, +} from "resium"; + +import type { Engine, EngineProps, EngineRef } from ".."; + +import Cluster from "./Cluster"; +import Clock from "./core/Clock"; +import Globe from "./core/Globe"; +import ImageryLayers from "./core/Imagery"; +import Indicator from "./core/Indicator"; +import Event from "./Event"; +import Feature, { context as featureContext } from "./Feature"; +import useHooks from "./hooks"; + +const Cesium: React.ForwardRefRenderFunction = ( + { + className, + style, + property, + camera, + small, + ready, + children, + selectedLayerId, + selectionReason, + isLayerDraggable, + isLayerDragging, + shouldRender, + meta, + onLayerSelect, + onCameraChange, + onTick, + onLayerDrag, + onLayerDrop, + }, + ref, +) => { + const { + backgroundColor, + cesium, + cameraViewBoundaries, + cameraViewOuterBoundaries, + cameraViewBoundariesMaterial, + mouseEventHandles, + cesiumIonAccessToken, + handleMount, + handleUnmount, + handleClick, + handleCameraChange, + handleCameraMoveEnd, + context, + } = useHooks({ + ref, + property, + camera, + selectedLayerId, + selectionReason, + isLayerDraggable, + meta, + onLayerSelect, + onCameraChange, + onTick, + onLayerDrag, + onLayerDrop, + }); + + return ( + + + + + + + + + + {/* remove default click event */} + + {/* remove default double click event */} + + + + + {cameraViewBoundaries && property?.cameraLimiter?.cameraLimitterShowHelper && ( + + + + )} + {cameraViewOuterBoundaries && property?.cameraLimiter?.cameraLimitterShowHelper && ( + + + + )} + + + + + + + {ready ? children : null} + + + ); +}; + +const creditContainer = document.createElement("div"); + +const Component = forwardRef(Cesium); + +export default Component; + +export const engine: Engine = { + component: Component, + featureComponent: Feature, + clusterComponent: Cluster, + // delegatedDataTypes: ["3dtiles", "czml"], +}; diff --git a/src/core/engines/Cesium/useEngineRef.test.tsx b/src/core/engines/Cesium/useEngineRef.test.tsx new file mode 100644 index 000000000..4b3eb8e9f --- /dev/null +++ b/src/core/engines/Cesium/useEngineRef.test.tsx @@ -0,0 +1,492 @@ +import { renderHook } from "@testing-library/react"; +import { JulianDate, Viewer as CesiumViewer, Cartesian3, Globe, Ellipsoid, Matrix4 } from "cesium"; +import { useRef } from "react"; +import type { CesiumComponentRef } from "resium"; +import { vi, expect, test, afterEach } from "vitest"; + +import type { EngineRef, Clock } from ".."; + +import useEngineRef from "./useEngineRef"; + +vi.mock("./common", async () => { + const commons: Record = await vi.importActual("./common"); + return { + ...commons, + zoom: vi.fn(), + getCenterCamera: vi.fn(({ scene }) => scene?.globe?.pick?.()), + }; +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +test("engine should be cesium", () => { + const { result } = renderHook(() => { + const cesium = useRef>(null); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + expect(result.current.current?.name).toBe("cesium"); +}); + +test("bind mouse events", () => { + const mockMouseEventCallback = vi.fn(e => e); + const props = { x: 1, y: 1 }; + const { result } = renderHook(() => { + const cesium = useRef>(null); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + result.current.current?.onClick(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.click).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.click?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(1); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onDoubleClick(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.doubleclick).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.doubleclick?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(2); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMouseDown(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.mousedown).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.mousedown?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(3); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMouseUp(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.mouseup).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.mouseup?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(4); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onRightClick(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.rightclick).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.rightclick?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(5); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onRightDown(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.rightdown).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.rightdown?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(6); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onRightUp(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.rightup).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.rightup?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(7); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMiddleClick(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.middleclick).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.middleclick?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(8); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMiddleDown(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.middledown).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.middledown?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(9); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMiddleUp(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.middleup).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.middleup?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(10); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMouseMove(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.mousemove).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.mousemove?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(11); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMouseEnter(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.mouseenter).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.mouseenter?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(12); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onMouseLeave(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.mouseleave).toBe(mockMouseEventCallback); + + result.current.current?.mouseEventCallbacks.mouseleave?.(props); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(13); + expect(mockMouseEventCallback).toHaveBeenCalledWith(props); + + result.current.current?.onWheel(mockMouseEventCallback); + expect(result.current.current?.mouseEventCallbacks.wheel).toBe(mockMouseEventCallback); + + const wheelProps = { delta: 1 }; + result.current.current?.mouseEventCallbacks.wheel?.(wheelProps); + expect(mockMouseEventCallback).toHaveBeenCalledTimes(14); + expect(mockMouseEventCallback).toHaveBeenCalledWith(wheelProps); +}); + +const mockRequestRender = vi.fn(); +test("requestRender", () => { + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + scene: { + requestRender: mockRequestRender, + }, + isDestroyed: () => { + return false; + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + result.current.current?.requestRender(); + expect(mockRequestRender).toHaveBeenCalledTimes(1); +}); + +test("zoom", async () => { + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + scene: { + camera: {}, + }, + isDestroyed: () => { + return false; + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + const commons = await import("./common"); + + result.current.current?.zoomIn(10); + expect(commons.zoom).toHaveBeenCalledTimes(1); + expect(commons.zoom).toHaveBeenCalledWith( + { + camera: {}, + scene: { camera: {} }, + relativeAmount: 0.1, + }, + undefined, + ); + + result.current.current?.zoomOut(20); + expect(commons.zoom).toHaveBeenCalledTimes(2); + expect(commons.zoom).toHaveBeenCalledWith( + { + camera: {}, + scene: { camera: {} }, + relativeAmount: 20, + }, + undefined, + ); +}); + +test("call orbit when camera focuses on center", async () => { + const { result } = renderHook(() => { + const cesiumElement = { + scene: { + camera: { lookAtTransform: vi.fn(), rotateLeft: vi.fn(), rotateUp: vi.fn(), look: vi.fn() }, + globe: { + ellipsoid: new Ellipsoid(), + pick: () => new Cartesian3(), + }, + }, + transform: new Matrix4(), + positionWC: new Cartesian3(), + isDestroyed: () => { + return false; + }, + } as any; + const cesium = useRef>({ + cesiumElement, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return [engineRef, cesium] as const; + }); + + const commons = await import("./common"); + + const [engineRef, cesium] = result.current; + + engineRef.current?.orbit(90); + expect(commons.getCenterCamera).toHaveBeenCalled(); + expect(cesium.current.cesiumElement?.scene.camera.rotateLeft).toHaveBeenCalled(); + expect(cesium.current.cesiumElement?.scene.camera.rotateUp).toHaveBeenCalled(); + expect(cesium.current.cesiumElement?.scene.camera.lookAtTransform).toHaveBeenCalledTimes(2); +}); + +test("call orbit when camera does not focus on center", async () => { + const { result } = renderHook(() => { + const cesiumElement = { + scene: { + camera: { + lookAtTransform: vi.fn(), + rotateLeft: vi.fn(), + rotateUp: vi.fn(), + look: vi.fn(), + positionWC: new Cartesian3(), + }, + globe: { + ellipsoid: new Ellipsoid(), + pick: () => undefined, + }, + }, + transform: new Matrix4(), + isDestroyed: () => { + return false; + }, + } as any; + const cesium = useRef>({ + cesiumElement, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return [engineRef, cesium] as const; + }); + + const commons = await import("./common"); + + const [engineRef, cesium] = result.current; + + engineRef.current?.orbit(90); + expect(commons.getCenterCamera).toHaveBeenCalled(); + expect(cesium.current.cesiumElement?.scene.camera.look).toHaveBeenCalledTimes(2); + expect(cesium.current.cesiumElement?.scene.camera.lookAtTransform).toHaveBeenCalledTimes(2); +}); + +test("rotateRight", async () => { + const { result } = renderHook(() => { + const cesiumElement = { + scene: { + camera: { + lookAtTransform: vi.fn(), + rotateRight: vi.fn(), + positionWC: new Cartesian3(), + }, + globe: { + ellipsoid: new Ellipsoid(), + }, + }, + transform: new Matrix4(), + isDestroyed: () => { + return false; + }, + } as any; + const cesium = useRef>({ + cesiumElement, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return [engineRef, cesium] as const; + }); + + const [engineRef, cesium] = result.current; + + engineRef.current?.rotateRight(90); + expect(cesium.current.cesiumElement?.scene.camera.rotateRight).toHaveBeenCalled(); + expect(cesium.current.cesiumElement?.scene.camera.lookAtTransform).toHaveBeenCalledTimes(2); +}); + +test("getClock", () => { + const startTime = JulianDate.fromIso8601("2022-01-11"); + const stopTime = JulianDate.fromIso8601("2022-01-15"); + const currentTime = JulianDate.fromIso8601("2022-01-14"); + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + clock: { + startTime, + stopTime, + currentTime, + shouldAnimate: false, + multiplier: 1, + }, + isDestroyed: () => { + return false; + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return { engineRef, cesium }; + }); + + const actual = result.current.engineRef.current?.getClock(); + expect(actual).toEqual({ + start: JulianDate.toDate(startTime), + stop: JulianDate.toDate(stopTime), + current: JulianDate.toDate(currentTime), + playing: false, + speed: 1, + }); +}); + +test("captureScreen", () => { + const mockViewerRender = vi.fn(); + const mockCanvasToDataURL = vi.fn(); + const mockViewerIsDestroyed = vi.fn(() => false); + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + render: mockViewerRender, + isDestroyed: mockViewerIsDestroyed, + canvas: { + toDataURL: mockCanvasToDataURL, + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + result.current.current?.captureScreen(); + expect(mockViewerRender).toHaveBeenCalledTimes(1); + expect(mockCanvasToDataURL).toHaveBeenCalledTimes(1); + + result.current.current?.captureScreen("image/jpeg", 0.8); + expect(mockCanvasToDataURL).toHaveBeenCalledWith("image/jpeg", 0.8); +}); + +test("move", () => { + const mockMove = vi.fn(e => e); + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + isDestroyed: () => false, + scene: { + camera: { + position: new Cartesian3(0, 0, 1), + direction: Cartesian3.clone(Cartesian3.UNIT_X), + up: Cartesian3.clone(Cartesian3.UNIT_Z), + right: Cartesian3.clone(Cartesian3.UNIT_Y), + move: mockMove, + }, + globe: new Globe(Ellipsoid.UNIT_SPHERE), + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + result.current.current?.moveForward(100); + expect(mockMove).toHaveBeenCalledTimes(1); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(1, 0, 0), 100); + + result.current.current?.moveBackward(100); + expect(mockMove).toHaveBeenCalledTimes(2); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(1, 0, 0), -100); + + result.current.current?.moveUp(100); + expect(mockMove).toHaveBeenCalledTimes(3); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(0, 0, 1), 100); + + result.current.current?.moveDown(100); + expect(mockMove).toHaveBeenCalledTimes(4); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(0, 0, 1), -100); + + result.current.current?.moveRight(100); + expect(mockMove).toHaveBeenCalledTimes(5); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(0, 1, 0), 100); + + result.current.current?.moveLeft(100); + expect(mockMove).toHaveBeenCalledTimes(6); + expect(mockMove).toHaveBeenLastCalledWith(new Cartesian3(0, 1, 0), -100); +}); + +test("look", () => { + const mockLook = vi.fn(e => e); + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + isDestroyed: () => false, + scene: { + camera: { + position: new Cartesian3(0, 0, 1), + direction: Cartesian3.clone(Cartesian3.UNIT_X), + up: Cartesian3.clone(Cartesian3.UNIT_Z), + right: Cartesian3.clone(Cartesian3.UNIT_Y), + look: mockLook, + }, + globe: new Globe(Ellipsoid.UNIT_SPHERE), + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + result.current.current?.lookHorizontal(90); + expect(mockLook).toHaveBeenCalledTimes(1); + expect(mockLook).toHaveBeenLastCalledWith(new Cartesian3(0, 0, 1), 90); + + result.current.current?.lookVertical(90); + expect(mockLook).toHaveBeenCalledTimes(2); + expect(mockLook).toHaveBeenLastCalledWith(new Cartesian3(0, 1, 0), 90); +}); + +test("get location from screen xy", () => { + const mockGetPickRay = vi.fn(() => true); + const mockPickEllipsoid = vi.fn(() => + Cartesian3.fromDegrees(137, 40, 0, new Globe(Ellipsoid.WGS84).ellipsoid), + ); + const mockPick = vi.fn(() => + Cartesian3.fromDegrees(110, 20, 10000, new Globe(Ellipsoid.WGS84).ellipsoid), + ); + const { result } = renderHook(() => { + const cesium = useRef>({ + cesiumElement: { + isDestroyed: () => false, + scene: { + camera: { + getPickRay: mockGetPickRay, + pickEllipsoid: mockPickEllipsoid, + }, + globe: { + ellipsoid: new Globe(Ellipsoid.WGS84).ellipsoid, + pick: mockPick, + }, + }, + } as any, + }); + const engineRef = useRef(null); + useEngineRef(engineRef, cesium); + return engineRef; + }); + + const location = result.current.current?.getLocationFromScreen(0, 0); + expect(location?.lng).toBeCloseTo(137, 0); + expect(location?.lat).toBeCloseTo(40, 0); + expect(location?.height).toBeCloseTo(0, 0); + const location2 = result.current.current?.getLocationFromScreen(0, 0, true); + expect(location2?.lng).toBeCloseTo(110, 0); + expect(location2?.lat).toBeCloseTo(20, 0); + expect(location2?.height).toBeCloseTo(10000, 0); +}); diff --git a/src/core/engines/Cesium/useEngineRef.ts b/src/core/engines/Cesium/useEngineRef.ts new file mode 100644 index 000000000..04cc8a980 --- /dev/null +++ b/src/core/engines/Cesium/useEngineRef.ts @@ -0,0 +1,319 @@ +import * as Cesium from "cesium"; +import { Math as CesiumMath } from "cesium"; +import { useImperativeHandle, Ref, RefObject, useMemo, useRef } from "react"; +import { CesiumComponentRef } from "resium"; + +import type { EngineRef, MouseEvents, MouseEvent } from ".."; + +import { + getLocationFromScreen, + flyTo, + lookAt, + getCamera, + getClock, + lookHorizontal, + lookVertical, + moveForward, + moveBackward, + moveUp, + moveDown, + moveLeft, + moveRight, + moveOverTerrain, + flyToGround, + getCenterCamera, + zoom, + lookAtWithoutAnimation, +} from "./common"; + +export default function useEngineRef( + ref: Ref, + cesium: RefObject>, +): EngineRef { + const cancelCameraFlight = useRef<() => void>(); + const mouseEventCallbacks = useRef({ + click: undefined, + doubleclick: undefined, + mousedown: undefined, + mouseup: undefined, + rightclick: undefined, + rightdown: undefined, + rightup: undefined, + middleclick: undefined, + middledown: undefined, + middleup: undefined, + mousemove: undefined, + mouseenter: undefined, + mouseleave: undefined, + wheel: undefined, + }); + const e = useMemo((): EngineRef => { + return { + name: "cesium", + requestRender: () => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + viewer.scene?.requestRender(); + }, + getCamera: () => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + return getCamera(viewer); + }, + getLocationFromScreen: (x, y, withTerrain) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + return getLocationFromScreen(viewer.scene, x, y, withTerrain); + }, + flyTo: (camera, options) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + cancelCameraFlight.current?.(); + cancelCameraFlight.current = flyTo( + viewer.scene?.camera, + { ...getCamera(viewer), ...camera }, + options, + ); + }, + lookAt: (camera, options) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + if (options?.withoutAnimation) { + return lookAtWithoutAnimation(viewer.scene, { ...getCamera(viewer), ...camera }); + } + cancelCameraFlight.current?.(); + cancelCameraFlight.current = lookAt( + viewer.scene?.camera, + { ...getCamera(viewer), ...camera }, + options, + ); + }, + lookAtLayer: layerId => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const e = viewer.entities.getById(layerId); + if (!e) return; + const entityPos = e.position?.getValue(viewer.clock.currentTime); + if (!entityPos) return; + const cameraPos = viewer.camera.positionWC; + const distance = Cesium.Cartesian3.distance(entityPos, cameraPos); + if (Math.round(distance * 1000) / 1000 === 5000) return; + const camera = getCamera(viewer); + const offset = new Cesium.HeadingPitchRange( + camera?.heading ?? 0, + camera?.pitch ?? -90, + 5000, + ); + viewer.zoomTo(e, offset); + }, + getViewport: () => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const rect = viewer.camera.computeViewRectangle(); + return rect + ? { + north: CesiumMath.toDegrees(rect.north), + south: CesiumMath.toDegrees(rect.south), + west: CesiumMath.toDegrees(rect.west), + east: CesiumMath.toDegrees(rect.east), + } + : undefined; + }, + zoomIn: (amount, options) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const scene = viewer.scene; + const camera = scene.camera; + zoom({ camera, scene, relativeAmount: 1 / amount }, options); + }, + zoomOut: (amount, options) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const scene = viewer.scene; + const camera = scene.camera; + zoom({ camera, scene, relativeAmount: amount }, options); + }, + orbit: radian => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const scene = viewer.scene; + const camera = scene.camera; + + const distance = 0.02; + const angle = radian + CesiumMath.PI_OVER_TWO; + + const x = Math.cos(angle) * distance; + const y = Math.sin(angle) * distance; + + const oldTransform = Cesium.Matrix4.clone(camera.transform); + + const center = getCenterCamera({ camera, scene }); + // Get fixed frame from center to globe ellipsoid. + const frame = Cesium.Transforms.eastNorthUpToFixedFrame( + center || camera.positionWC, + scene.globe.ellipsoid, + ); + + camera.lookAtTransform(frame); + + if (center) { + camera.rotateLeft(x); + camera.rotateUp(y); + } else { + camera.look(Cesium.Cartesian3.UNIT_Z, x); + camera.look(camera.right, y); + } + camera.lookAtTransform(oldTransform); + }, + rotateRight: radian => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + const scene = viewer.scene; + const camera = scene.camera; + const oldTransform = Cesium.Matrix4.clone(camera.transform); + const frame = Cesium.Transforms.eastNorthUpToFixedFrame( + camera.positionWC, + scene.globe.ellipsoid, + ); + camera.lookAtTransform(frame); + camera.rotateRight(radian - -camera.heading); + camera.lookAtTransform(oldTransform); + }, + changeSceneMode: (sceneMode, duration = 2) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene) return; + switch (sceneMode) { + case "2d": + viewer?.scene?.morphTo2D(duration); + break; + case "columbus": + viewer?.scene?.morphToColumbusView(duration); + break; + case "3d": + default: + viewer?.scene?.morphTo3D(duration); + break; + } + }, + getClock: () => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.clock) return; + const clock: Cesium.Clock = viewer.clock; + return getClock(clock); + }, + captureScreen: (type?: string, encoderOptions?: number) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + viewer.render(); + return viewer.canvas.toDataURL(type, encoderOptions); + }, + enableScreenSpaceCameraController: (enabled = true) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene) return; + const enable = !!enabled; + viewer.scene.screenSpaceCameraController.enableRotate = enable; + viewer.scene.screenSpaceCameraController.enableTranslate = enable; + viewer.scene.screenSpaceCameraController.enableZoom = enable; + viewer.scene.screenSpaceCameraController.enableTilt = enable; + viewer.scene.screenSpaceCameraController.enableLook = enable; + }, + lookHorizontal: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + lookHorizontal(viewer.scene, amount); + }, + lookVertical: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + lookVertical(viewer.scene, amount); + }, + moveForward: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveForward(viewer.scene, amount); + }, + moveBackward: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveBackward(viewer.scene, amount); + }, + moveUp: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveUp(viewer.scene, amount); + }, + moveDown: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveDown(viewer.scene, amount); + }, + moveLeft: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveLeft(viewer.scene, amount); + }, + moveRight: amount => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed() || !viewer.scene || !amount) return; + moveRight(viewer.scene, amount); + }, + moveOverTerrain: async offset => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + moveOverTerrain(viewer, offset); + }, + flyToGround: async (camera, options, offset) => { + const viewer = cesium.current?.cesiumElement; + if (!viewer || viewer.isDestroyed()) return; + flyToGround(viewer, cancelCameraFlight, camera, options, offset); + }, + onClick: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.click = cb; + }, + onDoubleClick: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.doubleclick = cb; + }, + onMouseDown: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.mousedown = cb; + }, + onMouseUp: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.mouseup = cb; + }, + onRightClick: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.rightclick = cb; + }, + onRightDown: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.rightdown = cb; + }, + onRightUp: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.rightup = cb; + }, + onMiddleClick: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.middleclick = cb; + }, + onMiddleDown: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.middledown = cb; + }, + onMiddleUp: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.middleup = cb; + }, + onMouseMove: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.mousemove = cb; + }, + onMouseEnter: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.mouseenter = cb; + }, + onMouseLeave: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.mouseleave = cb; + }, + onWheel: (cb: ((props: MouseEvent) => void) | undefined) => { + mouseEventCallbacks.current.wheel = cb; + }, + mouseEventCallbacks: mouseEventCallbacks.current, + }; + }, [cesium]); + + useImperativeHandle(ref, () => e, [e]); + + return e; +} diff --git a/src/core/engines/Cesium/utils.ts b/src/core/engines/Cesium/utils.ts new file mode 100644 index 000000000..9b41d4843 --- /dev/null +++ b/src/core/engines/Cesium/utils.ts @@ -0,0 +1,37 @@ +import { + Cartesian3, + Viewer as CesiumViewer, + Math as CesiumMath, + TranslationRotationScale, + Cartographic, +} from "cesium"; + +export const convertCartesian3ToPosition = ( + cesium?: CesiumViewer, + pos?: Cartesian3, +): { lat: number; lng: number; height: number } | undefined => { + if (!pos) return; + const cartographic = cesium?.scene.globe.ellipsoid.cartesianToCartographic(pos); + if (!cartographic) return; + return { + lat: CesiumMath.toDegrees(cartographic.latitude), + lng: CesiumMath.toDegrees(cartographic.longitude), + height: cartographic.height, + }; +}; + +export const translationWithClamping = ( + trs: TranslationRotationScale, + allowEnterGround: boolean, + terrainHeightEstimate: number, +) => { + if (!allowEnterGround) { + const cartographic = Cartographic.fromCartesian(trs.translation, undefined, new Cartographic()); + const boxBottomHeight = cartographic.height - trs.scale.z / 2; + const floorHeight = terrainHeightEstimate; + if (boxBottomHeight < floorHeight) { + cartographic.height += floorHeight - boxBottomHeight; + Cartographic.toCartesian(cartographic, undefined, trs.translation); + } + } +}; diff --git a/src/core/engines/index.ts b/src/core/engines/index.ts new file mode 100644 index 000000000..b7a4d5b85 --- /dev/null +++ b/src/core/engines/index.ts @@ -0,0 +1,33 @@ +export type { + FeatureComponentProps, + Engine, + EngineComponent, + EngineRef, + EngineProps, + SelectLayerOptions, + SceneProperty, + MouseEvent, + MouseEvents, + ComputedFeature, + ComputedLayer, + Geometry, + AppearanceTypes, + Camera, + LatLng, + ClusterProperty, + ClusterComponentProps, + CameraOptions, + Clock, + FlyToDestination, + TerrainProperty, +} from "../Map"; +export type { + Cesium3DTilesAppearance, + EllipsoidAppearance, + PolygonAppearance, + PolylineAppearance, + MarkerAppearance, + ModelAppearance, + LegacyPhotooverlayAppearance, + LegacyResourceAppearance, +} from "../mantle"; diff --git a/src/core/mantle/README.md b/src/core/mantle/README.md new file mode 100644 index 000000000..01e93807d --- /dev/null +++ b/src/core/mantle/README.md @@ -0,0 +1,7 @@ +# @reearth/core/mantle + +This library provides React hooks and components for following functionality: + +- Fetching various data and convert them to features +- Evaluating Re:Earth Style Language expressions +- Compatibility with conventional layer system diff --git a/src/core/mantle/atoms/cache.test.ts b/src/core/mantle/atoms/cache.test.ts new file mode 100644 index 000000000..6fea1cf03 --- /dev/null +++ b/src/core/mantle/atoms/cache.test.ts @@ -0,0 +1,61 @@ +import { renderHook, act } from "@testing-library/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useMemo } from "react"; +import { test, expect } from "vitest"; + +import { cacheAtom, doubleKeyCacheAtom } from "./cache"; + +test("cacheAtom", () => { + const { result } = renderHook(() => { + const atoms = useMemo(() => cacheAtom(), []); + const get = useAtomValue(atoms.get); + const set = useSetAtom(atoms.set); + return { get, set }; + }); + + expect(result.current.get("test")).toBeUndefined(); + + act(() => { + result.current.set({ key: "test", value: "aaaa" }); + }); + + expect(result.current.get("test")).toBe("aaaa"); + + act(() => { + result.current.set({ key: "test" }); + }); + + expect(result.current.get("test")).toBeUndefined(); +}); + +test("doubleKeyCacheAtom", () => { + const { result } = renderHook(() => { + const atoms = useMemo(() => doubleKeyCacheAtom(), []); + const get = useAtomValue(atoms.get); + const getAll = useAtomValue(atoms.getAll); + const set = useSetAtom(atoms.set); + return { get, set, getAll }; + }); + + expect(result.current.get("test", "a")).toBeUndefined(); + expect(result.current.get("test", "b")).toBeUndefined(); + expect(result.current.get("test", "c")).toBeUndefined(); + + act(() => { + result.current.set({ key: "test", key2: "a", value: "aaaa" }); + result.current.set({ key: "test", key2: "c", value: "ccc" }); + }); + + expect(result.current.get("test", "a")).toBe("aaaa"); + expect(result.current.get("test", "b")).toBeUndefined(); + expect(result.current.get("test", "c")).toBe("ccc"); + expect(result.current.getAll("test")).toEqual(["aaaa", "ccc"]); + + act(() => { + result.current.set({ key: "test", key2: "a" }); + }); + + expect(result.current.get("test", "a")).toBeUndefined(); + expect(result.current.get("test", "b")).toBeUndefined(); + expect(result.current.get("test", "c")).toBe("ccc"); +}); diff --git a/src/core/mantle/atoms/cache.ts b/src/core/mantle/atoms/cache.ts new file mode 100644 index 000000000..b3733d657 --- /dev/null +++ b/src/core/mantle/atoms/cache.ts @@ -0,0 +1,55 @@ +import { atom } from "jotai"; + +export function cacheAtom() { + const map = atom | undefined>(undefined); + + const get = atom(get => (key: K) => get(map)?.get(key)); + + const set = atom(null, (get, set, value: { key: K; value?: T }) => { + const m = get(map) ?? new Map(); + if (typeof value.value === "undefined") { + m.delete(value.key); + } else { + m.set(value.key, value.value); + } + set(map, m); + }); + + return { get, set }; +} + +export function doubleKeyCacheAtom() { + const map = atom> | undefined>(undefined); + + const get = atom(get => (key: K, key2: L) => get(map)?.get(key)?.get(key2)); + + const getAll = atom(get => (key: K) => { + const m = get(map)?.get(key); + if (!m) return undefined; + + const res: T[] = []; + for (const k of m.keys()) { + const v = m.get(k); + if (typeof v === "undefined") continue; + res.push(v); + } + return res; + }); + + const set = atom(null, (get, set, value: { key: K; key2: L; value?: T }) => { + const m: Map> = get(map) ?? new Map(); + const n = m.get(value.key) ?? new Map(); + if (typeof value.value === "undefined") { + n?.delete(value.key2); + if (n?.size === 0) { + m.delete(value.key); + } + } else { + n.set(value.key2, value.value); + m.set(value.key, n); + } + set(map, m); + }); + + return { get, set, getAll }; +} diff --git a/src/core/mantle/atoms/compute.test.ts b/src/core/mantle/atoms/compute.test.ts new file mode 100644 index 000000000..9f0bd2960 --- /dev/null +++ b/src/core/mantle/atoms/compute.test.ts @@ -0,0 +1,201 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useAtom } from "jotai"; +import { useMemo } from "react"; +import { test, expect, vi } from "vitest"; + +import { fetchData } from "../data"; +import { EvalContext, evalLayer } from "../evaluator"; +import type { Data, DataRange, Feature, Layer, LayerSimple } from "../types"; + +import { doubleKeyCacheAtom } from "./cache"; +import { computeAtom } from "./compute"; + +const data: Data = { type: "geojson", url: "https://example.com/example.geojson" }; +const range: DataRange = { x: 0, y: 0, z: 0 }; +const layer: Layer = { id: "xxx", type: "simple", data }; +const features: Feature[] = [{ id: "a", geometry: { type: "Point", coordinates: [0, 0] } }]; +const features2: Feature[] = [ + { + id: "b", + geometry: { type: "Point", coordinates: [0, 0] }, + range, + }, +]; + +test("computeAtom", async () => { + const { result } = renderHook(() => { + const atoms = useMemo(() => computeAtom(doubleKeyCacheAtom()), []); + const [result, set] = useAtom(atoms); + return { result, set }; + }); + + expect(result.current.result).toBeUndefined(); + + act(() => { + result.current.set({ type: "setLayer", layer: { id: "xxx", type: "simple" } }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer: { id: "xxx", type: "simple" }, + status: "ready", + features: [], + originalFeatures: [], + }); + + // set a layer with delegatedDataTypes + act(() => { + result.current.set({ type: "updateDelegatedDataTypes", delegatedDataTypes: ["geojson"] }); + }); + + act(() => { + result.current.set({ type: "setLayer", layer }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features: [], + originalFeatures: [], + }), + await waitFor(() => + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features: [], + originalFeatures: [], + }), + ); + + // delete delegatedDataTypes + act(() => { + result.current.set({ type: "updateDelegatedDataTypes", delegatedDataTypes: [] }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "fetching", + features: [], + originalFeatures: [], + }); + + await waitFor(() => + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features, + originalFeatures: features, + }), + ); + + // reset a layer + act(() => { + result.current.set({ type: "setLayer", layer }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "fetching", + features, + originalFeatures: features, + }); + + await waitFor(() => { + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features, + originalFeatures: features, + }); + }); + + // write features + act(() => { + result.current.set({ type: "writeFeatures", features: features2 }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "fetching", + features, + originalFeatures: [...features, ...features2], + }); + + await waitFor(() => + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features: [...features, ...features2], + originalFeatures: [...features, ...features2], + }), + ); + + // override appearances + act(() => { + result.current.set({ + type: "override", + overrides: { + marker: { pointColor: "red" }, + hogehoge: { foobar: 1 }, // invalid appearance + }, + }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features: [...features, ...features2].map(f => ({ ...f, marker: { pointColor: "red" } })), + originalFeatures: [...features, ...features2], + }); + + // delete a feature + act(() => { + result.current.set({ type: "override" }); + result.current.set({ type: "deleteFeatures", features: ["b"] }); + }); + + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "fetching", + features: [...features, ...features2], + originalFeatures: features, + }); + + await waitFor(() => + expect(result.current.result).toEqual({ + id: "xxx", + layer, + status: "ready", + features, + originalFeatures: features, + }), + ); + + // delete a layer + act(() => { + result.current.set({ type: "setLayer" }); + }); + + expect(result.current.result).toBeUndefined(); +}); + +vi.mock("../evaluator", (): { evalLayer: typeof evalLayer } => ({ + evalLayer: async (layer: LayerSimple, ctx: EvalContext) => { + if (!layer.data) return { layer: {}, features: undefined }; + return { layer: {}, features: await ctx.getAllFeatures(layer.data) }; + }, +})); + +vi.mock("../data", (): { fetchData: typeof fetchData } => ({ + fetchData: async () => features, +})); diff --git a/src/core/mantle/atoms/compute.ts b/src/core/mantle/atoms/compute.ts new file mode 100644 index 000000000..3223e0462 --- /dev/null +++ b/src/core/mantle/atoms/compute.ts @@ -0,0 +1,183 @@ +import { atom } from "jotai"; +import { merge, pick } from "lodash-es"; + +import { evalLayer, EvalResult } from "../evaluator"; +import type { + ComputedFeature, + ComputedLayer, + ComputedLayerStatus, + Data, + DataRange, + DataType, + Feature, + Layer, +} from "../types"; +import { appearanceKeys } from "../types"; + +import { dataAtom, globalDataFeaturesCache } from "./data"; + +export type Atom = ReturnType; + +export type Command = + | { type: "setLayer"; layer?: Layer } + | { type: "requestFetch"; range: DataRange } + | { type: "writeFeatures"; features: Feature[] } + | { type: "deleteFeatures"; features: string[] } + | { type: "override"; overrides?: Record } + | { type: "updateDelegatedDataTypes"; delegatedDataTypes: DataType[] }; + +export function computeAtom(cache?: typeof globalDataFeaturesCache) { + const delegatedDataTypes = atom([]); + const updateDelegatedDataTypes = atom(null, async (_, set, value: DataType[]) => { + set(delegatedDataTypes, value); + await set(compute, undefined); + }); + + const layer = atom(undefined); + const overrides = atom | undefined, Record | undefined>( + undefined, + (_, set, value) => { + set(overrides, pick(value, appearanceKeys)); + }, + ); + + const computedResult = atom(undefined); + const finalFeatures = atom(get => + get(computedResult)?.features?.map((f): ComputedFeature => merge({ ...f }, get(overrides))), + ); + const layerStatus = atom("fetching"); + const dataAtoms = dataAtom(cache); + + const get = atom((get): ComputedLayer | undefined => { + const currentLayer = get(layer); + if (!currentLayer) return; + + return { + id: currentLayer.id, + layer: currentLayer, + status: get(layerStatus), + features: get(finalFeatures) ?? [], + originalFeatures: + currentLayer.type === "simple" && currentLayer.data + ? get(dataAtoms.getAll)(currentLayer.id, currentLayer.data)?.flat() ?? [] + : [], + ...get(computedResult)?.layer, + }; + }); + + const compute = atom(null, async (get, set, _: any) => { + const currentLayer = get(layer); + if (!currentLayer) return; + + if ( + currentLayer.type !== "simple" || + !currentLayer.data || + get(delegatedDataTypes).includes(currentLayer.data.type) + ) { + set(layerStatus, "ready"); + return; + } + + set(layerStatus, "fetching"); + + const layerId = currentLayer.id; + + // Used for a simple layer. + // It retrieves all features for the layer stored in the cache, + // but attempts to retrieve data from the network if the main feature is not yet in the cache. + const getAllFeatures = async (data: Data) => { + const getterAll = get(dataAtoms.getAll); + + // Ignore cache if data is embedded + if (!data.value) { + const allFeatures = getterAll(layerId, data); + if (allFeatures) return allFeatures.flat(); + } + + await set(dataAtoms.fetch, { data, layerId }); + return getterAll(layerId, data)?.flat() ?? []; + }; + + // Used for a flow layer. + // Retrieves and returns only a specific range of features from the cache. + // If it is not stored in the cache, it attempts to retrieve the data from the network. + const getFeatures = async (data: Data, range?: DataRange) => { + const getter = get(dataAtoms.get); + + // Ignore cache if data is embedded + if (!data.value) { + const c = getter(layerId, data, range); + if (c) return c; + } + + await set(dataAtoms.fetch, { data, range, layerId }); + return getter(layerId, data, range); + }; + + const result = await evalLayer(currentLayer, { getFeatures, getAllFeatures }); + set(layerStatus, "ready"); + set(computedResult, result); + }); + + const set = atom(null, async (_get, set, value: Layer | undefined) => { + set(layer, value); + await set(compute, undefined); + }); + + const requestFetch = atom(null, async (get, set, value: DataRange) => { + const currentLayer = get(layer); + if (currentLayer?.type !== "simple" || !currentLayer.data) return; + + await set(dataAtoms.fetch, { data: currentLayer.data, range: value, layerId: currentLayer.id }); + }); + + const writeFeatures = atom(null, async (get, set, value: Feature[]) => { + const currentLayer = get(layer); + if (currentLayer?.type !== "simple" || !currentLayer.data) return; + + set(dataAtoms.set, { + data: currentLayer.data, + features: value, + layerId: currentLayer.id, + }); + await set(compute, undefined); + }); + + const deleteFeatures = atom(null, async (get, set, value: string[]) => { + const currentLayer = get(layer); + if (currentLayer?.type !== "simple" || !currentLayer?.data) return; + + set(dataAtoms.deleteAll, { + data: currentLayer.data, + features: value, + layerId: currentLayer.id, + }); + await set(compute, undefined); + }); + + return atom( + g => g(get), + async (_, s, value) => { + switch (value.type) { + case "setLayer": + await s(set, value.layer); + break; + case "requestFetch": + await s(requestFetch, value.range); + break; + case "writeFeatures": + await s(writeFeatures, value.features); + break; + case "deleteFeatures": + await s(deleteFeatures, value.features); + break; + case "override": + await s(overrides, value.overrides); + break; + case "updateDelegatedDataTypes": + await s(updateDelegatedDataTypes, value.delegatedDataTypes); + break; + } + }, + ); +} diff --git a/src/core/mantle/atoms/data.test.ts b/src/core/mantle/atoms/data.test.ts new file mode 100644 index 000000000..5b20e0581 --- /dev/null +++ b/src/core/mantle/atoms/data.test.ts @@ -0,0 +1,176 @@ +import { renderHook, act, waitFor } from "@testing-library/react"; +import { useAtomValue, useSetAtom } from "jotai"; +import { useMemo } from "react"; +import { test, expect, vi } from "vitest"; + +import { fetchData } from "../data"; +import type { Data, DataRange, Feature } from "../types"; + +import { doubleKeyCacheAtom } from "./cache"; +import { dataAtom } from "./data"; + +const data: Data = { type: "geojson", url: "https://example.com/example.geojson" }; +const range: DataRange = { x: 0, y: 0, z: 0 }; +const features: Feature[] = [{ id: "a", geometry: { type: "Point", coordinates: [0, 0] } }]; +const features2: Feature[] = [{ id: "a", geometry: { type: "Point", coordinates: [0, 0] }, range }]; +const features3: Feature[] = [ + { id: "a", geometry: { type: "Point", coordinates: [0, 0] }, range }, + { id: "b", geometry: { type: "Point", coordinates: [0, 0] }, range }, + { id: "c", geometry: { type: "Point", coordinates: [0, 0] }, range }, +]; + +test("dataAtom set", () => { + const layerId = "x"; + + const { result } = renderHook(() => { + const atoms = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const get = useAtomValue(atoms.get); + const getAll = useAtomValue(atoms.getAll); + const set = useSetAtom(atoms.set); + return { get, set, getAll }; + }); + + expect(result.current.get(layerId, data)).toBeUndefined(); + + act(() => { + result.current.set({ layerId, data, features }); + }); + + expect(result.current.get(layerId, data)).toEqual(features); + expect(result.current.getAll(layerId, data)).toEqual([features]); + expect(result.current.get(layerId, data, range)).toBeUndefined(); + + act(() => { + result.current.set({ layerId, data, features: features2 }); + }); + + expect(result.current.get(layerId, data, range)).toEqual(features2); + expect(result.current.getAll(layerId, data)).toEqual([features, features2]); +}); + +test("dataAtom fetch", async () => { + const layerId = "x"; + + const { result } = renderHook(() => { + const atoms = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const get = useAtomValue(atoms.get); + const getAll = useAtomValue(atoms.getAll); + const fetch = useSetAtom(atoms.fetch); + return { get, fetch, getAll }; + }); + + expect(result.current.get(layerId, data)).toBeUndefined(); + expect(result.current.getAll(layerId, data)).toBeUndefined(); + + act(() => { + result.current.fetch({ layerId, data }); + }); + + await waitFor(() => expect(result.current.get(layerId, data)).toEqual(features)); + expect(result.current.get(layerId, data, range)).toBeUndefined(); + expect(result.current.getAll(layerId, data)).toEqual([features]); +}); + +test("dataAtom deleteAll", async () => { + const layerId = "x"; + + const { result } = renderHook(() => { + const atoms = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const get = useAtomValue(atoms.get); + const getAll = useAtomValue(atoms.getAll); + const set = useSetAtom(atoms.set); + const deleteAll = useSetAtom(atoms.deleteAll); + return { get, getAll, set, deleteAll }; + }); + + act(() => { + result.current.set({ layerId, data, features: features3 }); + }); + + await waitFor(() => expect(result.current.get(layerId, data, range)).toEqual(features3)); + + act(() => { + result.current.deleteAll({ layerId, data, features: ["b"] }); + }); + + await waitFor(() => + expect(result.current.get(layerId, data, range)).toEqual([features3[0], features3[2]]), + ); +}); + +test("dataAtom double fetch", async () => { + const layerId = "x"; + + const { result } = renderHook(() => { + const atoms1 = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const atoms2 = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const getAll1 = useAtomValue(atoms1.getAll); + const fetch1 = useSetAtom(atoms1.fetch); + const getAll2 = useAtomValue(atoms2.getAll); + const fetch2 = useSetAtom(atoms2.fetch); + return { fetch1, getAll1, fetch2, getAll2 }; + }); + + const { fetchData } = await import("../data"); + + expect(result.current.getAll1(layerId, data)).toBeUndefined(); + expect(result.current.getAll2(layerId, data)).toBeUndefined(); + expect(fetchData).toBeCalledTimes(1); + + act(() => { + result.current.fetch1({ layerId, data }); + result.current.fetch2({ layerId, data }); + }); + + await waitFor(() => expect(result.current.getAll1(layerId, data)).toEqual([features])); + await waitFor(() => expect(result.current.getAll2(layerId, data)).toEqual([features])); + expect(fetchData).toBeCalledTimes(3); +}); + +test("data.value is present", async () => { + const layerId = "x"; + const data1: Data = { + type: "geojson", + value: { id: "f", type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } }, + }; + const data2: Data = { + type: "geojson", + value: { id: "g", type: "Feature", geometry: { type: "Point", coordinates: [2, 3] } }, + }; + + const { result } = renderHook(() => { + const atoms = useMemo(() => dataAtom(doubleKeyCacheAtom()), []); + const getAll = useAtomValue(atoms.getAll); + const fetch = useSetAtom(atoms.fetch); + return { fetch, getAll }; + }); + + expect(result.current.getAll(layerId, data1)).toBeUndefined(); + + act(() => { + result.current.fetch({ layerId, data: data1 }); + }); + + await waitFor(() => + expect(result.current.getAll(layerId, data1)).toEqual([ + [{ id: "f", geometry: { type: "Point", coordinates: [1, 2] } }], + ]), + ); + + act(() => { + console.log("ACT"); + result.current.fetch({ layerId, data: data2 }); + }); + + await waitFor(() => + expect(result.current.getAll(layerId, data1)).toEqual([ + [{ id: "g", geometry: { type: "Point", coordinates: [2, 3] } }], + ]), + ); +}); + +vi.mock("../data", (): { fetchData: typeof fetchData } => ({ + fetchData: vi.fn(async data => + data.value ? [{ id: data.value.id, geometry: data.value.geometry }] : features, + ), +})); diff --git a/src/core/mantle/atoms/data.ts b/src/core/mantle/atoms/data.ts new file mode 100644 index 000000000..5cfe75a09 --- /dev/null +++ b/src/core/mantle/atoms/data.ts @@ -0,0 +1,98 @@ +import { atom } from "jotai"; +import { groupBy } from "lodash-es"; + +import { fetchData } from "../data"; +import type { Feature, Data, DataRange } from "../types"; + +import { doubleKeyCacheAtom } from "./cache"; + +export const globalDataFeaturesCache = doubleKeyCacheAtom(); + +export function dataAtom(cacheAtoms = globalDataFeaturesCache) { + const fetching = atom<[string, string][]>([]); + + const get = atom( + get => (layerId: string, data: Data, range?: DataRange) => + get(cacheAtoms.get)(dataKey(layerId, data), rangeKey(range)), + ); + + const getAll = atom( + get => (layerId: string, data: Data) => get(cacheAtoms.getAll)(dataKey(layerId, data)), + ); + + const set = atom( + null, + async (_get, set, value: { data: Data; features: Feature[]; layerId: string }) => { + Object.entries(groupBy(value.features, f => rangeKey(f.range))).forEach(([k, v]) => { + set(cacheAtoms.set, { + key: dataKey(value.layerId, value.data), + key2: k, + value: v, + }); + }); + }, + ); + + const deleteAll = atom( + null, + async (get, set, value: { data: Data; features: string[]; layerId: string }) => { + const d = dataKey(value.layerId, value.data); + Object.entries( + groupBy( + get(getAll)(value.layerId, value.data) + ?.filter(f => f.length) + .map( + f => + [rangeKey(f[0].range), f, f.filter(g => !value.features.includes(g.id))] as const, + ) + .filter(f => f[1].length !== f[2].length), + g => g[0], + ), + ).forEach(([k, f]) => { + set(cacheAtoms.set, { + key: d, + key2: k, + value: f.flatMap(g => g[2]), + }); + }); + }, + ); + + const fetch = atom( + null, + async (get, set, value: { data: Data; range?: DataRange; layerId: string }) => { + const k = dataKey(value.layerId, value.data); + if (!k) return; + + const rk = rangeKey(value.range); + const cached = !value.data.value && value.data.url ? get(cacheAtoms.get)(k, rk) : undefined; + if (cached || get(fetching).findIndex(e => e[0] === k && e[1] === rk) >= 0) return; + + try { + set(fetching, f => [...f, [k, rk]]); + const features = await fetchData(value.data, value.range); + if (features) { + set(cacheAtoms.set, { key: k, key2: rk, value: features }); + } + } finally { + set(fetching, f => f.filter(e => e[0] !== k || e[1] !== rk)); + } + }, + ); + + return { + get, + getAll, + set, + fetch, + deleteAll, + }; +} + +function dataKey(layerId: string, data: Data): string { + return !data.value && data.url ? `${data.type}:${data.url}` : `layer:${layerId}`; +} + +function rangeKey(range?: DataRange): string { + return range ? `${range.x}:${range.y}:${range.z}` : ""; +} diff --git a/src/core/mantle/atoms/index.ts b/src/core/mantle/atoms/index.ts new file mode 100644 index 000000000..475935e7d --- /dev/null +++ b/src/core/mantle/atoms/index.ts @@ -0,0 +1 @@ +export { computeAtom, type Atom } from "./compute"; diff --git a/src/core/mantle/compat/backward.test.ts b/src/core/mantle/compat/backward.test.ts new file mode 100644 index 000000000..8238d30ac --- /dev/null +++ b/src/core/mantle/compat/backward.test.ts @@ -0,0 +1,366 @@ +import { expect, test } from "vitest"; + +import { convertLayer, getCompat } from "./backward"; +import type { Infobox, Tag } from "./types"; + +const infobox: Infobox = { blocks: [], property: { default: { bgcolor: "red" } } }; +const tags: Tag[] = [{ id: "x", label: "x" }]; + +test("group", () => { + expect( + convertLayer({ + id: "xxx", + type: "group", + children: [ + { + id: "yyy", + type: "group", + children: [], + }, + ], + visible: true, + compat: { + property: { a: 1 }, + propertyId: "p", + extensionId: "hoge", + }, + title: "title", + creator: "creator", + infobox, + tags, + }), + ).toEqual({ + id: "xxx", + children: [ + { + id: "yyy", + children: [], + }, + ], + title: "title", + isVisible: true, + creator: "creator", + infobox, + tags, + pluginId: "reearth", + extensionId: "hoge", + property: { a: 1 }, + propertyId: "p", + }); +}); + +test("item", () => { + expect( + convertLayer({ + id: "xxx", + type: "simple", + visible: true, + compat: { + property: { a: 1 }, + propertyId: "p", + extensionId: "hoge", + }, + title: "title", + creator: "creator", + infobox, + tags, + }), + ).toEqual({ + id: "xxx", + isVisible: true, + title: "title", + creator: "creator", + infobox, + tags, + pluginId: "reearth", + extensionId: "hoge", + property: { a: 1 }, + propertyId: "p", + }); +}); + +test("getCompat", () => { + expect(getCompat(undefined)).toBeUndefined(); + expect(getCompat({ type: "aaa" } as any)).toBeUndefined(); +}); + +test("marker", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [1, 2, 3], + }, + }, + }, + marker: { + pointColor: "red", + }, + compat: { + propertyId: "p", + }, + }), + ).toEqual({ + extensionId: "marker", + propertyId: "p", + property: { + default: { + location: { lat: 2, lng: 1 }, + height: 3, + pointColor: "red", + }, + }, + }); +}); + +test("polyline", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [1, 2, 3], + [2, 3, 4], + ], + }, + }, + }, + polyline: { + strokeColor: "red", + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "polyline", + propertyId: "p", + property: { + default: { + coordinates: [ + { lat: 2, lng: 1, height: 3 }, + { lat: 3, lng: 2, height: 4 }, + ], + strokeColor: "red", + }, + }, + }); +}); + +test("polygon", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [1, 2, 3], + [2, 3, 4], + ], + ], + }, + }, + }, + polygon: { + fillColor: "red", + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "polygon", + propertyId: "p", + property: { + default: { + polygon: [ + [ + { lat: 2, lng: 1, height: 3 }, + { lat: 3, lng: 2, height: 4 }, + ], + ], + fillColor: "red", + }, + }, + }); +}); + +test("photooverlay", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [1, 2, 3], + }, + }, + }, + photooverlay: { + imageSize: 1, + }, + compat: { + propertyId: "p", + }, + }), + ).toEqual({ + extensionId: "photooverlay", + propertyId: "p", + property: { + default: { + location: { lat: 2, lng: 1 }, + height: 3, + imageSize: 1, + }, + }, + }); +}); + +test("ellipsoid", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [1, 2, 3], + }, + }, + }, + ellipsoid: { + radii: 100, + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "ellipsoid", + propertyId: "p", + property: { + default: { + position: { lat: 2, lng: 1 }, + height: 3, + radii: 100, + }, + }, + }); +}); + +test("model", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [1, 2, 3], + }, + }, + }, + model: { + model: "xxx", + color: "red", + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "model", + propertyId: "p", + property: { + default: { + location: { lat: 2, lng: 1 }, + height: 3, + model: "xxx", + }, + appearance: { + color: "red", + }, + }, + }); +}); + +test("3dtiles", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + data: { + type: "3dtiles", + url: "xxx", + }, + "3dtiles": { + aaaa: 1, + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "tileset", + propertyId: "p", + property: { + default: { + tileset: "xxx", + aaaa: 1, + }, + }, + }); +}); + +test("legacy_resource", () => { + expect( + getCompat({ + id: "xxx", + type: "simple", + legacy_resource: { + url: "xxx", + aaaa: 1, + }, + compat: { + propertyId: "p", + }, + } as any), + ).toEqual({ + extensionId: "resource", + propertyId: "p", + property: { + default: { + url: "xxx", + aaaa: 1, + }, + }, + }); +}); diff --git a/src/core/mantle/compat/backward.ts b/src/core/mantle/compat/backward.ts new file mode 100644 index 000000000..bd4bce1ec --- /dev/null +++ b/src/core/mantle/compat/backward.ts @@ -0,0 +1,189 @@ +import type { GeoJSON } from "geojson"; +import { omit, omitBy, pick } from "lodash-es"; + +import type { Layer, LayerCompat } from "../types"; +import { getCoord, getCoords, getGeom } from "../utils"; + +import type { LegacyLayer } from "."; + +export function convertLayer(l: Layer): LegacyLayer | undefined { + return convertLayerGroup(l) ?? convertLayerItem(l); +} + +function convertLayerCommon(l: Layer): any { + return omitBy( + { + id: l.id, + isVisible: l.visible, + title: l.title, + creator: l.creator, + infobox: l.infobox, + tags: l.tags, + property: l.compat?.property, + propertyId: l.compat?.propertyId, + pluginId: l.compat?.extensionId ? "reearth" : undefined, + extensionId: l.compat?.extensionId, + }, + v => typeof v === "undefined" || v === null, + ); +} + +function convertLayerGroup(l: Layer): LegacyLayer | undefined { + if (l.type !== "group") return; + + return { + ...convertLayerCommon(l), + children: l.children?.map(convertLayer).filter((l): l is LegacyLayer => !!l) ?? [], + }; +} + +function convertLayerItem(l: Layer): LegacyLayer | undefined { + if (l.type !== "simple") return; + + return convertLayerCommon(l); +} + +export function getCompat(l: Layer | undefined): LayerCompat | undefined { + if (!l || typeof l !== "object" || l.type !== "simple") return; + + const data: GeoJSON | undefined = l.data?.type === "geojson" ? l.data.value : undefined; + + let property: any; + let extensionId: string | undefined; + + if ("marker" in l) { + const coord = getCoord(data); + extensionId = "marker"; + property = { + default: { + ...l.marker, + ...(coord && coord.length >= 2 + ? { + location: { lng: coord[0], lat: coord[1] }, + height: coord[2], + } + : {}), + }, + }; + } else if ("polyline" in l) { + const coords = getCoords(data); + extensionId = "polyline"; + property = { + default: { + ...(l as any).polyline, + ...(coords + ? { + coordinates: coords.map(c => ({ lng: c[0], lat: c[1], height: c[2] })), + } + : {}), + }, + }; + } else if ("polygon" in l) { + // rect is also included in polygon + const geo = getGeom(data); + extensionId = "polygon"; + property = { + default: { + ...(l as any).polygon, + ...(geo?.type === "Polygon" + ? { + polygon: geo.coordinates.map(coords => + coords.map(c => ({ lng: c[0], lat: c[1], height: c[2] })), + ), + } + : {}), + }, + }; + } else if ("photooverlay" in l) { + const coord = getCoord(data); + extensionId = "photooverlay"; + property = { + default: { + ...(l as any).photooverlay, + ...(coord && coord.length >= 2 + ? { + location: { lng: coord[0], lat: coord[1] }, + height: coord[2], + } + : {}), + }, + }; + } else if ("ellipsoid" in l) { + const coord = getCoord(data); + extensionId = "ellipsoid"; + property = { + default: { + ...(l as any).ellipsoid, + ...(coord && coord.length >= 2 + ? { + position: { lng: coord[0], lat: coord[1] }, + height: coord[2], + } + : {}), + }, + }; + } else if ("model" in l) { + const appearanceKeys = [ + "shadows", + "colorBlend", + "color", + "colorBlendAmount", + "lightColor", + "silhouette", + "silhouetteColor", + "silhouetteSize", + ]; + const m = omit((l as any).model, ...appearanceKeys); + const a = omitBy( + pick((l as any).model, ...appearanceKeys), + v => typeof v === "undefined" || v === null, + ); + const coord = getCoord(data); + extensionId = "model"; + property = { + default: { + ...m, + ...(coord && coord.length >= 2 + ? { + location: { lng: coord[0], lat: coord[1] }, + height: coord[2], + } + : {}), + }, + ...(Object.keys(a).length + ? { + appearance: a, + } + : {}), + }; + } else if ("3dtiles" in l) { + extensionId = "tileset"; + property = { + default: { + ...(l as any)["3dtiles"], + ...((l as any).data?.type === "3dtiles" && (l as any).data.url + ? { + tileset: (l as any).data.url, + } + : {}), + }, + }; + } else if ("legacy_resource" in l) { + extensionId = "resource"; + property = { + default: { + ...(l as any).legacy_resource, + }, + }; + } + + return { + extensionId, + property, + ...(l.compat?.propertyId + ? { + propertyId: l.compat?.propertyId, + } + : {}), + }; +} diff --git a/src/core/mantle/compat/forward.test.ts b/src/core/mantle/compat/forward.test.ts new file mode 100644 index 000000000..99fadb530 --- /dev/null +++ b/src/core/mantle/compat/forward.test.ts @@ -0,0 +1,490 @@ +import { expect, test } from "vitest"; + +import { convertLegacyLayer } from "./forward"; +import type { Infobox, Tag } from "./types"; + +const infobox: Infobox = { blocks: [], property: { default: { bgcolor: "red" } } }; +const tags: Tag[] = [{ id: "x", label: "x" }]; + +test("group", () => { + expect( + convertLegacyLayer({ + id: "xxx", + isVisible: true, + title: "title", + creator: "aaa", + infobox, + tags, + extensionId: "a", + propertyId: "p", + property: { a: 1 }, + children: [ + { + id: "yyy", + type: "group", + children: [], + }, + ], + }), + ).toEqual({ + id: "xxx", + type: "group", + title: "title", + visible: true, + compat: { + property: { a: 1 }, + extensionId: "a", + propertyId: "p", + }, + infobox, + tags, + creator: "aaa", + children: [ + { + id: "yyy", + type: "group", + children: [], + }, + ], + }); +}); + +test("marker", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "marker", + propertyId: "p", + isVisible: true, + property: { + default: { + location: { lat: 1, lng: 2 }, + height: 3, + pointColor: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [2, 1, 3], + }, + }, + }, + visible: true, + marker: { + pointColor: "red", + }, + compat: { + extensionId: "marker", + propertyId: "p", + property: { + default: { + location: { lat: 1, lng: 2 }, + height: 3, + pointColor: "red", + }, + }, + }, + }); +}); + +test("polyline", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "polyline", + propertyId: "p", + isVisible: true, + property: { + default: { + coordinates: [ + { lat: 1, lng: 2, height: 3 }, + { lat: 2, lng: 3, height: 4 }, + ], + strokeColor: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "LineString", + coordinates: [ + [2, 1, 3], + [3, 2, 4], + ], + }, + }, + }, + visible: true, + polyline: { + strokeColor: "red", + }, + compat: { + extensionId: "polyline", + propertyId: "p", + property: { + default: { + coordinates: [ + { lat: 1, lng: 2, height: 3 }, + { lat: 2, lng: 3, height: 4 }, + ], + strokeColor: "red", + }, + }, + }, + }); +}); + +test("polygon", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "polygon", + propertyId: "p", + isVisible: true, + property: { + default: { + polygon: [ + [ + { lat: 1, lng: 2, height: 3 }, + { lat: 2, lng: 3, height: 4 }, + ], + ], + strokeColor: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [2, 1, 3], + [3, 2, 4], + ], + ], + }, + }, + }, + visible: true, + polygon: { + strokeColor: "red", + }, + compat: { + extensionId: "polygon", + propertyId: "p", + property: { + default: { + polygon: [ + [ + { lat: 1, lng: 2, height: 3 }, + { lat: 2, lng: 3, height: 4 }, + ], + ], + strokeColor: "red", + }, + }, + }, + }); +}); + +test("rect", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "rect", + propertyId: "p", + isVisible: true, + property: { + default: { + rect: { + north: 1, + east: 2, + south: 3, + west: 4, + }, + height: 3, + strokeColor: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [4, 1, 3], + [2, 1, 3], + [2, 3, 3], + [4, 3, 3], + [4, 1, 3], + ], + ], + }, + }, + }, + visible: true, + polygon: { + strokeColor: "red", + }, + compat: { + extensionId: "rect", + propertyId: "p", + property: { + default: { + rect: { + north: 1, + east: 2, + south: 3, + west: 4, + }, + height: 3, + strokeColor: "red", + }, + }, + }, + }); +}); + +test("photooverlay", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "photooverlay", + propertyId: "p", + isVisible: true, + property: { + default: { + location: { lat: 1, lng: 2 }, + height: 3, + hoge: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + visible: true, + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [2, 1, 3], + }, + }, + }, + photooverlay: { + hoge: "red", + }, + compat: { + extensionId: "photooverlay", + propertyId: "p", + property: { + default: { + location: { lat: 1, lng: 2 }, + height: 3, + hoge: "red", + }, + }, + }, + }); +}); + +test("ellipsoid", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "ellipsoid", + propertyId: "p", + isVisible: true, + property: { + default: { + position: { lat: 1, lng: 2 }, + height: 3, + radii: 100, + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [2, 1, 3], + }, + }, + }, + visible: true, + ellipsoid: { + radii: 100, + }, + compat: { + extensionId: "ellipsoid", + propertyId: "p", + property: { + default: { + position: { lat: 1, lng: 2 }, + height: 3, + radii: 100, + }, + }, + }, + }); +}); + +test("model", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "model", + propertyId: "p", + isVisible: true, + property: { + default: { + model: "xxx", + location: { lat: 1, lng: 2 }, + height: 3, + }, + appearance: { + aaa: 1, + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + visible: true, + data: { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [2, 1, 3], + }, + }, + }, + model: { + model: "xxx", + aaa: 1, + }, + compat: { + extensionId: "model", + propertyId: "p", + property: { + default: { + model: "xxx", + location: { lat: 1, lng: 2 }, + height: 3, + }, + appearance: { + aaa: 1, + }, + }, + }, + }); +}); + +test("3dtiles", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "tileset", + propertyId: "p", + isVisible: true, + property: { + default: { + tileset: "xxx", + hoge: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + visible: true, + data: { + type: "3dtiles", + url: "xxx", + }, + "3dtiles": { + hoge: "red", + }, + compat: { + extensionId: "tileset", + propertyId: "p", + property: { + default: { + tileset: "xxx", + hoge: "red", + }, + }, + }, + }); +}); + +test("resource", () => { + expect( + convertLegacyLayer({ + id: "x", + extensionId: "resource", + propertyId: "p", + isVisible: true, + property: { + default: { + url: "xxx", + hoge: "red", + }, + }, + }), + ).toEqual({ + id: "x", + type: "simple", + visible: true, + legacy_resource: { + url: "xxx", + hoge: "red", + }, + compat: { + extensionId: "resource", + propertyId: "p", + property: { + default: { + url: "xxx", + hoge: "red", + }, + }, + }, + }); +}); diff --git a/src/core/mantle/compat/forward.ts b/src/core/mantle/compat/forward.ts new file mode 100644 index 000000000..b7462927a --- /dev/null +++ b/src/core/mantle/compat/forward.ts @@ -0,0 +1,219 @@ +import { Feature, LineString, Point, Polygon } from "geojson"; +import { omitBy } from "lodash-es"; + +import type { Data, Layer, LayerGroup, LayerSimple } from "../types"; + +import type { LegacyLayer } from "."; + +export function convertLegacyLayer(l: LegacyLayer | undefined): Layer | undefined { + return l ? convertLegacyLayerGroup(l) ?? convertLegacyLayerItem(l) : undefined; +} + +function convertLegacyLayerCommon(l: LegacyLayer): any { + const compat = omitBy( + { + extensionId: l.extensionId, + property: l.property, + propertyId: l.propertyId, + }, + v => typeof v === "undefined" || v === null, + ); + + return omitBy( + { + id: l.id, + title: l.title, + visible: l.isVisible, + creator: l.creator, + infobox: l.infobox, + tags: l.tags, + ...(Object.keys(compat).length ? { compat } : {}), + }, + v => typeof v === "undefined" || v === null, + ); +} + +function convertLegacyLayerGroup(l: LegacyLayer): LayerGroup | undefined { + if (!Array.isArray(l.children)) return; + + return { + type: "group", + ...convertLegacyLayerCommon(l), + children: l.children?.map(convertLegacyLayer).filter((l): l is Layer => !!l) ?? [], + }; +} + +function convertLegacyLayerItem(l: LegacyLayer): LayerSimple | undefined { + if (Array.isArray(l.children)) return; + + let appearance: string | undefined; + let data: Data | undefined; + let legacyPropertyKeys: string[] | undefined; + + if (l.extensionId === "marker") { + appearance = "marker"; + legacyPropertyKeys = ["location", "height"]; + if (l.property?.default?.location) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + l.property.default.location.lng, + l.property.default.location.lat, + ...(l.property.default.height ? [l.property.default.height] : []), + ], + }, + } as Feature, + }; + } + } else if (l.extensionId === "polyline") { + appearance = "polyline"; + legacyPropertyKeys = ["coordinates"]; + if (l.property?.default?.coordinates) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "LineString", + coordinates: l.property.default.coordinates.map((c: any) => [c.lng, c.lat, c.height]), + }, + } as Feature, + }; + } + } else if (l.extensionId === "polygon") { + appearance = "polygon"; + legacyPropertyKeys = ["polygon"]; + if (l.property?.default?.polygon) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: l.property.default.polygon.map((p: any) => + p.map((c: any) => [c.lng, c.lat, c.height]), + ), + }, + } as Feature, + }; + } + } else if (l.extensionId === "rect") { + appearance = "polygon"; + legacyPropertyKeys = ["rect", "height"]; + if (l.property?.default?.rect) { + const r = l.property?.default?.rect; + const h = l.property.default.height; + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Polygon", + coordinates: [ + [ + [r.west, r.north, h], + [r.east, r.north, h], + [r.east, r.south, h], + [r.west, r.south, h], + [r.west, r.north, h], + ], + ], + }, + } as Feature, + }; + } + } else if (l.extensionId === "photooverlay") { + appearance = "photooverlay"; + legacyPropertyKeys = ["location", "height"]; + if (l.property?.default?.location) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + l.property.default.location.lng, + l.property.default.location.lat, + ...(l.property.default.height ? [l.property.default.height] : []), + ], + }, + } as Feature, + }; + } + } else if (l.extensionId === "ellipsoid") { + appearance = "ellipsoid"; + legacyPropertyKeys = ["position", "height"]; + if (l.property?.default?.position) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + l.property.default.position.lng, + l.property.default.position.lat, + ...(typeof l.property.default.height === "number" ? [l.property.default.height] : []), + ], + }, + } as Feature, + }; + } + } else if (l.extensionId === "model") { + appearance = "model"; + legacyPropertyKeys = ["location", "height"]; + if (l.property?.default?.location) { + data = { + type: "geojson", + value: { + type: "Feature", + geometry: { + type: "Point", + coordinates: [ + l.property.default.location.lng, + l.property.default.location.lat, + ...(typeof l.property.default.height === "number" ? [l.property.default.height] : []), + ], + }, + } as Feature, + }; + } + } else if (l.extensionId === "tileset") { + appearance = "3dtiles"; + legacyPropertyKeys = ["tileset"]; + if (l.property?.default?.tileset) { + data = { + type: "3dtiles", + url: l.property.default.tileset, + }; + } + } else if (l.extensionId === "resource") { + appearance = "legacy_resource"; + } + + const property = appearance + ? Object.fromEntries( + Object.entries(l.property) + .flatMap(([k, v]): (readonly [PropertyKey, any])[] | undefined => { + if (Array.isArray(v) || k === "id" || !v || typeof v !== "object") return undefined; + return Object.entries(v).filter(([k]) => !legacyPropertyKeys?.includes(k)); + }) + .filter((p): p is readonly [PropertyKey, any] => !!p), + ) + : undefined; + + return omitBy( + { + type: "simple", + ...convertLegacyLayerCommon(l), + data, + ...(appearance && property ? { [appearance]: property } : {}), + }, + v => typeof v === "undefined" || v === null, + ) as any; +} diff --git a/src/core/mantle/compat/index.ts b/src/core/mantle/compat/index.ts new file mode 100644 index 000000000..667b49b05 --- /dev/null +++ b/src/core/mantle/compat/index.ts @@ -0,0 +1,4 @@ +export { convertLayer, getCompat } from "./backward"; +export { convertLegacyLayer } from "./forward"; + +export type { LegacyLayer } from "./types"; diff --git a/src/core/mantle/compat/types.ts b/src/core/mantle/compat/types.ts new file mode 100644 index 000000000..acdb53afb --- /dev/null +++ b/src/core/mantle/compat/types.ts @@ -0,0 +1,89 @@ +export type LegacyLayer

= { + id: string; + type?: string; + pluginId?: string; + extensionId?: string; + title?: string; + property?: P; + infobox?: Infobox; + isVisible?: boolean; + propertyId?: string; + tags?: Tag[]; + readonly children?: LegacyLayer[]; + creator?: string; +}; + +export type Tag = { + id: string; + label: string; + tags?: Tag[]; +}; + +export type Infobox = { + property?: InfoboxProperty; + blocks?: Block[]; +}; + +export type InfoboxProperty = { + default?: { + showTitle?: boolean; + title?: string; + height?: number; + heightType?: "auto" | "manual"; + infoboxPaddingTop?: number; + infoboxPaddingBottom?: number; + infoboxPaddingLeft?: number; + infoboxPaddingRight?: number; + size?: "small" | "medium" | "large"; + position?: "right" | "middle" | "left"; + typography?: Typography; + bgcolor?: string; + outlineColor?: string; + outlineWidth?: number; + useMask?: boolean; + }; +}; + +export type Block

= { + id: string; + pluginId?: string; + extensionId?: string; + property?: P; + propertyId?: string; +}; + +export type Rect = { + north: number; + south: number; + east: number; + west: number; +}; + +/** Represents the camera position and state */ +export type CameraPosition = { + /** degrees */ + lat: number; + /** degrees */ + lng: number; + /** meters */ + height: number; + /** radians */ + heading: number; + /** radians */ + pitch: number; + /** radians */ + roll: number; + /** Field of view expressed in radians */ + fov: number; +}; + +export type Typography = { + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + color?: string; + textAlign?: "left" | "center" | "right" | "justify" | "justify_all"; + bold?: boolean; + italic?: boolean; + underline?: boolean; +}; diff --git a/src/core/mantle/data/geojson.ts b/src/core/mantle/data/geojson.ts new file mode 100644 index 000000000..78e2e2920 --- /dev/null +++ b/src/core/mantle/data/geojson.ts @@ -0,0 +1,33 @@ +import type { GeoJSON } from "geojson"; + +import type { Data, DataRange, Feature } from "../types"; + +import { f, generateRandomString } from "./utils"; + +export async function fetchGeoJSON(data: Data, range?: DataRange): Promise { + const d = data.url ? await (await f(data.url)).json() : data.value; + return processGeoJSON(d, range); +} + +export function processGeoJSON(geojson: GeoJSON, range?: DataRange): Feature[] { + if (geojson.type === "FeatureCollection") { + return geojson.features.flatMap(f => processGeoJSON(f, range)); + } + + if (geojson.type === "Feature") { + const geo = geojson.geometry; + return [ + { + id: (geojson.id && String(geojson.id)) || generateRandomString(12), + geometry: + geo.type === "Point" || geo.type === "LineString" || geo.type === "Polygon" + ? geo + : undefined, + properties: geojson.properties, + range, + }, + ]; + } + + return []; +} diff --git a/src/core/mantle/data/index.ts b/src/core/mantle/data/index.ts new file mode 100644 index 000000000..cf31f8a60 --- /dev/null +++ b/src/core/mantle/data/index.ts @@ -0,0 +1,13 @@ +import type { Data, DataRange, Feature } from "../types"; + +import { fetchGeoJSON } from "./geojson"; + +export type DataFetcher = (data: Data, range?: DataRange) => Promise; + +const registry: Record = { + geojson: fetchGeoJSON, +}; + +export async function fetchData(data: Data, range?: DataRange): Promise { + return registry[data.type]?.(data, range); +} diff --git a/src/core/mantle/data/utils.ts b/src/core/mantle/data/utils.ts new file mode 100644 index 000000000..54bd3453b --- /dev/null +++ b/src/core/mantle/data/utils.ts @@ -0,0 +1,9 @@ +export { default as generateRandomString } from "@reearth/util/generate-random-string"; + +export async function f(url: string): Promise { + const res = await fetch(url); + if (res.status !== 200) { + throw new Error(`fetched ${url} but status code was ${res.status}`); + } + return res; +} diff --git a/src/core/mantle/evaluator/index.ts b/src/core/mantle/evaluator/index.ts new file mode 100644 index 000000000..281385d00 --- /dev/null +++ b/src/core/mantle/evaluator/index.ts @@ -0,0 +1,23 @@ +import { AppearanceTypes, ComputedFeature, Data, DataRange, Feature, LayerSimple } from "../types"; + +import { evalSimpleLayer } from "./simple"; + +export type EvalContext = { + getFeatures: (d: Data, r?: DataRange) => Promise; + getAllFeatures: (d: Data) => Promise; +}; + +export type EvalResult = { + features?: ComputedFeature[]; + layer: Partial; +}; + +export async function evalLayer( + layer: LayerSimple, + ctx: EvalContext, +): Promise { + if (layer.type === "simple") { + return evalSimpleLayer(layer, ctx); + } + return; +} diff --git a/src/core/mantle/evaluator/simple/index.test.ts b/src/core/mantle/evaluator/simple/index.test.ts new file mode 100644 index 000000000..9b9e6ec40 --- /dev/null +++ b/src/core/mantle/evaluator/simple/index.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "vitest"; + +import { evalLayerAppearances, evalSimpleLayer } from "."; + +test("evalLayerAppearances", async () => { + expect( + await evalSimpleLayer( + { + id: "x", + type: "simple", + data: { + type: "geojson", + }, + marker: { + pointColor: "red", + pointSize: { conditions: [["true", "1"]] }, + }, + }, + { + getAllFeatures: async () => [{ id: "a" }], + getFeatures: async () => undefined, + }, + ), + ).toEqual({ + layer: { + marker: { + pointColor: "red", + pointSize: undefined, + }, + }, + features: [ + { + id: "a", + marker: { + pointColor: "red", + pointSize: undefined, + }, + }, + ], + }); +}); + +test("evalLayerAppearances", () => { + expect( + evalLayerAppearances( + { + marker: { + pointColor: "red", + pointSize: { conditions: [["true", "1"]] }, + }, + }, + { + id: "x", + type: "simple", + }, + { + id: "y", + }, + ), + ).toEqual({ + marker: { + pointColor: "red", + pointSize: undefined, + }, + }); +}); diff --git a/src/core/mantle/evaluator/simple/index.ts b/src/core/mantle/evaluator/simple/index.ts new file mode 100644 index 000000000..df70e28b2 --- /dev/null +++ b/src/core/mantle/evaluator/simple/index.ts @@ -0,0 +1,48 @@ +import { pick } from "lodash-es"; + +import type { EvalContext, EvalResult } from ".."; +import { + appearanceKeys, + AppearanceTypes, + Expression, + Feature, + LayerAppearanceTypes, + LayerSimple, +} from "../../types"; + +export async function evalSimpleLayer( + layer: LayerSimple, + ctx: EvalContext, +): Promise { + const features = layer.data ? await ctx.getAllFeatures(layer.data) : undefined; + const appearances: Partial = pick(layer, appearanceKeys); + return { + layer: evalLayerAppearances(appearances, layer), + features: features?.map(f => ({ ...f, ...evalLayerAppearances(appearances, layer, f) })), + }; +} + +export function evalLayerAppearances( + appearance: Partial, + layer: LayerSimple, + feature?: Feature, +): Partial { + return Object.fromEntries( + Object.entries(appearance).map(([k, v]) => [ + k, + Object.fromEntries( + Object.entries(v).map(([k, v]) => { + if (typeof v === "object" && "conditions" in v) { + return [k, evalExpression(v, layer, feature)]; + } + return [k, v]; + }), + ), + ]), + ); +} + +function evalExpression(_e: Expression, _layer: LayerSimple, _feature?: Feature): unknown { + // TODO: eval + return undefined; +} diff --git a/src/core/mantle/index.ts b/src/core/mantle/index.ts new file mode 100644 index 000000000..ff4554d9c --- /dev/null +++ b/src/core/mantle/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./compat"; +export { computeAtom } from "./atoms"; +export type { Atom } from "./atoms"; diff --git a/src/core/mantle/types/appearance.ts b/src/core/mantle/types/appearance.ts new file mode 100644 index 000000000..b66d5c749 --- /dev/null +++ b/src/core/mantle/types/appearance.ts @@ -0,0 +1,149 @@ +import { objKeys } from "../utils"; + +import type { Camera, LatLng, Typography } from "./value"; + +export type LayerAppearance = { + [K in keyof T]?: T[K] | Expression; +}; + +export type Expression = { + conditions: [string, string][]; +}; + +export type LayerAppearanceTypes = { + [K in keyof AppearanceTypes]: LayerAppearance; +}; + +export type AppearanceTypes = { + marker: MarkerAppearance; + polyline: PolylineAppearance; + polygon: PolygonAppearance; + model: ModelAppearance; + "3dtiles": Cesium3DTilesAppearance; + ellipsoid: EllipsoidAppearance; + photooverlay: LegacyPhotooverlayAppearance; + legacy_resource: LegacyResourceAppearance; +}; + +export type MarkerAppearance = { + heightReference?: "none" | "clamp" | "relative"; + style?: "none" | "point" | "image"; + pointSize?: number; + pointColor?: string; + pointOutlineColor?: string; + pointOutlineWidth?: number; + image?: string; + imageSize?: number; + imageHorizontalOrigin?: "left" | "center" | "right"; + imageVerticalOrigin?: "top" | "center" | "baseline" | "bottom"; + imageColor?: string; + imageCrop?: "none" | "rounded" | "circle"; + imageShadow?: boolean; + imageShadowColor?: string; + imageShadowBlur?: number; + imageShadowPositionX?: number; + imageShadowPositionY?: number; + label?: boolean; + labelText?: string; + labelPosition?: + | "left" + | "right" + | "top" + | "bottom" + | "lefttop" + | "leftbottom" + | "righttop" + | "rightbottom"; + labelTypography?: Typography; + labelBackground?: boolean; + extrude?: boolean; +}; + +export type PolylineAppearance = { + clampToGround?: boolean; + strokeColor?: string; + strokeWidth?: number; + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only"; +}; + +export type PolygonAppearance = { + fill?: boolean; + fillColor?: string; + stroke?: boolean; + strokeColor?: string; + strokeWidth?: number; + heightReference?: "none" | "clamp" | "relative"; + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only"; +}; + +export type EllipsoidAppearance = { + heightReference?: "none" | "clamp" | "relative"; + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only"; + radius?: number; + fillColor?: string; +}; + +export type ModelAppearance = { + model?: string; + heightReference?: "none" | "clamp" | "relative"; + heading?: number; + pitch?: number; + roll?: number; + scale?: number; + maximumScale?: number; + minimumPixelSize?: number; + animation?: boolean; // default: true + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only"; + colorBlend?: "none" | "highlight" | "replace" | "mix"; + color?: string; + colorBlendAmount?: number; + lightColor?: string; + silhouette?: boolean; + silhouetteColor?: string; + silhouetteSize?: number; // default: 1 +}; + +export type Cesium3DTilesAppearance = { + sourceType?: "url" | "osm"; + tileset?: string; + styleUrl?: string; + shadows?: "disabled" | "enabled" | "cast_only" | "receive_only"; +}; + +export type LegacyPhotooverlayAppearance = { + location?: LatLng; + height?: number; + heightReference?: "none" | "clamp" | "relative"; + camera?: Camera; // You may also update the field name in storytelling widget + image?: string; + imageSize?: number; + imageHorizontalOrigin?: "left" | "center" | "right"; + imageVerticalOrigin?: "top" | "center" | "baseline" | "bottom"; + imageCrop?: "none" | "rounded" | "circle"; + imageShadow?: boolean; + imageShadowColor?: string; + imageShadowBlur?: number; + imageShadowPositionX?: number; + imageShadowPositionY?: number; + photoOverlayImage?: string; + photoOverlayDescription?: string; +}; + +export type LegacyResourceAppearance = { + url?: string; + type?: "geojson" | "kml" | "czml" | "auto"; + clampToGround?: boolean; +}; + +export const appearanceKeyObj: { [k in keyof AppearanceTypes]: 1 } = { + marker: 1, + polyline: 1, + polygon: 1, + ellipsoid: 1, + model: 1, + "3dtiles": 1, + photooverlay: 1, + legacy_resource: 1, +}; + +export const appearanceKeys = objKeys(appearanceKeyObj); diff --git a/src/core/mantle/types/index.ts b/src/core/mantle/types/index.ts new file mode 100644 index 000000000..5fc7f3453 --- /dev/null +++ b/src/core/mantle/types/index.ts @@ -0,0 +1,89 @@ +import type { LineString, Point, Polygon } from "geojson"; + +import type { Infobox, Block, Tag } from "../compat/types"; + +import type { AppearanceTypes, LayerAppearanceTypes } from "./appearance"; + +export * from "./appearance"; +export * from "./value"; + +// Layer + +export type Layer = LayerSimple | LayerGroup; + +export type LayerSimple = { + type: "simple"; + data?: Data; + properties?: any; +} & Partial & + LayerCommon; + +export type LayerGroup = { + type: "group"; + children: Layer[]; +} & LayerCommon; + +export type LayerCommon = { + id: string; + title?: string; + /** default is true */ + visible?: boolean; + infobox?: Infobox; + tags?: Tag[]; + creator?: string; + compat?: LayerCompat; +}; + +export type LayerCompat = { extensionId?: string; property?: any; propertyId?: string }; + +/** Same as a Layer, but its ID is unknown. */ +export type NaiveLayer = NaiveLayerSimple | NaiveLayerGroup; +export type NaiveLayerSimple = Omit & { infobox?: NaiveInfobox }; +export type NaiveLayerGroup = Omit & { + infobox?: NaiveInfobox; + children?: NaiveLayer[]; +}; +export type NaiveInfobox = Omit & { blocks?: NaiveBlock[] }; +export type NaiveBlock

= Omit, "id">; + +// Data + +export type Data = { + type: DataType; + url?: string; + value?: any; +}; + +export type DataRange = { + x: number; + y: number; + z: number; +}; + +export type DataType = "geojson" | "3dtiles"; + +// Feature + +export type Feature = { + id: string; + geometry?: Geometry; + properties?: any; + range?: DataRange; +}; + +export type Geometry = Point | LineString | Polygon; + +export type ComputedLayerStatus = "fetching" | "ready"; + +// Computed + +export type ComputedLayer = { + id: string; + status: ComputedLayerStatus; + layer: Layer; + originalFeatures: Feature[]; + features: ComputedFeature[]; + properties?: any; +} & Partial; + +export type ComputedFeature = Feature & Partial; diff --git a/src/core/mantle/types/value.ts b/src/core/mantle/types/value.ts new file mode 100644 index 000000000..6b7df5eb4 --- /dev/null +++ b/src/core/mantle/types/value.ts @@ -0,0 +1,219 @@ +import { Color } from "cesium"; + +import { ValueType as GQLValueType } from "@reearth/gql"; +import { css } from "@reearth/theme"; + +export type LatLng = { + lat: number; + lng: number; +}; + +export type LatLngHeight = { + lat: number; + lng: number; + height: number; +}; + +export type Camera = { + lat: number; + lng: number; + height: number; + heading: number; + pitch: number; + roll: number; + fov: number; +}; + +export type Typography = { + fontFamily?: string; + fontSize?: number; + fontWeight?: number; + color?: string; + textAlign?: "left" | "center" | "right" | "justify" | "justify_all"; + bold?: boolean; + italic?: boolean; + underline?: boolean; +}; + +export type Coordinates = LatLngHeight[]; + +export type Polygon = LatLngHeight[][]; + +export type Rect = { + west: number; + south: number; + east: number; + north: number; +}; + +// Ideal for plugin developers, but it's hard to implement it with Cesium +export type Plane = { + location: LatLngHeight; + width: number; + height: number; + length: number; + heading: number; + pitch: number; +}; + +// Familiar with Cesium +export type EXPERIMENTAL_clipping = { + planes?: { + normal: { + x: number; + y: number; + z: number; + }; + distance: number; + }[]; + location: LatLngHeight; + /** + * x-axis + */ + width?: number; + /** + * y-axis + */ + length?: number; + /** + * z-axis + */ + height?: number; + heading?: number; + pitch?: number; + roll?: number; +}; + +// Don't forget adding a new field to valueTypeMapper also! +export type ValueTypes = { + string: string; + number: number; + bool: boolean; + latlng: LatLng; + latlngheight: LatLngHeight; + url: string; + camera: Camera; + typography: Typography; + coordinates: Coordinates; + polygon: Polygon; + rect: Rect; + ref: string; + tiletype: string; +}; + +const valueTypeMapper: Partial> = { + [GQLValueType.Bool]: "bool", + [GQLValueType.Number]: "number", + [GQLValueType.String]: "string", + [GQLValueType.Url]: "url", + [GQLValueType.Latlng]: "latlng", + [GQLValueType.Latlngheight]: "latlngheight", + [GQLValueType.Camera]: "camera", + [GQLValueType.Typography]: "typography", + [GQLValueType.Coordinates]: "coordinates", + [GQLValueType.Polygon]: "polygon", + [GQLValueType.Rect]: "rect", + [GQLValueType.Ref]: "ref", +}; + +export type ValueType = keyof ValueTypes; + +export const valueFromGQL = (val: any, type: GQLValueType) => { + const t = valueTypeFromGQL(type); + if (typeof val === "undefined" || val === null || !t) { + return undefined; + } + const ok = typeof val !== "undefined" && val !== null; + let newVal = val; + if (t === "camera" && val && typeof val === "object" && "altitude" in val) { + newVal = { + ...val, + height: val.altitude, + }; + } + if ( + t === "typography" && + val && + typeof val === "object" && + "textAlign" in val && + typeof val.textAlign === "string" + ) { + newVal = { + ...val, + textAlign: val.textAlign.toLowerCase(), + }; + } + return { type: t, value: newVal ?? undefined, ok }; +}; + +export function valueToGQL( + val: ValueTypes[T] | null | undefined, + type: T, +): any { + if (type === "camera" && val && typeof val === "object" && "height" in val) { + return { + ...(val as any), + altitude: (val as any).height, + }; + } + return val ?? null; +} + +export const valueTypeFromGQL = (t: GQLValueType): ValueType | undefined => { + return valueTypeMapper[t]; +}; + +export const valueTypeToGQL = (t: ValueType): GQLValueType | undefined => { + return (Object.keys(valueTypeMapper) as GQLValueType[]).find(k => valueTypeMapper[k] === t); +}; + +export const toGQLSimpleValue = (v: unknown): string | number | boolean | undefined => { + return typeof v === "string" || typeof v === "number" || typeof v === "boolean" ? v : undefined; +}; + +export const getCSSFontFamily = (f?: string) => { + return !f + ? undefined + : f === "YuGothic" + ? `"游ゴシック体", YuGothic, "游ゴシック Medium", "Yu Gothic Medium", "游ゴシック", "Yu Gothic"` + : f; +}; + +export const toCSSFont = (t?: Typography, d?: Typography) => { + const ff = getCSSFontFamily(t?.fontFamily ?? d?.fontFamily) + ?.replace("'", '"') + .trim(); + return `${t?.italic ?? d?.italic ? "italic " : ""}${ + t?.bold ?? d?.bold ? "bold " : (t?.fontWeight ?? d?.fontWeight ?? "") + " " ?? "" + }${t?.fontSize ?? d?.fontSize ?? 16}px ${ + ff ? (ff.includes(`"`) ? ff : `"${ff}"`) : "sans-serif" + }`; +}; + +export const toTextDecoration = (t?: Typography) => (t?.underline ? "underline" : "none"); + +export const toColor = (c?: string) => { + if (!c || typeof c !== "string") return undefined; + + // support alpha + const m = c.match(/^#([A-Fa-f0-9]{6})([A-Fa-f0-9]{2})$|^#([A-Fa-f0-9]{3})([A-Fa-f0-9])$/); + if (!m) return Color.fromCssColorString(c); + + const alpha = parseInt(m[4] ? m[4].repeat(2) : m[2], 16) / 255; + return Color.fromCssColorString(`#${m[1] ?? m[3]}`).withAlpha(alpha); +}; + +export const typographyStyles = (t?: Typography) => { + if (!t) return null; + return css` + font: ${toCSSFont(t)}; + text-decoration: ${toTextDecoration(t)}; + color: ${t.color ?? null}; + text-align: ${t.textAlign ?? null}; + `; +}; + +export const zeroValues: { [key in ValueType]?: ValueTypes[ValueType] } = { + bool: false, + string: "", +}; diff --git a/src/core/mantle/utils.test.ts b/src/core/mantle/utils.test.ts new file mode 100644 index 000000000..29670b6ca --- /dev/null +++ b/src/core/mantle/utils.test.ts @@ -0,0 +1,74 @@ +import { expect, test } from "vitest"; + +import { getCoord, getCoords, getGeom } from "./utils"; + +test("getCoord", () => { + expect(getCoord("a")).toBeUndefined(); + expect(getCoord({ type: "Point", coordinates: [1, 2] })).toEqual([1, 2]); + expect(getCoord({ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } })).toEqual([ + 1, 2, + ]); + expect(getCoord({ type: "LineString", coordinates: [[1, 2]] })).toBeUndefined(); + expect(getCoord({ type: "Polygon", coordinates: [[[1, 2]]] })).toBeUndefined(); + expect( + getCoord({ + type: "FeatureCollection", + features: [{ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } }], + }), + ).toBeUndefined(); +}); + +test("getCoords", () => { + expect(getCoords("a")).toBeUndefined(); + expect(getCoords({ type: "Point", coordinates: [1, 2] })).toEqual([1, 2]); + expect(getCoords({ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } })).toEqual([ + 1, 2, + ]); + expect(getCoords({ type: "LineString", coordinates: [[1, 2]] })).toEqual([[1, 2]]); + expect( + getCoords({ + type: "Polygon", + coordinates: [ + [ + [1, 2], + [2, 3], + ], + ], + }), + ).toEqual([ + [ + [1, 2], + [2, 3], + ], + ]); + expect( + getCoords({ + type: "FeatureCollection", + features: [{ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } }], + }), + ).toBeUndefined(); +}); + +test("getGeom", () => { + expect(getGeom("a")).toBeUndefined(); + expect(getGeom({ type: "Point", coordinates: [1, 2] })).toEqual({ + type: "Point", + coordinates: [1, 2], + }); + expect(getGeom({ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } })).toEqual({ + type: "Point", + coordinates: [1, 2], + }); + expect( + getGeom({ + type: "GeometryCollection", + geometries: [{ type: "Point", coordinates: [1, 2] }], + }), + ).toBeUndefined(); + expect( + getGeom({ + type: "FeatureCollection", + features: [{ type: "Feature", geometry: { type: "Point", coordinates: [1, 2] } }], + }), + ).toBeUndefined(); +}); diff --git a/src/core/mantle/utils.ts b/src/core/mantle/utils.ts new file mode 100644 index 000000000..eb7ac0d7c --- /dev/null +++ b/src/core/mantle/utils.ts @@ -0,0 +1,42 @@ +import { getCoord as turfGetCoord, getCoords as turfGetCoords } from "@turf/turf"; +import type { Geometry, GeometryCollection } from "geojson"; + +export const getCoord = wrap(turfGetCoord); +export const getCoords = wrap(turfGetCoords); + +export const getGeom = (g: any): Exclude> | undefined => { + if (typeof g !== "object" || !g || !("type" in g)) return; + + if (g.type === "Feature") return getGeom(g.geometry); + + if ( + g.type !== "Point" && + g.type !== "MultiPoint" && + g.type !== "LineString" && + g.type !== "MultiLineString" && + g.type !== "Polygon" && + g.type !== "MultiPolygon" + ) + return; + + return g; +}; + +function wrap(f: (d: any) => T): (d: any) => T | undefined { + return (d: any) => { + try { + return f(d); + } catch { + return; + } + }; +} + +/** + * Often we want to make an array of keys of an object type, + * but if we just specify the key names directly, we may forget to change the array if the object type is changed. + * With this function, the compiler checks the object keys for completeness, so the array of keys is always up to date. + */ +export function objKeys(obj: { [k in T]: 1 }): T[] { + return Object.keys(obj) as T[]; +} diff --git a/yarn.lock b/yarn.lock index 412bca149..b2e03de1b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3507,6 +3507,1143 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== +"@turf/along@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/along/-/along-6.5.0.tgz#ab12eec58a14de60fe243a62d31a474f415c8fef" + integrity sha512-LLyWQ0AARqJCmMcIEAXF4GEu8usmd4Kbz3qk1Oy5HoRNpZX47+i5exQtmIWKdqJ1MMhW26fCTXgpsEs5zgJ5gw== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/angle@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/angle/-/angle-6.5.0.tgz#985934171284e109d41e19ed48ad91cf9709a928" + integrity sha512-4pXMbWhFofJJAOvTMCns6N4C8CMd5Ih4O2jSAG9b3dDHakj3O4yN1+Zbm+NUei+eVEZ9gFeVp9svE3aMDenIkw== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + +"@turf/area@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/area/-/area-6.5.0.tgz#1d0d7aee01d8a4a3d4c91663ed35cc615f36ad56" + integrity sha512-xCZdiuojokLbQ+29qR6qoMD89hv+JAgWjLrwSEWL+3JV8IXKeNFl6XkEJz9HGkVpnXvQKJoRz4/liT+8ZZ5Jyg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/bbox-clip@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox-clip/-/bbox-clip-6.5.0.tgz#8e07d51ef8c875f9490d5c8699a2e51918587c94" + integrity sha512-F6PaIRF8WMp8EmgU/Ke5B1Y6/pia14UAYB5TiBC668w5rVVjy5L8rTm/m2lEkkDMHlzoP9vNY4pxpNthE7rLcQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/bbox-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox-polygon/-/bbox-polygon-6.5.0.tgz#f18128b012eedfa860a521d8f2b3779cc0801032" + integrity sha512-+/r0NyL1lOG3zKZmmf6L8ommU07HliP4dgYToMoTxqzsWzyLjaj/OzgQ8rBmv703WJX+aS6yCmLuIhYqyufyuw== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/bbox@*", "@turf/bbox@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" + integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/bearing@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.5.0.tgz#462a053c6c644434bdb636b39f8f43fb0cd857b0" + integrity sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/bezier-spline@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/bezier-spline/-/bezier-spline-6.5.0.tgz#d1b1764948b0fa3d9aa6e4895aebeba24048b11f" + integrity sha512-vokPaurTd4PF96rRgGVm6zYYC5r1u98ZsG+wZEv9y3kJTuJRX/O3xIY2QnTGTdbVmAJN1ouOsD0RoZYaVoXORQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-clockwise@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-clockwise/-/boolean-clockwise-6.5.0.tgz#34573ecc18f900080f00e4ff364631a8b1135794" + integrity sha512-45+C7LC5RMbRWrxh3Z0Eihsc8db1VGBO5d9BLTOAwU4jR6SgsunTfRWR16X7JUwIDYlCVEmnjcXJNi/kIU3VIw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-contains@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz#f802e7432fb53109242d5bf57393ef2f53849bbf" + integrity sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/boolean-point-on-line" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-crosses@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-crosses/-/boolean-crosses-6.5.0.tgz#4a1981475b9d6e23b25721f9fb8ef20696ff1648" + integrity sha512-gvshbTPhAHporTlQwBJqyfW+2yV8q/mOTxG6PzRVl6ARsqNoqYQWkd4MLug7OmAqVyBzLK3201uAeBjxbGw0Ng== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/polygon-to-line" "^6.5.0" + +"@turf/boolean-disjoint@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-disjoint/-/boolean-disjoint-6.5.0.tgz#e291d8f8f8cce7f7bb3c11e23059156a49afc5e4" + integrity sha512-rZ2ozlrRLIAGo2bjQ/ZUu4oZ/+ZjGvLkN5CKXSKBcu6xFO6k2bgqeM8a1836tAW+Pqp/ZFsTA5fZHsJZvP2D5g== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/polygon-to-line" "^6.5.0" + +"@turf/boolean-equal@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-equal/-/boolean-equal-6.5.0.tgz#b1c0ce14e9d9fb7778cddcf22558c9f523fe9141" + integrity sha512-cY0M3yoLC26mhAnjv1gyYNQjn7wxIXmL2hBmI/qs8g5uKuC2hRWi13ydufE3k4x0aNRjFGlg41fjoYLwaVF+9Q== + dependencies: + "@turf/clean-coords" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + geojson-equality "0.1.6" + +"@turf/boolean-intersects@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-intersects/-/boolean-intersects-6.5.0.tgz#df2b831ea31a4574af6b2fefe391f097a926b9d6" + integrity sha512-nIxkizjRdjKCYFQMnml6cjPsDOBCThrt+nkqtSEcxkKMhAQj5OO7o2CecioNTaX8EayqwMGVKcsz27oP4mKPTw== + dependencies: + "@turf/boolean-disjoint" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/boolean-overlap@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-overlap/-/boolean-overlap-6.5.0.tgz#f27c85888c3665d42d613a91a83adf1657cd1385" + integrity sha512-8btMIdnbXVWUa1M7D4shyaSGxLRw6NjMcqKBcsTXcZdnaixl22k7ar7BvIzkaRYN3SFECk9VGXfLncNS3ckQUw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/line-overlap" "^6.5.0" + "@turf/meta" "^6.5.0" + geojson-equality "0.1.6" + +"@turf/boolean-parallel@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-parallel/-/boolean-parallel-6.5.0.tgz#4e8a9dafdccaf18aca95f1265a5eade3f330173f" + integrity sha512-aSHJsr1nq9e5TthZGZ9CZYeXklJyRgR5kCLm5X4urz7+MotMOp/LsGOsvKvK9NeUl9+8OUmfMn8EFTT8LkcvIQ== + dependencies: + "@turf/clean-coords" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + +"@turf/boolean-point-in-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" + integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-point-on-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz#a8efa7bad88760676f395afb9980746bc5b376e9" + integrity sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/boolean-within@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/boolean-within/-/boolean-within-6.5.0.tgz#31a749d3be51065da8c470a1e5613f4d2efdee06" + integrity sha512-YQB3oU18Inx35C/LU930D36RAVe7LDXk1kWsQ8mLmuqYn9YdPsDQTMTkLJMhoQ8EbN7QTdy333xRQ4MYgToteQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/boolean-point-on-line" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/buffer@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/buffer/-/buffer-6.5.0.tgz#22bd0d05b4e1e73eaebc69b8f574a410ff704842" + integrity sha512-qeX4N6+PPWbKqp1AVkBVWFerGjMYMUyencwfnkCesoznU6qvfugFHNAngNqIBVnJjZ5n8IFyOf+akcxnrt9sNg== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/center" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/projection" "^6.5.0" + d3-geo "1.7.1" + turf-jsts "*" + +"@turf/center-mean@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center-mean/-/center-mean-6.5.0.tgz#2dc329c003f8012ba9ae7812a61b5647e1ae86a2" + integrity sha512-AAX6f4bVn12pTVrMUiB9KrnV94BgeBKpyg3YpfnEbBpkN/znfVhL8dG8IxMAxAoSZ61Zt9WLY34HfENveuOZ7Q== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/center-median@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center-median/-/center-median-6.5.0.tgz#1b68e3f288af47f76c247d6bf671f30d8c25c974" + integrity sha512-dT8Ndu5CiZkPrj15PBvslpuf01ky41DEYEPxS01LOxp5HOUHXp1oJxsPxvc+i/wK4BwccPNzU1vzJ0S4emd1KQ== + dependencies: + "@turf/center-mean" "^6.5.0" + "@turf/centroid" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/center-of-mass@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center-of-mass/-/center-of-mass-6.5.0.tgz#f9e6988bc296b7f763a0137ad6095f54843cf06a" + integrity sha512-EWrriU6LraOfPN7m1jZi+1NLTKNkuIsGLZc2+Y8zbGruvUW+QV7K0nhf7iZWutlxHXTBqEXHbKue/o79IumAsQ== + dependencies: + "@turf/centroid" "^6.5.0" + "@turf/convex" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/center@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/center/-/center-6.5.0.tgz#3bcb6bffcb8ba147430cfea84aabaed5dbdd4f07" + integrity sha512-T8KtMTfSATWcAX088rEDKjyvQCBkUsLnK/Txb6/8WUXIeOZyHu42G7MkdkHRoHtwieLdduDdmPLFyTdG5/e7ZQ== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/centroid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/centroid/-/centroid-6.5.0.tgz#ecaa365412e5a4d595bb448e7dcdacfb49eb0009" + integrity sha512-MwE1oq5E3isewPprEClbfU5pXljIK/GUOMbn22UM3IFPDJX0KeoyLNwghszkdmFp/qMGL/M13MMWvU+GNLXP/A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/circle@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/circle/-/circle-6.5.0.tgz#dc017d8c0131d1d212b7c06f76510c22bbeb093c" + integrity sha512-oU1+Kq9DgRnoSbWFHKnnUdTmtcRUMmHoV9DjTXu9vOLNV5OWtAAh1VZ+mzsioGGzoDNT/V5igbFOkMfBQc0B6A== + dependencies: + "@turf/destination" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/clean-coords@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clean-coords/-/clean-coords-6.5.0.tgz#6690adf764ec4b649710a8a20dab7005efbea53f" + integrity sha512-EMX7gyZz0WTH/ET7xV8MyrExywfm9qUi0/MY89yNffzGIEHuFfqwhcCqZ8O00rZIPZHUTxpmsxQSTfzJJA1CPw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/clone@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clone/-/clone-6.5.0.tgz#895860573881ae10a02dfff95f274388b1cda51a" + integrity sha512-mzVtTFj/QycXOn6ig+annKrM6ZlimreKYz6f/GSERytOpgzodbQyOgkfwru100O1KQhhjSudKK4DsQ0oyi9cTw== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/clusters-dbscan@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clusters-dbscan/-/clusters-dbscan-6.5.0.tgz#e01f854d24fac4899009fc6811854424ea8f0985" + integrity sha512-SxZEE4kADU9DqLRiT53QZBBhu8EP9skviSyl+FGj08Y01xfICM/RR9ACUdM0aEQimhpu+ZpRVcUK+2jtiCGrYQ== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + density-clustering "1.3.0" + +"@turf/clusters-kmeans@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clusters-kmeans/-/clusters-kmeans-6.5.0.tgz#aca6f66858af6476b7352a2bbbb392f9ddb7f5b4" + integrity sha512-DwacD5+YO8kwDPKaXwT9DV46tMBVNsbi1IzdajZu1JDSWoN7yc7N9Qt88oi+p30583O0UPVkAK+A10WAQv4mUw== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + skmeans "0.9.7" + +"@turf/clusters@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/clusters/-/clusters-6.5.0.tgz#a5ee7b62cdf345db2f1eafe2eb382adc186163e1" + integrity sha512-Y6gfnTJzQ1hdLfCsyd5zApNbfLIxYEpmDibHUqR5z03Lpe02pa78JtgrgUNt1seeO/aJ4TG1NLN8V5gOrHk04g== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/collect@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/collect/-/collect-6.5.0.tgz#3749ca7d4b91fbcbe1b9b8858ed70df8b6290910" + integrity sha512-4dN/T6LNnRg099m97BJeOcTA5fSI8cu87Ydgfibewd2KQwBexO69AnjEFqfPX3Wj+Zvisj1uAVIZbPmSSrZkjg== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + rbush "2.x" + +"@turf/combine@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/combine/-/combine-6.5.0.tgz#e0f3468ac9c09c24fa7184ebbd8a79d2e595ef81" + integrity sha512-Q8EIC4OtAcHiJB3C4R+FpB4LANiT90t17uOd851qkM2/o6m39bfN5Mv0PWqMZIHWrrosZqRqoY9dJnzz/rJxYQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/concave@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/concave/-/concave-6.5.0.tgz#19ab1a3f04087c478cebc5e631293f3eeb2e7ce4" + integrity sha512-I/sUmUC8TC5h/E2vPwxVht+nRt+TnXIPRoztDFvS8/Y0+cBDple9inLSo9nnPXMXidrBlGXZ9vQx/BjZUJgsRQ== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/tin" "^6.5.0" + topojson-client "3.x" + topojson-server "3.x" + +"@turf/convex@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/convex/-/convex-6.5.0.tgz#a7613e0d3795e2f5b9ce79a39271e86f54a3d354" + integrity sha512-x7ZwC5z7PJB0SBwNh7JCeCNx7Iu+QSrH7fYgK0RhhNop13TqUlvHMirMLRgf2db1DqUetrAO2qHJeIuasquUWg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + concaveman "*" + +"@turf/destination@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-6.5.0.tgz#30a84702f9677d076130e0440d3223ae503fdae1" + integrity sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/difference@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/difference/-/difference-6.5.0.tgz#677b0d5641a93bba2e82f2c683f0d880105b3197" + integrity sha512-l8iR5uJqvI+5Fs6leNbhPY5t/a3vipUF/3AeVLpwPQcgmedNXyheYuy07PcMGH5Jdpi5gItOiTqwiU/bUH4b3A== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + polygon-clipping "^0.15.3" + +"@turf/dissolve@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/dissolve/-/dissolve-6.5.0.tgz#65debed7ef185087d842b450ebd01e81cc2e80f6" + integrity sha512-WBVbpm9zLTp0Bl9CE35NomTaOL1c4TQCtEoO43YaAhNEWJOOIhZMFJyr8mbvYruKl817KinT3x7aYjjCMjTAsQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + polygon-clipping "^0.15.3" + +"@turf/distance-weight@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/distance-weight/-/distance-weight-6.5.0.tgz#fe1fb45b5ae5ca4e09a898cb0a15c6c79ed0849e" + integrity sha512-a8qBKkgVNvPKBfZfEJZnC3DV7dfIsC3UIdpRci/iap/wZLH41EmS90nM+BokAJflUHYy8PqE44wySGWHN1FXrQ== + dependencies: + "@turf/centroid" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/distance@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.5.0.tgz#21f04d5f86e864d54e2abde16f35c15b4f36149a" + integrity sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/ellipse@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/ellipse/-/ellipse-6.5.0.tgz#1e20cc9eb968f35ab891572892a0bffcef5e552a" + integrity sha512-kuXtwFviw/JqnyJXF1mrR/cb496zDTSbGKtSiolWMNImYzGGkbsAsFTjwJYgD7+4FixHjp0uQPzo70KDf3AIBw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/rhumb-destination" "^6.5.0" + "@turf/transform-rotate" "^6.5.0" + +"@turf/envelope@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/envelope/-/envelope-6.5.0.tgz#73e81b9b7ed519bd8a614d36322d6f9fbeeb0579" + integrity sha512-9Z+FnBWvOGOU4X+fMZxYFs1HjFlkKqsddLuMknRaqcJd6t+NIv5DWvPtDL8ATD2GEExYDiFLwMdckfr1yqJgHA== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/bbox-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/explode@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/explode/-/explode-6.5.0.tgz#02c292cc143dd629643da5b70bb5b19b9f0f1c6b" + integrity sha512-6cSvMrnHm2qAsace6pw9cDmK2buAlw8+tjeJVXMfMyY+w7ZUi1rprWMsY92J7s2Dar63Bv09n56/1V7+tcj52Q== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/flatten@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/flatten/-/flatten-6.5.0.tgz#0bd26161f4f1759bbad6ba9485e8ee65f3fa72a7" + integrity sha512-IBZVwoNLVNT6U/bcUUllubgElzpMsNoCw8tLqBw6dfYg9ObGmpEjf9BIYLr7a2Yn5ZR4l7YIj2T7kD5uJjZADQ== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/flip@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/flip/-/flip-6.5.0.tgz#04b38eae8a78f2cf9240140b25401b16b37d20e2" + integrity sha512-oyikJFNjt2LmIXQqgOGLvt70RgE2lyzPMloYWM7OR5oIFGRiBvqVD2hA6MNw6JewIm30fWZ8DQJw1NHXJTJPbg== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/great-circle@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/great-circle/-/great-circle-6.5.0.tgz#2daccbdd1c609a13b00d566ea0ad95457cfc87c2" + integrity sha512-7ovyi3HaKOXdFyN7yy1yOMa8IyOvV46RC1QOQTT+RYUN8ke10eyqExwBpL9RFUPvlpoTzoYbM/+lWPogQlFncg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/helpers@6.x", "@turf/helpers@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" + integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + +"@turf/hex-grid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/hex-grid/-/hex-grid-6.5.0.tgz#aa5ee46e291839d4405db74b7516c6da89ee56f7" + integrity sha512-Ln3tc2tgZT8etDOldgc6e741Smg1CsMKAz1/Mlel+MEL5Ynv2mhx3m0q4J9IB1F3a4MNjDeVvm8drAaf9SF33g== + dependencies: + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/intersect" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/interpolate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/interpolate/-/interpolate-6.5.0.tgz#9120def5d4498dd7b7d5e92a263aac3e1fd92886" + integrity sha512-LSH5fMeiGyuDZ4WrDJNgh81d2DnNDUVJtuFryJFup8PV8jbs46lQGfI3r1DJ2p1IlEJIz3pmAZYeTfMMoeeohw== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/centroid" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/hex-grid" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/point-grid" "^6.5.0" + "@turf/square-grid" "^6.5.0" + "@turf/triangle-grid" "^6.5.0" + +"@turf/intersect@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/intersect/-/intersect-6.5.0.tgz#a14e161ddd0264d0f07ac4e325553c70c421f9e6" + integrity sha512-2legGJeKrfFkzntcd4GouPugoqPUjexPZnOvfez+3SfIMrHvulw8qV8u7pfVyn2Yqs53yoVCEjS5sEpvQ5YRQg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + polygon-clipping "^0.15.3" + +"@turf/invariant@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" + integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/isobands@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/isobands/-/isobands-6.5.0.tgz#5e0929d9d8d53147074a5cfe4533768782e2a2ce" + integrity sha512-4h6sjBPhRwMVuFaVBv70YB7eGz+iw0bhPRnp+8JBdX1UPJSXhoi/ZF2rACemRUr0HkdVB/a1r9gC32vn5IAEkw== + dependencies: + "@turf/area" "^6.5.0" + "@turf/bbox" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/explode" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + object-assign "*" + +"@turf/isolines@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/isolines/-/isolines-6.5.0.tgz#3435c7cb5a79411207a5657aa4095357cfd35831" + integrity sha512-6ElhiLCopxWlv4tPoxiCzASWt/jMRvmp6mRYrpzOm3EUl75OhHKa/Pu6Y9nWtCMmVC/RcWtiiweUocbPLZLm0A== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + object-assign "*" + +"@turf/kinks@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-6.5.0.tgz#80e7456367535365012f658cf1a988b39a2c920b" + integrity sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/length@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/length/-/length-6.5.0.tgz#ff4e9072d5f997e1c32a1311d214d184463f83fa" + integrity sha512-5pL5/pnw52fck3oRsHDcSGrj9HibvtlrZ0QNy2OcW8qBFDNgZ4jtl6U7eATVoyWPKBHszW3dWETW+iLV7UARig== + dependencies: + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/line-arc@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-arc/-/line-arc-6.5.0.tgz#5ca35516ccf1f3a01149889d9facb39a77b07431" + integrity sha512-I6c+V6mIyEwbtg9P9zSFF89T7QPe1DPTG3MJJ6Cm1MrAY0MdejwQKOpsvNl8LDU2ekHOlz2kHpPVR7VJsoMllA== + dependencies: + "@turf/circle" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/line-chunk@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-chunk/-/line-chunk-6.5.0.tgz#02cefa74564b9cf533a3ac8a5109c97cb7170d10" + integrity sha512-i1FGE6YJaaYa+IJesTfyRRQZP31QouS+wh/pa6O3CC0q4T7LtHigyBSYjrbjSLfn2EVPYGlPCMFEqNWCOkC6zg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/length" "^6.5.0" + "@turf/line-slice-along" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/line-intersect@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.5.0.tgz#dea48348b30c093715d2195d2dd7524aee4cf020" + integrity sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/meta" "^6.5.0" + geojson-rbush "3.x" + +"@turf/line-offset@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-offset/-/line-offset-6.5.0.tgz#2bbd8fcf9ff82009b72890863da444b190e53689" + integrity sha512-CEXZbKgyz8r72qRvPchK0dxqsq8IQBdH275FE6o4MrBkzMcoZsfSjghtXzKaz9vvro+HfIXal0sTk2mqV1lQTw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/line-overlap@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-overlap/-/line-overlap-6.5.0.tgz#10ebb805c2d047463379fc1f997785fa8f3f4cc1" + integrity sha512-xHOaWLd0hkaC/1OLcStCpfq55lPHpPNadZySDXYiYjEz5HXr1oKmtMYpn0wGizsLwrOixRdEp+j7bL8dPt4ojQ== + dependencies: + "@turf/boolean-point-on-line" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/nearest-point-on-line" "^6.5.0" + deep-equal "1.x" + geojson-rbush "3.x" + +"@turf/line-segment@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-6.5.0.tgz#ee73f3ffcb7c956203b64ed966d96af380a4dd65" + integrity sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/line-slice-along@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-slice-along/-/line-slice-along-6.5.0.tgz#6e7a861d72c6f80caba2c4418b69a776f0292953" + integrity sha512-KHJRU6KpHrAj+BTgTNqby6VCTnDzG6a1sJx/I3hNvqMBLvWVA2IrkR9L9DtsQsVY63IBwVdQDqiwCuZLDQh4Ng== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/line-slice@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-slice/-/line-slice-6.5.0.tgz#7b6e0c8e8e93fdb4e65c3b9a123a2ec93a21bdb0" + integrity sha512-vDqJxve9tBHhOaVVFXqVjF5qDzGtKWviyjbyi2QnSnxyFAmLlLnBfMX8TLQCAf2GxHibB95RO5FBE6I2KVPRuw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/nearest-point-on-line" "^6.5.0" + +"@turf/line-split@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-6.5.0.tgz#116d7fbf714457878225187f5820ef98db7b02c2" + integrity sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/nearest-point-on-line" "^6.5.0" + "@turf/square" "^6.5.0" + "@turf/truncate" "^6.5.0" + geojson-rbush "3.x" + +"@turf/line-to-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/line-to-polygon/-/line-to-polygon-6.5.0.tgz#c919a03064a1cd5cef4c4e4d98dc786e12ffbc89" + integrity sha512-qYBuRCJJL8Gx27OwCD1TMijM/9XjRgXH/m/TyuND4OXedBpIWlK5VbTIO2gJ8OCfznBBddpjiObLBrkuxTpN4Q== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/mask@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/mask/-/mask-6.5.0.tgz#a97f355ee071ac60d8d3782ae39e5bb4b4e26857" + integrity sha512-RQha4aU8LpBrmrkH8CPaaoAfk0Egj5OuXtv6HuCQnHeGNOQt3TQVibTA3Sh4iduq4EPxnZfDjgsOeKtrCA19lg== + dependencies: + "@turf/helpers" "^6.5.0" + polygon-clipping "^0.15.3" + +"@turf/meta@6.x", "@turf/meta@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" + integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/midpoint@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/midpoint/-/midpoint-6.5.0.tgz#5f9428959309feccaf3f55873a8de70d4121bdce" + integrity sha512-MyTzV44IwmVI6ec9fB2OgZ53JGNlgOpaYl9ArKoF49rXpL84F9rNATndbe0+MQIhdkw8IlzA6xVP4lZzfMNVCw== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/moran-index@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/moran-index/-/moran-index-6.5.0.tgz#456264bfb014a7b5f527807c9dcf25df3c6b2efd" + integrity sha512-ItsnhrU2XYtTtTudrM8so4afBCYWNaB0Mfy28NZwLjB5jWuAsvyV+YW+J88+neK/ougKMTawkmjQqodNJaBeLQ== + dependencies: + "@turf/distance-weight" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/nearest-point-on-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz#8e1cd2cdc0b5acaf4c8d8b3b33bb008d3cb99e7b" + integrity sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/nearest-point-to-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-to-line/-/nearest-point-to-line-6.5.0.tgz#5549b48690d523f9af4765fe64a3cbebfbc6bb75" + integrity sha512-PXV7cN0BVzUZdjj6oeb/ESnzXSfWmEMrsfZSDRgqyZ9ytdiIj/eRsnOXLR13LkTdXVOJYDBuf7xt1mLhM4p6+Q== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/point-to-line-distance" "^6.5.0" + object-assign "*" + +"@turf/nearest-point@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/nearest-point/-/nearest-point-6.5.0.tgz#2f1781c26ff3f054005d4ff352042973318b92f1" + integrity sha512-fguV09QxilZv/p94s8SMsXILIAMiaXI5PATq9d7YWijLxWUj6Q/r43kxyoi78Zmwwh1Zfqz9w+bCYUAxZ5+euA== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/planepoint@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/planepoint/-/planepoint-6.5.0.tgz#5cb788670c31a6b064ae464180d51b4d550d87de" + integrity sha512-R3AahA6DUvtFbka1kcJHqZ7DMHmPXDEQpbU5WaglNn7NaCQg9HB0XM0ZfqWcd5u92YXV+Gg8QhC8x5XojfcM4Q== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/point-grid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/point-grid/-/point-grid-6.5.0.tgz#f628d30afe29d60dcbf54b195e46eab48a4fbfaa" + integrity sha512-Iq38lFokNNtQJnOj/RBKmyt6dlof0yhaHEDELaWHuECm1lIZLY3ZbVMwbs+nXkwTAHjKfS/OtMheUBkw+ee49w== + dependencies: + "@turf/boolean-within" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/point-on-feature@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/point-on-feature/-/point-on-feature-6.5.0.tgz#37d07afeb31896e53c0833aa404993ba7d500f0c" + integrity sha512-bDpuIlvugJhfcF/0awAQ+QI6Om1Y1FFYE8Y/YdxGRongivix850dTeXCo0mDylFdWFPGDo7Mmh9Vo4VxNwW/TA== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/center" "^6.5.0" + "@turf/explode" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/nearest-point" "^6.5.0" + +"@turf/point-to-line-distance@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/point-to-line-distance/-/point-to-line-distance-6.5.0.tgz#bc46fe09ea630aaf73f13c40b38a7df79050fff8" + integrity sha512-opHVQ4vjUhNBly1bob6RWy+F+hsZDH9SA0UW36pIRzfpu27qipU18xup0XXEePfY6+wvhF6yL/WgCO2IbrLqEA== + dependencies: + "@turf/bearing" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/projection" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + "@turf/rhumb-distance" "^6.5.0" + +"@turf/points-within-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/points-within-polygon/-/points-within-polygon-6.5.0.tgz#d49f4d7cf19b7a440bf1e06f771ff4e1df13107f" + integrity sha512-YyuheKqjliDsBDt3Ho73QVZk1VXX1+zIA2gwWvuz8bR1HXOkcuwk/1J76HuFMOQI3WK78wyAi+xbkx268PkQzQ== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/polygon-smooth@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/polygon-smooth/-/polygon-smooth-6.5.0.tgz#00ca366871cb6ea3bee44ff3ea870aaf75711733" + integrity sha512-LO/X/5hfh/Rk4EfkDBpLlVwt3i6IXdtQccDT9rMjXEP32tRgy0VMFmdkNaXoGlSSKf/1mGqLl4y4wHd86DqKbg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/polygon-tangents@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/polygon-tangents/-/polygon-tangents-6.5.0.tgz#dc025202727ba2f3347baa95dbca4e0ffdb2ddf5" + integrity sha512-sB4/IUqJMYRQH9jVBwqS/XDitkEfbyqRy+EH/cMRJURTg78eHunvJ708x5r6umXsbiUyQU4eqgPzEylWEQiunw== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/boolean-within" "^6.5.0" + "@turf/explode" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/nearest-point" "^6.5.0" + +"@turf/polygon-to-line@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/polygon-to-line/-/polygon-to-line-6.5.0.tgz#4dc86db66168b32bb83ce448cf966208a447d952" + integrity sha512-5p4n/ij97EIttAq+ewSnKt0ruvuM+LIDzuczSzuHTpq4oS7Oq8yqg5TQ4nzMVuK41r/tALCk7nAoBuw3Su4Gcw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/polygonize@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/polygonize/-/polygonize-6.5.0.tgz#8aa0f1e386e96c533a320c426aaf387020320fa3" + integrity sha512-a/3GzHRaCyzg7tVYHo43QUChCspa99oK4yPqooVIwTC61npFzdrmnywMv0S+WZjHZwK37BrFJGFrZGf6ocmY5w== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/envelope" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/projection@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/projection/-/projection-6.5.0.tgz#d2aad862370bf03f2270701115464a8406c144b2" + integrity sha512-/Pgh9mDvQWWu8HRxqpM+tKz8OzgauV+DiOcr3FCjD6ubDnrrmMJlsf6fFJmggw93mtVPrZRL6yyi9aYCQBOIvg== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/random@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/random/-/random-6.5.0.tgz#b19672cf4549557660034d4a303911656df7747e" + integrity sha512-8Q25gQ/XbA7HJAe+eXp4UhcXM9aOOJFaxZ02+XSNwMvY8gtWSCBLVqRcW4OhqilgZ8PeuQDWgBxeo+BIqqFWFQ== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/rectangle-grid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/rectangle-grid/-/rectangle-grid-6.5.0.tgz#c3ef38e8cfdb763012beb1f22e2b77288a37a5cf" + integrity sha512-yQZ/1vbW68O2KsSB3OZYK+72aWz/Adnf7m2CMKcC+aq6TwjxZjAvlbCOsNUnMAuldRUVN1ph6RXMG4e9KEvKvg== + dependencies: + "@turf/boolean-intersects" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/rewind@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/rewind/-/rewind-6.5.0.tgz#bc0088f8ec56f00c8eacd902bbe51e3786cb73a0" + integrity sha512-IoUAMcHWotBWYwSYuYypw/LlqZmO+wcBpn8ysrBNbazkFNkLf3btSDZMkKJO/bvOzl55imr/Xj4fi3DdsLsbzQ== + dependencies: + "@turf/boolean-clockwise" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/rhumb-bearing@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/rhumb-bearing/-/rhumb-bearing-6.5.0.tgz#8c41ad62b44fb4e57c14fe790488056684eee7b9" + integrity sha512-jMyqiMRK4hzREjQmnLXmkJ+VTNTx1ii8vuqRwJPcTlKbNWfjDz/5JqJlb5NaFDcdMpftWovkW5GevfnuzHnOYA== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/rhumb-destination@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/rhumb-destination/-/rhumb-destination-6.5.0.tgz#12da8c85e674b182e8b0ec8ea9c5fe2186716dae" + integrity sha512-RHNP1Oy+7xTTdRrTt375jOZeHceFbjwohPHlr9Hf68VdHHPMAWgAKqiX2YgSWDcvECVmiGaBKWus1Df+N7eE4Q== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/rhumb-distance@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/rhumb-distance/-/rhumb-distance-6.5.0.tgz#ed068004b1469512b857070fbf5cb7b7eabbe592" + integrity sha512-oKp8KFE8E4huC2Z1a1KNcFwjVOqa99isxNOwfo4g3SUABQ6NezjKDDrnvC4yI5YZ3/huDjULLBvhed45xdCrzg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + +"@turf/sample@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/sample/-/sample-6.5.0.tgz#00cca024514989448e57fb1bf34e9a33ed3f0755" + integrity sha512-kSdCwY7el15xQjnXYW520heKUrHwRvnzx8ka4eYxX9NFeOxaFITLW2G7UtXb6LJK8mmPXI8Aexv23F2ERqzGFg== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/sector@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/sector/-/sector-6.5.0.tgz#599a87ebbe6ee613b4e04c5928e0ef1fc78fc16c" + integrity sha512-cYUOkgCTWqa23SOJBqxoFAc/yGCUsPRdn/ovbRTn1zNTm/Spmk6hVB84LCKOgHqvSF25i0d2kWqpZDzLDdAPbw== + dependencies: + "@turf/circle" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/line-arc" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/shortest-path@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/shortest-path/-/shortest-path-6.5.0.tgz#e1fdf9b4758bd20caf845fdc03d0dc2eede2ff0e" + integrity sha512-4de5+G7+P4hgSoPwn+SO9QSi9HY5NEV/xRJ+cmoFVRwv2CDsuOPDheHKeuIAhKyeKDvPvPt04XYWbac4insJMg== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/bbox-polygon" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/clean-coords" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/transform-scale" "^6.5.0" + +"@turf/simplify@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/simplify/-/simplify-6.5.0.tgz#ec435460bde0985b781618b05d97146c32c8bc16" + integrity sha512-USas3QqffPHUY184dwQdP8qsvcVH/PWBYdXY5am7YTBACaQOMAlf6AKJs9FT8jiO6fQpxfgxuEtwmox+pBtlOg== + dependencies: + "@turf/clean-coords" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/square-grid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/square-grid/-/square-grid-6.5.0.tgz#3a517301b42ed98aa62d727786dc5290998ddbae" + integrity sha512-mlR0ayUdA+L4c9h7p4k3pX6gPWHNGuZkt2c5II1TJRmhLkW2557d6b/Vjfd1z9OVaajb1HinIs1FMSAPXuuUrA== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/rectangle-grid" "^6.5.0" + +"@turf/square@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" + integrity sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ== + dependencies: + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + +"@turf/standard-deviational-ellipse@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/standard-deviational-ellipse/-/standard-deviational-ellipse-6.5.0.tgz#775c7b9a2be6546bf64ea8ac08cdcd80563f2935" + integrity sha512-02CAlz8POvGPFK2BKK8uHGUk/LXb0MK459JVjKxLC2yJYieOBTqEbjP0qaWhiBhGzIxSMaqe8WxZ0KvqdnstHA== + dependencies: + "@turf/center-mean" "^6.5.0" + "@turf/ellipse" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/points-within-polygon" "^6.5.0" + +"@turf/tag@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/tag/-/tag-6.5.0.tgz#13eae85f36f9fd8c4e076714a894cb5b7716d381" + integrity sha512-XwlBvrOV38CQsrNfrxvBaAPBQgXMljeU0DV8ExOyGM7/hvuGHJw3y8kKnQ4lmEQcmcrycjDQhP7JqoRv8vFssg== + dependencies: + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/tesselate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/tesselate/-/tesselate-6.5.0.tgz#de45b778f8e6a45535d8eb2aacea06f86c6b73fb" + integrity sha512-M1HXuyZFCfEIIKkglh/r5L9H3c5QTEsnMBoZOFQiRnGPGmJWcaBissGb7mTFX2+DKE7FNWXh4TDnZlaLABB0dQ== + dependencies: + "@turf/helpers" "^6.5.0" + earcut "^2.0.0" + +"@turf/tin@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/tin/-/tin-6.5.0.tgz#b77bebb48237e6613ac6bc0e37a6658be8c17a09" + integrity sha512-YLYikRzKisfwj7+F+Tmyy/LE3d2H7D4kajajIfc9mlik2+esG7IolsX/+oUz1biguDYsG0DUA8kVYXDkobukfg== + dependencies: + "@turf/helpers" "^6.5.0" + +"@turf/transform-rotate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/transform-rotate/-/transform-rotate-6.5.0.tgz#e50e96a8779af91d58149eedb00ffd7f6395c804" + integrity sha512-A2Ip1v4246ZmpssxpcL0hhiVBEf4L8lGnSPWTgSv5bWBEoya2fa/0SnFX9xJgP40rMP+ZzRaCN37vLHbv1Guag== + dependencies: + "@turf/centroid" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + "@turf/rhumb-destination" "^6.5.0" + "@turf/rhumb-distance" "^6.5.0" + +"@turf/transform-scale@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/transform-scale/-/transform-scale-6.5.0.tgz#dcccd8b0f139de32e32225a29c107a1279137120" + integrity sha512-VsATGXC9rYM8qTjbQJ/P7BswKWXHdnSJ35JlV4OsZyHBMxJQHftvmZJsFbOqVtQnIQIzf2OAly6rfzVV9QLr7g== + dependencies: + "@turf/bbox" "^6.5.0" + "@turf/center" "^6.5.0" + "@turf/centroid" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + "@turf/rhumb-destination" "^6.5.0" + "@turf/rhumb-distance" "^6.5.0" + +"@turf/transform-translate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/transform-translate/-/transform-translate-6.5.0.tgz#631b13aca6402898029e03fc2d1f4bc1c667fc3e" + integrity sha512-NABLw5VdtJt/9vSstChp93pc6oel4qXEos56RBMsPlYB8hzNTEKYtC146XJvyF4twJeeYS8RVe1u7KhoFwEM5w== + dependencies: + "@turf/clone" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/rhumb-destination" "^6.5.0" + +"@turf/triangle-grid@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/triangle-grid/-/triangle-grid-6.5.0.tgz#75664e8b9d9c7ca4c845673134a1e0d82b5e6887" + integrity sha512-2jToUSAS1R1htq4TyLQYPTIsoy6wg3e3BQXjm2rANzw4wPQCXGOxrur1Fy9RtzwqwljlC7DF4tg0OnWr8RjmfA== + dependencies: + "@turf/distance" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/intersect" "^6.5.0" + +"@turf/truncate@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-6.5.0.tgz#c3a16cad959f1be1c5156157d5555c64b19185d8" + integrity sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + +"@turf/turf@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/turf/-/turf-6.5.0.tgz#49cd07b942a757f3ebbdba6cb294bbb864825a83" + integrity sha512-ipMCPnhu59bh92MNt8+pr1VZQhHVuTMHklciQURo54heoxRzt1neNYZOBR6jdL+hNsbDGAECMuIpAutX+a3Y+w== + dependencies: + "@turf/along" "^6.5.0" + "@turf/angle" "^6.5.0" + "@turf/area" "^6.5.0" + "@turf/bbox" "^6.5.0" + "@turf/bbox-clip" "^6.5.0" + "@turf/bbox-polygon" "^6.5.0" + "@turf/bearing" "^6.5.0" + "@turf/bezier-spline" "^6.5.0" + "@turf/boolean-clockwise" "^6.5.0" + "@turf/boolean-contains" "^6.5.0" + "@turf/boolean-crosses" "^6.5.0" + "@turf/boolean-disjoint" "^6.5.0" + "@turf/boolean-equal" "^6.5.0" + "@turf/boolean-intersects" "^6.5.0" + "@turf/boolean-overlap" "^6.5.0" + "@turf/boolean-parallel" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/boolean-point-on-line" "^6.5.0" + "@turf/boolean-within" "^6.5.0" + "@turf/buffer" "^6.5.0" + "@turf/center" "^6.5.0" + "@turf/center-mean" "^6.5.0" + "@turf/center-median" "^6.5.0" + "@turf/center-of-mass" "^6.5.0" + "@turf/centroid" "^6.5.0" + "@turf/circle" "^6.5.0" + "@turf/clean-coords" "^6.5.0" + "@turf/clone" "^6.5.0" + "@turf/clusters" "^6.5.0" + "@turf/clusters-dbscan" "^6.5.0" + "@turf/clusters-kmeans" "^6.5.0" + "@turf/collect" "^6.5.0" + "@turf/combine" "^6.5.0" + "@turf/concave" "^6.5.0" + "@turf/convex" "^6.5.0" + "@turf/destination" "^6.5.0" + "@turf/difference" "^6.5.0" + "@turf/dissolve" "^6.5.0" + "@turf/distance" "^6.5.0" + "@turf/distance-weight" "^6.5.0" + "@turf/ellipse" "^6.5.0" + "@turf/envelope" "^6.5.0" + "@turf/explode" "^6.5.0" + "@turf/flatten" "^6.5.0" + "@turf/flip" "^6.5.0" + "@turf/great-circle" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/hex-grid" "^6.5.0" + "@turf/interpolate" "^6.5.0" + "@turf/intersect" "^6.5.0" + "@turf/invariant" "^6.5.0" + "@turf/isobands" "^6.5.0" + "@turf/isolines" "^6.5.0" + "@turf/kinks" "^6.5.0" + "@turf/length" "^6.5.0" + "@turf/line-arc" "^6.5.0" + "@turf/line-chunk" "^6.5.0" + "@turf/line-intersect" "^6.5.0" + "@turf/line-offset" "^6.5.0" + "@turf/line-overlap" "^6.5.0" + "@turf/line-segment" "^6.5.0" + "@turf/line-slice" "^6.5.0" + "@turf/line-slice-along" "^6.5.0" + "@turf/line-split" "^6.5.0" + "@turf/line-to-polygon" "^6.5.0" + "@turf/mask" "^6.5.0" + "@turf/meta" "^6.5.0" + "@turf/midpoint" "^6.5.0" + "@turf/moran-index" "^6.5.0" + "@turf/nearest-point" "^6.5.0" + "@turf/nearest-point-on-line" "^6.5.0" + "@turf/nearest-point-to-line" "^6.5.0" + "@turf/planepoint" "^6.5.0" + "@turf/point-grid" "^6.5.0" + "@turf/point-on-feature" "^6.5.0" + "@turf/point-to-line-distance" "^6.5.0" + "@turf/points-within-polygon" "^6.5.0" + "@turf/polygon-smooth" "^6.5.0" + "@turf/polygon-tangents" "^6.5.0" + "@turf/polygon-to-line" "^6.5.0" + "@turf/polygonize" "^6.5.0" + "@turf/projection" "^6.5.0" + "@turf/random" "^6.5.0" + "@turf/rewind" "^6.5.0" + "@turf/rhumb-bearing" "^6.5.0" + "@turf/rhumb-destination" "^6.5.0" + "@turf/rhumb-distance" "^6.5.0" + "@turf/sample" "^6.5.0" + "@turf/sector" "^6.5.0" + "@turf/shortest-path" "^6.5.0" + "@turf/simplify" "^6.5.0" + "@turf/square" "^6.5.0" + "@turf/square-grid" "^6.5.0" + "@turf/standard-deviational-ellipse" "^6.5.0" + "@turf/tag" "^6.5.0" + "@turf/tesselate" "^6.5.0" + "@turf/tin" "^6.5.0" + "@turf/transform-rotate" "^6.5.0" + "@turf/transform-scale" "^6.5.0" + "@turf/transform-translate" "^6.5.0" + "@turf/triangle-grid" "^6.5.0" + "@turf/truncate" "^6.5.0" + "@turf/union" "^6.5.0" + "@turf/unkink-polygon" "^6.5.0" + "@turf/voronoi" "^6.5.0" + +"@turf/union@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/union/-/union-6.5.0.tgz#82d28f55190608f9c7d39559b7f543393b03b82d" + integrity sha512-igYWCwP/f0RFHIlC2c0SKDuM/ObBaqSljI3IdV/x71805QbIvY/BYGcJdyNcgEA6cylIGl/0VSlIbpJHZ9ldhw== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + polygon-clipping "^0.15.3" + +"@turf/unkink-polygon@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/unkink-polygon/-/unkink-polygon-6.5.0.tgz#9e54186dcce08d7e62f608c8fa2d3f0342ebe826" + integrity sha512-8QswkzC0UqKmN1DT6HpA9upfa1HdAA5n6bbuzHy8NJOX8oVizVAqfEPY0wqqTgboDjmBR4yyImsdPGUl3gZ8JQ== + dependencies: + "@turf/area" "^6.5.0" + "@turf/boolean-point-in-polygon" "^6.5.0" + "@turf/helpers" "^6.5.0" + "@turf/meta" "^6.5.0" + rbush "^2.0.1" + +"@turf/voronoi@^6.5.0": + version "6.5.0" + resolved "https://registry.yarnpkg.com/@turf/voronoi/-/voronoi-6.5.0.tgz#afe6715a5c7eff687434010cde45cd4822489434" + integrity sha512-C/xUsywYX+7h1UyNqnydHXiun4UPjK88VDghtoRypR9cLlb7qozkiLRphQxxsCM0KxyxpVPHBVQXdAL3+Yurow== + dependencies: + "@turf/helpers" "^6.5.0" + "@turf/invariant" "^6.5.0" + d3-voronoi "1.1.2" + "@tweenjs/tween.js@^18.6.4": version "18.6.4" resolved "https://registry.yarnpkg.com/@tweenjs/tween.js/-/tween.js-18.6.4.tgz#40a3d0a93647124872dec8e0fd1bd5926695b6ca" @@ -3619,6 +4756,11 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== +"@types/geojson@7946.0.8": + version "7946.0.8" + resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" + integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== + "@types/glob@*": version "8.0.0" resolved "https://registry.yarnpkg.com/@types/glob/-/glob-8.0.0.tgz#321607e9cbaec54f687a0792b2d1d370739455d2" @@ -3960,6 +5102,11 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.6.tgz#250a7b16c3b91f672a24552ec64678eeb1d3a08d" integrity sha512-PBjIUxZHOuj0R15/xuwJYjFi+KZdNFrehocChv4g5hu6aFroHue8m0lBP0POdK2nKzbw0cgV1mws8+V/JAcEkQ== +"@types/uuid@^9.0.0": + version "9.0.0" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.0.tgz#53ef263e5239728b56096b0a869595135b7952d2" + integrity sha512-kr90f+ERiQtKWMz5rP32ltJ/BtULDI5RVO0uavn1HQUOwjx0R1h0rnDYNL0CepF1zL5bSY6FISAfd9tOdDhU5Q== + "@types/webpack-env@^1.16.0": version "1.18.0" resolved "https://registry.yarnpkg.com/@types/webpack-env/-/webpack-env-1.18.0.tgz#ed6ecaa8e5ed5dfe8b2b3d00181702c9925f13fb" @@ -6279,6 +7426,16 @@ concat-stream@~2.0.0: readable-stream "^3.0.2" typedarray "^0.0.6" +concaveman@*: + version "1.2.1" + resolved "https://registry.yarnpkg.com/concaveman/-/concaveman-1.2.1.tgz#47d20b4521125c15fabf453653c2696d9ee41e0b" + integrity sha512-PwZYKaM/ckQSa8peP5JpVr7IMJ4Nn/MHIaWUjP4be+KoZ7Botgs8seAZGpmaOM+UZXawcdYRao/px9ycrCihHw== + dependencies: + point-in-polygon "^1.1.0" + rbush "^3.0.1" + robust-predicates "^2.0.4" + tinyqueue "^2.0.3" + console-browserify@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" @@ -6672,6 +7829,23 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha512-NJGVKPS81XejHcLhaLJS7plab0fK3slPh11mESeeDq2W4ZI5kUKK/LRRdVDvjJseojbPB7ZwjnyOybg3Igea/A== +d3-array@1: + version "1.2.4" + resolved "https://registry.yarnpkg.com/d3-array/-/d3-array-1.2.4.tgz#635ce4d5eea759f6f605863dbcfc30edc737f71f" + integrity sha512-KHW6M86R+FUPYGb3R5XiYjXPq7VzwxZ22buHhAEVG5ztoEcZZMLov530mmccaqA1GghZArjQV46fuc8kUqhhHw== + +d3-geo@1.7.1: + version "1.7.1" + resolved "https://registry.yarnpkg.com/d3-geo/-/d3-geo-1.7.1.tgz#44bbc7a218b1fd859f3d8fd7c443ca836569ce99" + integrity sha512-O4AempWAr+P5qbk2bC2FuN/sDW4z+dN2wDf9QV3bxQt4M5HfOEeXLgJ/UKQW0+o1Dj8BE+L5kiDbdWUMjsmQpw== + dependencies: + d3-array "1" + +d3-voronoi@1.1.2: + version "1.1.2" + resolved "https://registry.yarnpkg.com/d3-voronoi/-/d3-voronoi-1.1.2.tgz#1687667e8f13a2d158c80c1480c5a29cb0d8973c" + integrity sha512-RhGS1u2vavcO7ay7ZNAPo4xeDh/VYeGof3x5ZLJBQgYhLegxr3s5IykvWmJ94FTU6mcbtp4sloqZ54mP6R4Utw== + data-uri-to-buffer@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.0.tgz#b5db46aea50f6176428ac05b73be39a57701a64b" @@ -6779,6 +7953,18 @@ deep-eql@^3.0.1: dependencies: type-detect "^4.0.0" +deep-equal@1.x, deep-equal@^1.0.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" + integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== + dependencies: + is-arguments "^1.0.4" + is-date-object "^1.0.1" + is-regex "^1.0.4" + object-is "^1.0.1" + object-keys "^1.1.1" + regexp.prototype.flags "^1.2.0" + deep-equal@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-2.0.5.tgz#55cd2fe326d83f9cbf7261ef0e060b3f724c5cb9" @@ -6898,6 +8084,11 @@ delegates@^1.0.0: resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a" integrity sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ== +density-clustering@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/density-clustering/-/density-clustering-1.3.0.tgz#dc9f59c8f0ab97e1624ac64930fd3194817dcac5" + integrity sha512-icpmBubVTwLnsaor9qH/4tG5+7+f61VcqMN3V3pm9sxxSCt2Jcs0zWOgwZW9ARJYaKD3FumIgHiMOcIMRRAzFQ== + depd@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" @@ -7165,7 +8356,7 @@ duplexify@^3.4.2, duplexify@^3.6.0: readable-stream "^2.0.0" stream-shift "^1.0.0" -earcut@^2.2.4: +earcut@^2.0.0, earcut@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/earcut/-/earcut-2.2.4.tgz#6d02fd4d68160c114825d06890a92ecaae60343a" integrity sha512-/pjZsA1b4RPHbeWZQn66SWS8nZZWLQQ23oE3Eam7aroEFGEvwKAsJfZ9ytiEMycfzXWpca4FA9QIOehf7PocBQ== @@ -8658,6 +9849,24 @@ gensync@^1.0.0-beta.1, gensync@^1.0.0-beta.2: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== +geojson-equality@0.1.6: + version "0.1.6" + resolved "https://registry.yarnpkg.com/geojson-equality/-/geojson-equality-0.1.6.tgz#a171374ef043e5d4797995840bae4648e0752d72" + integrity sha512-TqG8YbqizP3EfwP5Uw4aLu6pKkg6JQK9uq/XZ1lXQntvTHD1BBKJWhNpJ2M0ax6TuWMP3oyx6Oq7FCIfznrgpQ== + dependencies: + deep-equal "^1.0.0" + +geojson-rbush@3.x: + version "3.2.0" + resolved "https://registry.yarnpkg.com/geojson-rbush/-/geojson-rbush-3.2.0.tgz#8b543cf0d56f99b78faf1da52bb66acad6dfc290" + integrity sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w== + dependencies: + "@turf/bbox" "*" + "@turf/helpers" "6.x" + "@turf/meta" "6.x" + "@types/geojson" "7946.0.8" + rbush "^3.0.1" + get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" @@ -9937,7 +11146,7 @@ is-reference@^1.2.1: dependencies: "@types/estree" "*" -is-regex@^1.1.1, is-regex@^1.1.2, is-regex@^1.1.4: +is-regex@^1.0.4, is-regex@^1.1.1, is-regex@^1.1.2, is-regex@^1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== @@ -12120,7 +13329,7 @@ nwsapi@^2.2.2: resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.2.tgz#e5418863e7905df67d51ec95938d67bf801f0bb0" integrity sha512-90yv+6538zuvUMnN+zCr8LuV6bPFdq50304114vJYJ8RDyK8D5O9Phpbd6SZWgI7PwzmmfN1upeOJlvybDSgCw== -object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: +object-assign@*, object-assign@^4.0.1, object-assign@^4.1.0, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== @@ -12139,7 +13348,7 @@ object-inspect@^1.12.2, object-inspect@^1.9.0: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== -object-is@^1.1.4: +object-is@^1.0.1, object-is@^1.1.4: version "1.1.5" resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== @@ -12795,6 +14004,11 @@ pnp-webpack-plugin@1.6.4: dependencies: ts-pnp "^1.1.6" +point-in-polygon@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/point-in-polygon/-/point-in-polygon-1.1.0.tgz#b0af2616c01bdee341cbf2894df643387ca03357" + integrity sha512-3ojrFwjnnw8Q9242TzgXuTD+eKiutbzyslcq1ydfu82Db2y+Ogbmyrkpv0Hgj31qwT3lbS9+QAAO/pIQM35XRw== + polished@^4.2.2: version "4.2.2" resolved "https://registry.yarnpkg.com/polished/-/polished-4.2.2.tgz#2529bb7c3198945373c52e34618c8fe7b1aa84d1" @@ -12802,6 +14016,13 @@ polished@^4.2.2: dependencies: "@babel/runtime" "^7.17.8" +polygon-clipping@^0.15.3: + version "0.15.3" + resolved "https://registry.yarnpkg.com/polygon-clipping/-/polygon-clipping-0.15.3.tgz#0215840438470ba2e9e6593625e4ea5c1087b4b7" + integrity sha512-ho0Xx5DLkgxRx/+n4O74XyJ67DcyN3Tu9bGYKsnTukGAW6ssnuak6Mwcyb1wHy9MZc9xsUWqIoiazkZB5weECg== + dependencies: + splaytree "^3.1.0" + posix-character-classes@^0.1.0: version "0.1.1" resolved "https://registry.yarnpkg.com/posix-character-classes/-/posix-character-classes-0.1.1.tgz#01eac0fe3b5af71a2a6c02feabb8c1fef7e00eab" @@ -13214,6 +14435,11 @@ quickjs-emscripten@0.21.0: resolved "https://registry.yarnpkg.com/quickjs-emscripten/-/quickjs-emscripten-0.21.0.tgz#d6a7e81b311babb1a1c95d3257d56f0c5e4e48b2" integrity sha512-tx0o6Ig3K3ehLBaw+p/DnPKrY88gsVpFf9VirEPZNrf/q1egzXOpBuqiHOlFIp9OghWovdeCH+J7jqc/YjYALA== +quickselect@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-1.1.1.tgz#852e412ce418f237ad5b660d70cffac647ae94c2" + integrity sha512-qN0Gqdw4c4KGPsBOQafj6yj/PA6c/L63f6CaZ/DCF/xF4Esu3jVmKLUDYxghFx8Kb/O7y9tI7x2RjTSXwdK1iQ== + quickselect@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-2.0.0.tgz#f19680a486a5eefb581303e023e98faaf25dd018" @@ -13267,6 +14493,13 @@ raw-loader@^4.0.2: loader-utils "^2.0.0" schema-utils "^3.0.0" +rbush@2.x, rbush@^2.0.1: + version "2.0.2" + resolved "https://registry.yarnpkg.com/rbush/-/rbush-2.0.2.tgz#bb6005c2731b7ba1d5a9a035772927d16a614605" + integrity sha512-XBOuALcTm+O/H8G90b6pzu6nX6v2zCKiFG4BJho8a+bY6AER6t8uQUZdi5bomQc0AprCWhEGa7ncAbbRap0bRA== + dependencies: + quickselect "^1.0.1" + rbush@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/rbush/-/rbush-3.0.1.tgz#5fafa8a79b3b9afdfe5008403a720cc1de882ecf" @@ -13831,7 +15064,7 @@ regex-not@^1.0.0, regex-not@^1.0.2: extend-shallow "^3.0.2" safe-regex "^1.1.0" -regexp.prototype.flags@^1.3.0, regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: +regexp.prototype.flags@^1.2.0, regexp.prototype.flags@^1.3.0, regexp.prototype.flags@^1.4.1, regexp.prototype.flags@^1.4.3: version "1.4.3" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== @@ -14165,6 +15398,11 @@ ripemd160@^2.0.0, ripemd160@^2.0.1: hash-base "^3.0.0" inherits "^2.0.1" +robust-predicates@^2.0.4: + version "2.0.4" + resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-2.0.4.tgz#0a2367a93abd99676d075981707f29cfb402248b" + integrity sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg== + rollup-plugin-external-globals@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/rollup-plugin-external-globals/-/rollup-plugin-external-globals-0.6.1.tgz#861c260b5727144e0fd1b424b103f9f0282fc365" @@ -14550,6 +15788,11 @@ sisteransi@^1.0.5: resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed" integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== +skmeans@0.9.7: + version "0.9.7" + resolved "https://registry.yarnpkg.com/skmeans/-/skmeans-0.9.7.tgz#72670cebb728508f56e29c0e10d11e623529ce5d" + integrity sha512-hNj1/oZ7ygsfmPZ7ZfN5MUBRoGg1gtpnImuJBgLO0ljQ67DtJuiQaiYdS4lUA6s0KCwnPhGivtC/WRwIZLkHyg== + slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" @@ -14756,6 +15999,11 @@ specificity@^0.4.1: resolved "https://registry.yarnpkg.com/specificity/-/specificity-0.4.1.tgz#aab5e645012db08ba182e151165738d00887b019" integrity sha512-1klA3Gi5PD1Wv9Q0wUoOQN1IWAuPu0D1U03ThXTr0cJ20+/iq2tHSDnK7Kk/0LXJ1ztUB2/1Os0wKmfyNgUQfg== +splaytree@^3.1.0: + version "3.1.1" + resolved "https://registry.yarnpkg.com/splaytree/-/splaytree-3.1.1.tgz#e1bc8e68e64ef5a9d5f09d36e6d9f3621795a438" + integrity sha512-9FaQ18FF0+sZc/ieEeXHt+Jw2eSpUgUtTLDYB/HXKWvhYVyOc7h1hzkn5MMO3GPib9MmXG1go8+OsBBzs/NMww== + split-string@^3.0.1, split-string@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/split-string/-/split-string-3.1.0.tgz#7cb09dda3a86585705c64b39a6466038682e8fe2" @@ -15393,6 +16641,11 @@ tinypool@^0.3.0: resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.3.0.tgz#c405d8b743509fc28ea4ca358433190be654f819" integrity sha512-NX5KeqHOBZU6Bc0xj9Vr5Szbb1j8tUHIeD18s41aDJaPeC5QTdEhK0SpdpUrZlj2nv5cctNcSjaKNanXlfcVEQ== +tinyqueue@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== + tinyspy@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-1.0.2.tgz#6da0b3918bfd56170fb3cd3a2b5ef832ee1dff0d" @@ -15484,13 +16737,20 @@ toidentifier@1.0.1: resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== -topojson-client@^3.1.0: +topojson-client@3.x, topojson-client@^3.1.0: version "3.1.0" resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-3.1.0.tgz#22e8b1ed08a2b922feeb4af6f53b6ef09a467b99" integrity sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw== dependencies: commander "2" +topojson-server@3.x: + version "3.0.1" + resolved "https://registry.yarnpkg.com/topojson-server/-/topojson-server-3.0.1.tgz#d2b3ec095b6732299be76a48406111b3201a34f5" + integrity sha512-/VS9j/ffKr2XAOjlZ9CgyyeLmgJ9dMwq6Y0YEON8O7p/tGGk+dCWnrE03zEdu7i4L7YsFZLEPZPzCvcB7lEEXw== + dependencies: + commander "2" + tosource@^2.0.0-alpha.3: version "2.0.0-alpha.3" resolved "https://registry.yarnpkg.com/tosource/-/tosource-2.0.0-alpha.3.tgz#ef385dac9092e009bf25c018838ddaae436daeb6" @@ -15663,6 +16923,11 @@ tty-table@^4.1.5: wcwidth "^1.0.1" yargs "^17.1.1" +turf-jsts@*: + version "1.2.3" + resolved "https://registry.yarnpkg.com/turf-jsts/-/turf-jsts-1.2.3.tgz#59757f542afbff9a577bbf411f183b8f48d38aa4" + integrity sha512-Ja03QIJlPuHt4IQ2FfGex4F4JAr8m3jpaHbFbQrgwr7s7L6U8ocrHiF3J1+wf9jzhGKxvDeaCAnGDot8OjGFyA== + type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -16187,6 +17452,11 @@ uuid@^3.3.2: resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee" integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A== +uuid@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.0.tgz#592f550650024a38ceb0c562f2f6aa435761efb5" + integrity sha512-MXcSTerfPa4uqyzStbRoTgt5XIe3x5+42+q1sDuy3R5MDk66URdLMOZe5aPX/SQd+kuYAh0FdP/pO28IkQyTeg== + uvu@^0.5.0: version "0.5.6" resolved "https://registry.yarnpkg.com/uvu/-/uvu-0.5.6.tgz#2754ca20bcb0bb59b64e9985e84d2e81058502df"