diff --git a/deployment-config.json b/deployment-config.json index 0f172806ec..9cbe7ad75d 100644 --- a/deployment-config.json +++ b/deployment-config.json @@ -868,6 +868,33 @@ }, "projectPath": "./dist/playgrounds/cesium-reference" }, + "annotations": { + "deployment": { + "manual": { + "dev": { + "pages": { + "org": "carma-dev-playground-deployments", + "prj": "annotations" + } + }, + "live": { + "pages": { + "org": "carma-dev-playground-deployments", + "prj": "annotations" + } + } + }, + "auto": { + "dev": { + "pages": { + "org": "carma-dev-playground-deployments", + "prj": "annotations" + } + } + } + }, + "projectPath": "./dist/playgrounds/annotations" + }, "vector": { "deployment": { "manual": { diff --git a/libraries/commons/math/src/lib/index.ts b/libraries/commons/math/src/lib/index.ts index 264db2a133..53588591cf 100644 --- a/libraries/commons/math/src/lib/index.ts +++ b/libraries/commons/math/src/lib/index.ts @@ -4,4 +4,5 @@ export * from "./easingFunctions"; export * from "./scaling"; export * from "./interpolation"; export * from "./geometry2d"; +export * from "./vec3"; export * from "./trig"; diff --git a/libraries/commons/math/src/lib/vec3.ts b/libraries/commons/math/src/lib/vec3.ts new file mode 100644 index 0000000000..e5643ac58c --- /dev/null +++ b/libraries/commons/math/src/lib/vec3.ts @@ -0,0 +1,89 @@ +import { Ray, Vector3 } from "three"; + +export type PlaneBasis3 = { + xAxis: Vector3; + yAxis: Vector3; +}; + +export const VEC3_NUMERIC_EPSILON = 1e-6; + +export const getClosestLineParamToRay = ( + ray: Ray, + lineOrigin: Vector3, + lineDirection: Vector3, + epsilon: number = VEC3_NUMERIC_EPSILON +): number => { + const rayDirection = ray.direction.clone(); + const normalizedLineDirection = lineDirection.clone(); + + if ( + rayDirection.lengthSq() <= epsilon || + normalizedLineDirection.lengthSq() <= epsilon + ) { + return 0; + } + + rayDirection.normalize(); + normalizedLineDirection.normalize(); + + const originDelta = ray.origin.clone().sub(lineOrigin); + + const a = rayDirection.dot(rayDirection); + const b = rayDirection.dot(normalizedLineDirection); + const c = normalizedLineDirection.dot(normalizedLineDirection); + const d = rayDirection.dot(originDelta); + const e = normalizedLineDirection.dot(originDelta); + const denominator = a * c - b * b; + + if (Math.abs(denominator) < epsilon) { + return e; + } + + return (a * e - b * d) / denominator; +}; + +export const intersectRayWithPlane = ( + ray: Ray, + planeOrigin: Vector3, + planeNormal: Vector3, + epsilon: number = VEC3_NUMERIC_EPSILON +): Vector3 | null => { + const denominator = ray.direction.dot(planeNormal); + if (Math.abs(denominator) <= epsilon) return null; + + const originToPlane = planeOrigin.clone().sub(ray.origin); + const t = originToPlane.dot(planeNormal) / denominator; + if (!Number.isFinite(t)) return null; + + return ray.origin.clone().add(ray.direction.clone().multiplyScalar(t)); +}; + +export const createPlaneBasisFromNormal = ( + normal: Vector3, + epsilon: number = VEC3_NUMERIC_EPSILON +): PlaneBasis3 => { + const up = + normal.lengthSq() > epsilon + ? normal.clone().normalize() + : new Vector3(0, 0, 1); + const reference = + Math.abs(up.dot(new Vector3(0, 0, 1))) > 0.9 + ? new Vector3(1, 0, 0) + : new Vector3(0, 0, 1); + + const xAxis = up.clone().cross(reference); + if (xAxis.lengthSq() > epsilon) { + xAxis.normalize(); + } else { + xAxis.set(1, 0, 0); + } + + const yAxis = xAxis.clone().cross(up); + if (yAxis.lengthSq() > epsilon) { + yAxis.normalize(); + } else { + yAxis.set(0, 1, 0); + } + + return { xAxis, yAxis }; +}; diff --git a/libraries/commons/react-store/README.md b/libraries/commons/react-store/README.md new file mode 100644 index 0000000000..f8ebba4dcf --- /dev/null +++ b/libraries/commons/react-store/README.md @@ -0,0 +1,16 @@ +# react-store + +A tiny React-friendly external store helper for local monorepo state. + +It provides: +- `createStore(initialState)` +- `useStoreSelector(store, selector)` +- `useStoreValue(store)` + +The goal is to keep store usage explicit and small: +- one store object +- stable `getState` / `setState` / `subscribe` API +- React reads through `useSyncExternalStore` + +This package is intended for internal monorepo use where a lightweight store is +more appropriate than introducing or coupling to a larger state library. diff --git a/libraries/commons/react-store/package.json b/libraries/commons/react-store/package.json new file mode 100644 index 0000000000..72329d8cc7 --- /dev/null +++ b/libraries/commons/react-store/package.json @@ -0,0 +1,7 @@ +{ + "name": "@carma-commons/react-store", + "version": "0.0.1", + "type": "module", + "main": "./index.js", + "types": "./index.d.ts" +} diff --git a/libraries/commons/react-store/project.json b/libraries/commons/react-store/project.json new file mode 100644 index 0000000000..e83bd52642 --- /dev/null +++ b/libraries/commons/react-store/project.json @@ -0,0 +1,27 @@ +{ + "name": "react-store", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libraries/commons/react-store/src", + "projectType": "library", + "tags": ["type:util", "scope:commons", "scope:react"], + "targets": { + "lint": { + "executor": "@nx/eslint:lint" + }, + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libraries/commons/react-store" + } + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{workspaceRoot}/dist/.vite/react-store"], + "options": { + "config": "libraries/commons/react-store/vite.config.ts", + "passWithNoTests": false + } + } + } +} diff --git a/libraries/commons/react-store/src/index.ts b/libraries/commons/react-store/src/index.ts new file mode 100644 index 0000000000..8cd5167d1c --- /dev/null +++ b/libraries/commons/react-store/src/index.ts @@ -0,0 +1 @@ +export * from "./lib"; diff --git a/libraries/commons/react-store/src/lib/index.ts b/libraries/commons/react-store/src/lib/index.ts new file mode 100644 index 0000000000..ffec4ca58d --- /dev/null +++ b/libraries/commons/react-store/src/lib/index.ts @@ -0,0 +1,2 @@ +export * from "./store"; +export * from "./useStoreSelector"; diff --git a/libraries/commons/react-store/src/lib/store.spec.ts b/libraries/commons/react-store/src/lib/store.spec.ts new file mode 100644 index 0000000000..66ca595c8b --- /dev/null +++ b/libraries/commons/react-store/src/lib/store.spec.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createStore } from "./store"; + +describe("createStore", () => { + it("returns the initial state", () => { + const store = createStore({ count: 1 }); + + expect(store.getState()).toEqual({ count: 1 }); + }); + + it("updates state from a plain value", () => { + const store = createStore({ count: 1 }); + + store.setState({ count: 2 }); + + expect(store.getState()).toEqual({ count: 2 }); + }); + + it("updates state from an updater function", () => { + const store = createStore({ count: 1 }); + + store.setState((previousState) => ({ + count: previousState.count + 1, + })); + + expect(store.getState()).toEqual({ count: 2 }); + }); + + it("notifies subscribers when the state changes", () => { + const store = createStore({ count: 1 }); + const listener = vi.fn(); + + store.subscribe(listener); + store.setState({ count: 2 }); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + it("does not notify subscribers when the next state is identical", () => { + const state = { count: 1 }; + const store = createStore(state); + const listener = vi.fn(); + + store.subscribe(listener); + store.setState(state); + + expect(listener).not.toHaveBeenCalled(); + }); + + it("stops notifying unsubscribed listeners", () => { + const store = createStore({ count: 1 }); + const listener = vi.fn(); + + const unsubscribe = store.subscribe(listener); + unsubscribe(); + store.setState({ count: 2 }); + + expect(listener).not.toHaveBeenCalled(); + }); +}); diff --git a/libraries/commons/react-store/src/lib/store.ts b/libraries/commons/react-store/src/lib/store.ts new file mode 100644 index 0000000000..8f029496b8 --- /dev/null +++ b/libraries/commons/react-store/src/lib/store.ts @@ -0,0 +1,58 @@ +export type StoreListener = () => void; + +export type StoreUpdater = TState | ((previousState: TState) => TState); + +export type ReadonlyStore = { + getState: () => TState; + subscribe: (listener: StoreListener) => () => void; +}; + +export type Store = ReadonlyStore & { + setState: (updater: StoreUpdater) => void; +}; + +const resolveNextState = ( + currentState: TState, + updater: StoreUpdater +): TState => { + if (typeof updater === "function") { + return (updater as (previousState: TState) => TState)(currentState); + } + + return updater; +}; + +export const createStore = (initialState: TState): Store => { + let currentState = initialState; + const listeners = new Set(); + + const getState = () => currentState; + + const subscribe = (listener: StoreListener) => { + listeners.add(listener); + + return () => { + listeners.delete(listener); + }; + }; + + const setState = (updater: StoreUpdater) => { + const nextState = resolveNextState(currentState, updater); + + if (Object.is(nextState, currentState)) { + return; + } + + currentState = nextState; + + for (const listener of [...listeners]) { + listener(); + } + }; + + return { + getState, + subscribe, + setState, + }; +}; diff --git a/libraries/commons/react-store/src/lib/useStoreSelector.spec.tsx b/libraries/commons/react-store/src/lib/useStoreSelector.spec.tsx new file mode 100644 index 0000000000..1d2ed889e3 --- /dev/null +++ b/libraries/commons/react-store/src/lib/useStoreSelector.spec.tsx @@ -0,0 +1,64 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; + +import { createStore } from "./store"; +import { useStoreSelector, useStoreValue } from "./useStoreSelector"; + +describe("useStoreSelector", () => { + it("returns the selected store state", () => { + const store = createStore({ count: 1, label: "one" }); + + const { result } = renderHook(() => + useStoreSelector(store, (state) => state.count) + ); + + expect(result.current).toBe(1); + }); + + it("updates when the selected snapshot changes", () => { + const store = createStore({ count: 1, label: "one" }); + + const { result } = renderHook(() => + useStoreSelector(store, (state) => state.count) + ); + + act(() => { + store.setState((previousState) => ({ + ...previousState, + count: previousState.count + 1, + })); + }); + + expect(result.current).toBe(2); + }); + + it("does not force a rerender when the selected snapshot is unchanged", () => { + const store = createStore({ count: 1, label: "one" }); + let renderCount = 0; + + const { result } = renderHook(() => { + renderCount += 1; + return useStoreSelector(store, (state) => state.count); + }); + + act(() => { + store.setState((previousState) => ({ + ...previousState, + label: "two", + })); + }); + + expect(result.current).toBe(1); + expect(renderCount).toBe(1); + }); +}); + +describe("useStoreValue", () => { + it("returns the full store state", () => { + const store = createStore({ count: 1, label: "one" }); + + const { result } = renderHook(() => useStoreValue(store)); + + expect(result.current).toEqual({ count: 1, label: "one" }); + }); +}); diff --git a/libraries/commons/react-store/src/lib/useStoreSelector.ts b/libraries/commons/react-store/src/lib/useStoreSelector.ts new file mode 100644 index 0000000000..bcd4c86d91 --- /dev/null +++ b/libraries/commons/react-store/src/lib/useStoreSelector.ts @@ -0,0 +1,18 @@ +import { useSyncExternalStore } from "react"; + +import type { ReadonlyStore } from "./store"; + +const identity = (value: TValue) => value; + +export const useStoreSelector = ( + store: ReadonlyStore, + selector: (state: TState) => TSelected +): TSelected => + useSyncExternalStore( + store.subscribe, + () => selector(store.getState()), + () => selector(store.getState()) + ); + +export const useStoreValue = (store: ReadonlyStore): TState => + useStoreSelector(store, identity); diff --git a/libraries/commons/react-store/tsconfig.json b/libraries/commons/react-store/tsconfig.json new file mode 100644 index 0000000000..667a3463d1 --- /dev/null +++ b/libraries/commons/react-store/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libraries/commons/react-store/tsconfig.lib.json b/libraries/commons/react-store/tsconfig.lib.json new file mode 100644 index 0000000000..4c7d4623b8 --- /dev/null +++ b/libraries/commons/react-store/tsconfig.lib.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "outDir": "../../../dist/out-tsc", + "types": ["node"], + "skipLibCheck": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/libraries/commons/react-store/tsconfig.spec.json b/libraries/commons/react-store/tsconfig.spec.json new file mode 100644 index 0000000000..ccba8efdff --- /dev/null +++ b/libraries/commons/react-store/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.lib.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "types": ["node", "vitest/globals", "vite/client"] + }, + "include": ["src/**/*.spec.ts", "src/**/*.spec.tsx"] +} diff --git a/libraries/commons/react-store/vite.config.ts b/libraries/commons/react-store/vite.config.ts new file mode 100644 index 0000000000..41c38f0b30 --- /dev/null +++ b/libraries/commons/react-store/vite.config.ts @@ -0,0 +1,50 @@ +/// +import { defineConfig } from 'vite'; +import dts from 'vite-plugin-dts'; +import * as path from 'path'; +import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin'; + +export default defineConfig({ + root: __dirname, + cacheDir: '../../../node_modules/.vite/libraries/commons/react-store', + + plugins: [ + nxViteTsPaths(), + dts({ + entryRoot: 'src', + tsconfigPath: path.join(__dirname, 'tsconfig.lib.json'), + }), + ], + + build: { + outDir: '../../../dist/libraries/commons/react-store', + reportCompressedSize: true, + sourcemap: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + lib: { + entry: 'src/index.ts', + name: 'reactStore', + fileName: 'index', + formats: ['es'], + }, + rollupOptions: { + external: ['react'], + }, + }, + + test: { + globals: true, + environment: 'jsdom', + cache: { + dir: '../../../node_modules/.vitest', + }, + include: ['src/**/*.{test,spec}.{ts,tsx}'], + reporters: ['default'], + coverage: { + provider: 'v8', + reportsDirectory: '../../../coverage/libraries/commons/react-store', + }, + }, +}); diff --git a/libraries/commons/utils/src/lib/numbers.ts b/libraries/commons/utils/src/lib/numbers.ts index ce7d212aaa..4a46e27dba 100644 --- a/libraries/commons/utils/src/lib/numbers.ts +++ b/libraries/commons/utils/src/lib/numbers.ts @@ -1,4 +1,4 @@ -// todo consolidate with carma-commons math library +import { clamp as clampRange } from "@carma-commons/math"; /** * @param v @@ -29,10 +29,12 @@ export const clampToToleranceRange = ( * @returns the clamped value */ export const clamp = (v: number, min?: number, max?: number): number => { - let out = v; - if (typeof min === "number") out = Math.max(min, out); - if (typeof max === "number") out = Math.min(max, out); - return out; + if (typeof min === "number" && typeof max === "number") { + return clampRange(v, min, max); + } + if (typeof min === "number") return Math.max(min, v); + if (typeof max === "number") return Math.min(max, v); + return v; }; /** diff --git a/libraries/mapping/annotations/README.md b/libraries/mapping/annotations/README.md index 4f3ea2d150..0c591f27a1 100644 --- a/libraries/mapping/annotations/README.md +++ b/libraries/mapping/annotations/README.md @@ -1,54 +1,36 @@ -# Annotations Architecture +# Annotations -This folder is split by responsibility, not by framework widget. +High-level package split for the annotations stack. ## Packages ### `core` -- owns canonical annotation and measurement types -- owns pure derivations, selectors, formatting, and shared geometry helpers -- owns generic render-model contracts -- must stay engine-agnostic +- canonical annotation and measurement types +- pure derivations, selectors, and shared geometry helpers +- generic render-model contracts +- engine-agnostic code only ### `provider` -- owns draft state, commands, persistence wiring, and UI orchestration -- owns per-measurement-type controllers -- maps domain state to generic render models -- should be the only layer that knows the full measurement workflow +- draft state, commands, persistence wiring, and UI/workflow orchestration +- annotation-specific edit and gizmo mapping +- mapping domain state to render models ### `cesium` -- owns Cesium scene services and renderers only -- projects world to screen, queries scene state, and syncs primitives/overlays -- consumes already-partitioned render models from `provider` -- must not decide what a measurement means +- Cesium scene services and renderers only +- world-to-screen projection, picking, visibility, and primitive/overlay sync +- consumes provider-built render inputs ## Placement Rules -- If code answers "what is this measurement?" it belongs in `core`. -- If code answers "what is the user currently doing?" it belongs in `provider`. -- If code answers "how do we render/query this in Cesium?" it belongs in `cesium`. -- Generic Cesium math should move to `@carma/cesium`, not stay here. - -## Target Shape - -The target architecture is per measurement type: -- point -- distance -- polyline -- ground area -- planar area -- vertical area -- label - -Each type should eventually have: -- a canonical type in `core` -- draft/controller logic in `provider` -- derived render-model builders in `provider` -- engine renderers in `cesium` - -## Anti-Patterns - -- catch-all types that mix semantic type, draft state, derived geometry, and style -- provider monoliths that own all measurement kinds inline -- Cesium hooks branching on measurement semantics -- wrapper-only hooks/files that do not reduce coupling +- semantic measurement meaning belongs in `core` +- user workflow and draft state belong in `provider` +- Cesium scene/query/render runtime belongs in `cesium` +- generic Cesium math belongs in `@carma/cesium` + +## Refactor Status + +Ongoing architecture cleanup and target-shape decisions live in the local spec: + +- [.dev-local/specs/mapping/annotations/ARCHITECTURE_SPLIT_SPEC.md](/Users/friedrich/cisgit/carma/.dev-local/specs/mapping/annotations/ARCHITECTURE_SPLIT_SPEC.md) + +That spec is the active work document for the current measurement refactor. This README should stay limited to stable package boundaries. diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/area/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/area/index.ts deleted file mode 100644 index 7fa8b1fd91..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/area/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "../areaVisualizer.types"; -export * from "../useCesiumGroundAreaVisualizer"; -export * from "../useCesiumPlanarAreaVisualizer"; -export * from "../useCesiumPolygonAreaPrimitives"; -export * from "../useCesiumVerticalAreaVisualizer"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/areaVisualizer.types.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/areaVisualizer.types.ts deleted file mode 100644 index 0e79f004b4..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/areaVisualizer.types.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { type Scene } from "@carma/cesium"; - -import { - type GroundPolygonPreviewGroup, - type PlanarPolygonPreviewGroup, - type PolygonAreaBadge, - type PolygonPreviewGroup, - type VerticalPolygonPreviewGroup, -} from "@carma-mapping/annotations/core"; - -export type CesiumPolygonAreaPrimitivesOptions = { - scene: Scene | null; - polygonPreviewGroups: PolygonPreviewGroup[]; - focusedPolygonGroupId: string | null; -}; - -export type AreaVisualizerCommonOptions = { - scene: Scene | null; - focusedPolygonGroupId: string | null; - polygonAreaBadgeByGroupId: Readonly>; -}; - -export type GroundAreaVisualizerOptions = AreaVisualizerCommonOptions & { - groundPolygonPreviewGroups: GroundPolygonPreviewGroup[]; -}; - -export type VerticalAreaVisualizerOptions = AreaVisualizerCommonOptions & { - verticalPolygonPreviewGroups: VerticalPolygonPreviewGroup[]; -}; - -export type PlanarAreaVisualizerOptions = AreaVisualizerCommonOptions & { - planarPolygonPreviewGroups: PlanarPolygonPreviewGroup[]; -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/distance/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/distance/index.ts deleted file mode 100644 index 207ad56c25..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/distance/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../useCesiumDistanceVisualizer"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumCoplanarPolygonPrimitives.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumCoplanarPolygonPrimitives.ts new file mode 100644 index 0000000000..9a6144809d --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumCoplanarPolygonPrimitives.ts @@ -0,0 +1,99 @@ +import { useEffect, useRef } from "react"; + +import { + Cartesian3, + CoplanarPolygonGeometry, + ColorGeometryInstanceAttribute, + GeometryInstance, + Matrix4, + PerInstanceColorAppearance, + Primitive, + PrimitiveCollection, + offsetCartesian3Positions, + type Scene, +} from "@carma/cesium"; +import type { CesiumPolygonPrimitive } from "./useCesiumGroundPolygonPrimitives"; + +const removePrimitiveCollection = ( + scene: Scene, + primitiveCollection: PrimitiveCollection | null +) => { + if (!primitiveCollection) return; + scene.primitives.remove(primitiveCollection); +}; + +export const useCesiumCoplanarPolygonPrimitives = ( + scene: Scene | null, + polygonPrimitives: readonly CesiumPolygonPrimitive[] +) => { + const primitiveCollectionRef = useRef(null); + + useEffect(() => { + if (!scene || scene.isDestroyed()) return; + + removePrimitiveCollection(scene, primitiveCollectionRef.current); + primitiveCollectionRef.current = null; + + if (polygonPrimitives.length === 0) return; + + const collection = new PrimitiveCollection(); + let hasPrimitive = false; + + for (const { id, vertexPoints, fillColor } of polygonPrimitives) { + if (vertexPoints.length < 3) continue; + + const geometryVertexPoints = vertexPoints.map((point) => + Cartesian3.clone(point) + ); + const anchor = geometryVertexPoints[0]; + if (!anchor) continue; + + const localPositions = offsetCartesian3Positions( + geometryVertexPoints, + Cartesian3.negate(anchor, new Cartesian3()) + ); + const coplanarGeometry = CoplanarPolygonGeometry.fromPositions({ + positions: localPositions, + vertexFormat: PerInstanceColorAppearance.VERTEX_FORMAT, + }); + if (!coplanarGeometry) continue; + + const instance = new GeometryInstance({ + geometry: coplanarGeometry, + id: { polygonGroupId: id }, + attributes: { + color: ColorGeometryInstanceAttribute.fromColor(fillColor), + }, + }); + + collection.add( + new Primitive({ + geometryInstances: [instance], + modelMatrix: Matrix4.fromTranslation(anchor, new Matrix4()), + appearance: new PerInstanceColorAppearance({ + flat: true, + translucent: true, + }), + asynchronous: false, + }) + ); + hasPrimitive = true; + } + + if (!hasPrimitive) { + scene.requestRender(); + return; + } + + primitiveCollectionRef.current = collection; + scene.primitives.add(collection); + scene.requestRender(); + + return () => { + if (scene.isDestroyed()) return; + removePrimitiveCollection(scene, collection); + primitiveCollectionRef.current = null; + scene.requestRender(); + }; + }, [polygonPrimitives, scene]); +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumEdgeVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumEdgeVisualizer.ts new file mode 100644 index 0000000000..4cf8eb4146 --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumEdgeVisualizer.ts @@ -0,0 +1,173 @@ +import { useEffect, useRef } from "react"; + +import { + Cartesian3, + Color, + Material, + PolylineCollection, + isValidScene, + type Scene, +} from "@carma/cesium"; +import { + LINE_TYPE_CARTESIAN, + LINE_TYPE_GEOGRAPHIC, + type LineType, +} from "@carma-mapping/annotations/core"; + +type CesiumEdgeLineRenderModel = { + id: string; + start: Cartesian3; + end: Cartesian3; + stroke: string; + strokeWidth: number; + dashed?: boolean; + lineType?: LineType; +}; + +export type CesiumEdgeVisualizerOptions = { + enabled?: boolean; +}; + +const destroyLineVisualizerMap = (lineRefs: { + current: Record void }>; +}) => { + Object.values(lineRefs.current).forEach((lineVisualizer) => { + lineVisualizer.destroy(); + }); + lineRefs.current = {}; +}; + +const DEFAULT_DASH_LENGTH_METERS = 1.5; +const DEFAULT_GAP_LENGTH_METERS = 1.5; +const MIN_SEGMENT_LENGTH_METERS = 0.01; + +const buildLineSegments = ( + start: Cartesian3, + end: Cartesian3, + dashed: boolean, + dashLength: number, + gapLength: number +): Array<[Cartesian3, Cartesian3]> => { + const totalLength = Cartesian3.distance(start, end); + if (totalLength <= MIN_SEGMENT_LENGTH_METERS) return []; + if (!dashed) return [[start, end]]; + + const safeDashLength = Math.max(dashLength, MIN_SEGMENT_LENGTH_METERS); + const safeGapLength = Math.max(gapLength, 0); + const step = Math.max( + safeDashLength + safeGapLength, + MIN_SEGMENT_LENGTH_METERS + ); + + const segments: Array<[Cartesian3, Cartesian3]> = []; + for (let distance = 0; distance < totalLength; distance += step) { + const endDistance = Math.min(distance + safeDashLength, totalLength); + if (endDistance - distance <= MIN_SEGMENT_LENGTH_METERS * 0.5) continue; + const t0 = distance / totalLength; + const t1 = endDistance / totalLength; + segments.push([ + Cartesian3.lerp(start, end, t0, new Cartesian3()), + Cartesian3.lerp(start, end, t1, new Cartesian3()), + ]); + } + return segments; +}; + +const createAttachedLine = ( + scene: Scene, + line: CesiumEdgeLineRenderModel +): { destroy: () => void } => { + const segments = buildLineSegments( + line.start, + line.end, + line.dashed ?? false, + DEFAULT_DASH_LENGTH_METERS, + DEFAULT_GAP_LENGTH_METERS + ); + if (segments.length === 0) { + return { destroy: () => undefined }; + } + + const collection = new PolylineCollection(); + const material = Material.fromType("Color", { + color: Color.fromCssColorString(line.stroke), + }); + + segments.forEach(([segmentStart, segmentEnd], index) => { + collection.add({ + id: `${line.id}-${index}`, + positions: [segmentStart, segmentEnd], + width: line.strokeWidth, + material, + show: true, + }); + }); + + scene.primitives.add(collection); + scene.requestRender(); + + return { + destroy: () => { + if (!isValidScene(scene)) return; + scene.primitives.remove(collection); + scene.requestRender(); + }, + }; +}; + +export const useCesiumEdgeVisualizer = ( + scene: Scene | null, + lines: readonly CesiumEdgeLineRenderModel[], + { enabled = true }: CesiumEdgeVisualizerOptions = {} +) => { + const lineRefs = useRef void }>>({}); + const warnedAboutGeographicPathRef = useRef(false); + + useEffect(() => { + if (!scene) return; + + destroyLineVisualizerMap(lineRefs); + + if (!enabled || lines.length === 0) { + if (!scene.isDestroyed()) { + scene.requestRender(); + } + return; + } + + const hasGeographicPathLine = lines.some( + (line) => line.lineType === LINE_TYPE_GEOGRAPHIC + ); + if (hasGeographicPathLine && !warnedAboutGeographicPathRef.current) { + console.warn( + "[annotations/cesium] Geographic edge line paths are not implemented yet; rendering them as Cartesian lines." + ); + warnedAboutGeographicPathRef.current = true; + } + + lines.forEach((line) => { + const lineType = line.lineType ?? LINE_TYPE_CARTESIAN; + // Geographic line rendering is intentionally not implemented yet. + // All scene lines still use straight Cartesian segments for now. + void lineType; + lineRefs.current[line.id] = createAttachedLine(scene, line); + }); + + scene.requestRender(); + + return () => { + destroyLineVisualizerMap(lineRefs); + if (!scene || scene.isDestroyed()) return; + scene.requestRender(); + }; + }, [enabled, lines, scene]); + + useEffect( + () => () => { + destroyLineVisualizerMap(lineRefs); + }, + [] + ); +}; + +export default useCesiumEdgeVisualizer; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumGroundPolygonPrimitives.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumGroundPolygonPrimitives.ts new file mode 100644 index 0000000000..d6b7be7f2f --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/geometry/useCesiumGroundPolygonPrimitives.ts @@ -0,0 +1,91 @@ +import { useEffect, useRef } from "react"; + +import { + Cartesian3, + ClassificationType, + Color, + ColorGeometryInstanceAttribute, + GeometryInstance, + GroundPrimitive, + PerInstanceColorAppearance, + PolygonGeometry, + PolygonHierarchy, + type Scene, +} from "@carma/cesium"; +import { type PolygonPreviewGroup } from "@carma-mapping/annotations/core"; + +const removeGroundPrimitives = ( + scene: Scene, + groundPrimitives: readonly GroundPrimitive[] +) => { + groundPrimitives.forEach((groundPrimitive) => { + scene.groundPrimitives.remove(groundPrimitive); + }); +}; + +export type CesiumPolygonPrimitive = { + id: string; + vertexPoints: ReadonlyArray; + fillColor: Color; +}; + +export const useCesiumGroundPolygonPrimitives = ( + scene: Scene | null, + polygonPrimitives: readonly CesiumPolygonPrimitive[] +) => { + const groundPrimitivesRef = useRef([]); + + useEffect(() => { + if (!scene || scene.isDestroyed()) return; + + removeGroundPrimitives(scene, groundPrimitivesRef.current); + groundPrimitivesRef.current = []; + + if (polygonPrimitives.length === 0) return; + + const nextGroundPrimitives: GroundPrimitive[] = []; + + for (const { id, vertexPoints, fillColor } of polygonPrimitives) { + if (vertexPoints.length < 3) continue; + const geometryVertexPoints = vertexPoints.map((point) => + Cartesian3.clone(point) + ); + const groundGeometry = new PolygonGeometry({ + polygonHierarchy: new PolygonHierarchy(geometryVertexPoints), + vertexFormat: PerInstanceColorAppearance.VERTEX_FORMAT, + }); + + const groundInstance = new GeometryInstance({ + geometry: groundGeometry, + id: { polygonGroupId: id }, + attributes: { + color: ColorGeometryInstanceAttribute.fromColor(fillColor), + }, + }); + + const groundPrimitive = new GroundPrimitive({ + geometryInstances: [groundInstance], + appearance: new PerInstanceColorAppearance({ + flat: true, + translucent: true, + }), + asynchronous: false, + releaseGeometryInstances: false, + classificationType: ClassificationType.BOTH, + }); + + scene.groundPrimitives.add(groundPrimitive); + nextGroundPrimitives.push(groundPrimitive); + } + + groundPrimitivesRef.current = nextGroundPrimitives; + scene.requestRender(); + + return () => { + if (scene.isDestroyed()) return; + removeGroundPrimitives(scene, nextGroundPrimitives); + groundPrimitivesRef.current = []; + scene.requestRender(); + }; + }, [polygonPrimitives, scene]); +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/gizmo/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/gizmo/index.ts deleted file mode 100644 index 245234d3ff..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/gizmo/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../useAnnotationMoveGizmoAdapter"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/index.ts index 227d259103..e586cb654f 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/index.ts +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/index.ts @@ -1,7 +1,8 @@ -export * from "./area"; -export * from "./distance"; -export * from "./point"; -export * from "./polyline"; -export * from "./scene"; -export * from "./gizmo"; -export * from "@carma-mapping/gizmo/cesium"; +export * from "./geometry/useCesiumCoplanarPolygonPrimitives"; +export * from "./geometry/useCesiumEdgeVisualizer"; +export * from "./geometry/useCesiumGroundPolygonPrimitives"; +export * from "./scene/flyToMeasurementPoints"; +export * from "./scene/useCesiumOverlaySync"; +export * from "./scene/useCesiumPointQuery"; +export * from "./scene/useCesiumSceneVisibilityIndex"; +export * from "./scene/useCesiumViewProjector"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/point/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/point/index.ts deleted file mode 100644 index 1d7cb5f848..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/point/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * from "../useCesiumPointDomVisualizer"; -export * from "../useCesiumPointLabels"; -export * from "../useCesiumPointQuery"; -export * from "../useCesiumPointVisualizer"; -export * from "../usePointRectangleSelectionOverlay"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/polyline/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/polyline/index.ts deleted file mode 100644 index 2ba9690528..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/polyline/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "../useCesiumPolylineVisualizer"; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/flyToMeasurementPoints.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/flyToMeasurementPoints.ts new file mode 100644 index 0000000000..7b52d680e2 --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/flyToMeasurementPoints.ts @@ -0,0 +1,23 @@ +import { BoundingSphere, type Cartesian3, type Scene } from "@carma/cesium"; + +import { flyToBoundingSphereExtent } from "@carma-mapping/engines/cesium/api"; + +const FLY_TO_MIN_RADIUS_METERS = 50; +const FLY_TO_PADDING_FACTOR = 1.1; + +export const flyToMeasurementPoints = ( + scene: Scene | null | undefined, + points: readonly Cartesian3[] +) => { + if (!scene || scene.isDestroyed() || points.length === 0) { + return; + } + + const sphere = BoundingSphere.fromPoints([...points]); + sphere.radius = Math.max(sphere.radius, FLY_TO_MIN_RADIUS_METERS); + + flyToBoundingSphereExtent(scene.camera, sphere, { + minRange: FLY_TO_MIN_RADIUS_METERS, + paddingFactor: FLY_TO_PADDING_FACTOR, + }); +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/index.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/index.ts deleted file mode 100644 index 7ffa012667..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "../useCesiumOverlaySync"; -export * from "../useCesiumSceneVisibilityIndex"; diff --git a/libraries/mapping/annotations/cesium/src/lib/utils/occlusionDetection.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/occlusionDetection.ts similarity index 67% rename from libraries/mapping/annotations/cesium/src/lib/utils/occlusionDetection.ts rename to libraries/mapping/annotations/cesium/src/lib/hooks/scene/occlusionDetection.ts index a4e6e0b93c..45d670ae22 100644 --- a/libraries/mapping/annotations/cesium/src/lib/utils/occlusionDetection.ts +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/occlusionDetection.ts @@ -72,36 +72,3 @@ export function isPointOccluded( return false; } - -/** - * Checks if a point is within the viewport bounds with optional padding - * @param canvasPosition - The screen coordinates to check - * @param canvasWidth - Width of the canvas - * @param canvasHeight - Height of the canvas - * @param paddingHorizontal - Horizontal padding in pixels (default: 0) - * @param paddingVertical - Vertical padding in pixels (default: uses paddingHorizontal) - * @returns true if the point is within viewport bounds - */ -export function isPointInViewport( - canvasPosition: Cartesian2, - canvasWidth: number, - canvasHeight: number, - paddingHorizontal: number = 0, - paddingVertical?: number -): boolean { - if ( - !isFiniteCartesian2(canvasPosition) || - !Number.isFinite(canvasWidth) || - !Number.isFinite(canvasHeight) - ) { - return false; - } - - const verticalPadding = paddingVertical ?? paddingHorizontal; - return ( - canvasPosition.x >= -paddingHorizontal && - canvasPosition.x <= canvasWidth + paddingHorizontal && - canvasPosition.y >= -verticalPadding && - canvasPosition.y <= canvasHeight + verticalPadding - ); -} diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/pointQuerySampling.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/pointQuerySampling.ts new file mode 100644 index 0000000000..90cf64bc78 --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/pointQuerySampling.ts @@ -0,0 +1,97 @@ +import { + Cartesian2, + Cartesian3, + GUIDE_NORMAL_EPSILON_SQUARED, + getLocalUpDirectionAtPosition, + type Scene, +} from "@carma/cesium"; + +const POINTER_NORMAL_SAMPLE_OFFSET_PX = 2; + +export const pickScenePositionAtScreenPosition = ( + scene: Scene, + screenPosition: Cartesian2 +): Cartesian3 | null => scene.pickPosition(screenPosition) ?? null; + +export const pickGlobePositionAtScreenPosition = ( + scene: Scene, + screenPosition: Cartesian2 +): Cartesian3 | null => { + const pickRay = scene.camera.getPickRay(screenPosition); + if (!pickRay) return null; + return scene.globe.pick(pickRay, scene) ?? null; +}; + +export const sampleSurfaceNormalAtScreenPosition = ( + scene: Scene, + screenPosition: Cartesian2, + centerPosition: Cartesian3 +): Cartesian3 => { + const rightPosition = pickScenePositionAtScreenPosition( + scene, + new Cartesian2( + screenPosition.x + POINTER_NORMAL_SAMPLE_OFFSET_PX, + screenPosition.y + ) + ); + const leftPosition = pickScenePositionAtScreenPosition( + scene, + new Cartesian2( + screenPosition.x - POINTER_NORMAL_SAMPLE_OFFSET_PX, + screenPosition.y + ) + ); + const upPosition = pickScenePositionAtScreenPosition( + scene, + new Cartesian2( + screenPosition.x, + screenPosition.y - POINTER_NORMAL_SAMPLE_OFFSET_PX + ) + ); + const downPosition = pickScenePositionAtScreenPosition( + scene, + new Cartesian2( + screenPosition.x, + screenPosition.y + POINTER_NORMAL_SAMPLE_OFFSET_PX + ) + ); + + if (!rightPosition || !leftPosition || !upPosition || !downPosition) { + return getLocalUpDirectionAtPosition(centerPosition); + } + + const tangentX = Cartesian3.subtract( + rightPosition, + leftPosition, + new Cartesian3() + ); + const tangentY = Cartesian3.subtract( + downPosition, + upPosition, + new Cartesian3() + ); + if ( + Cartesian3.magnitudeSquared(tangentX) <= GUIDE_NORMAL_EPSILON_SQUARED || + Cartesian3.magnitudeSquared(tangentY) <= GUIDE_NORMAL_EPSILON_SQUARED + ) { + return getLocalUpDirectionAtPosition(centerPosition); + } + + const sampledNormal = Cartesian3.cross(tangentX, tangentY, new Cartesian3()); + if ( + Cartesian3.magnitudeSquared(sampledNormal) <= GUIDE_NORMAL_EPSILON_SQUARED + ) { + return getLocalUpDirectionAtPosition(centerPosition); + } + + const normalizedNormal = Cartesian3.normalize( + sampledNormal, + new Cartesian3() + ); + const localUp = getLocalUpDirectionAtPosition(centerPosition); + if (Cartesian3.dot(normalizedNormal, localUp) < 0) { + return Cartesian3.negate(normalizedNormal, new Cartesian3()); + } + + return normalizedNormal; +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/utils/sceneVisibilityIndex.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/sceneVisibilityIndex.ts similarity index 100% rename from libraries/mapping/annotations/cesium/src/lib/utils/sceneVisibilityIndex.ts rename to libraries/mapping/annotations/cesium/src/lib/hooks/scene/sceneVisibilityIndex.ts diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumOverlaySync.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumOverlaySync.ts new file mode 100644 index 0000000000..12d49e1dc8 --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumOverlaySync.ts @@ -0,0 +1,23 @@ +import { useCallback, useEffect, useRef } from "react"; + +import { type Scene } from "@carma/cesium"; + +export const useCesiumOverlaySync = (scene: Scene | null) => { + const overlayUpdateRef = useRef<(() => void) | null>(null); + + useEffect(() => { + if (!scene || scene.isDestroyed()) return; + + const removePreRenderListener = scene.preRender.addEventListener(() => { + overlayUpdateRef.current?.(); + }); + + return () => { + removePreRenderListener(); + }; + }, [scene]); + + return useCallback((updateOverlayPositions: () => void) => { + overlayUpdateRef.current = updateOverlayPositions; + }, []); +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumPointQuery.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumPointQuery.ts new file mode 100644 index 0000000000..ad714d77ce --- /dev/null +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumPointQuery.ts @@ -0,0 +1,326 @@ +import { useEffect, useRef } from "react"; + +import { + Cartesian2, + Cartesian3, + ScreenSpaceEventHandler, + ScreenSpaceEventType, + type Scene, +} from "@carma/cesium"; +import { + pickGlobePositionAtScreenPosition, + pickScenePositionAtScreenPosition, + sampleSurfaceNormalAtScreenPosition, +} from "./pointQuerySampling"; + +const POINT_CLICK_DELAY_MS = 220; +const DOUBLE_CLICK_POSITION_THRESHOLD_PX = 12; +const CLEARED_POINTER_POSITION = new Cartesian2(Number.NaN, Number.NaN); +const INTERACTIVE_POINT_LABEL_SELECTOR = + '[data-point-label-interactive="true"]'; +const LABEL_OVERLAY_CONTAINER_SELECTOR = "#label-overlay-container"; + +const isSameDoubleClickArea = ( + previousPosition: Cartesian2 | null, + nextPosition: Cartesian2 +) => { + if (!previousPosition) { + return false; + } + + return ( + Cartesian2.distance(previousPosition, nextPosition) <= + DOUBLE_CLICK_POSITION_THRESHOLD_PX + ); +}; + +export type CesiumPointQueryCreatePayload = { + screenPosition: Cartesian2; + pickedPositionECEF: Cartesian3; + globePositionECEF: Cartesian3 | null; +}; + +export type CesiumPointQueryPointerMoveHandler = ( + positionECEF: Cartesian3 | null, + screenPosition: Cartesian2, + surfaceNormalECEF?: Cartesian3 | null +) => void; + +export type CesiumPointQueryOptions = { + enabled?: boolean; + hideCursorWhileEnabled?: boolean; + pointClickDelayMs?: number; + onBeforePointCreate?: ( + positionECEF: Cartesian3 | null, + screenPosition: Cartesian2 + ) => boolean; + onPointCreate?: (payload: CesiumPointQueryCreatePayload) => void; + onLineFinish?: () => void; + onPointerMove?: CesiumPointQueryPointerMoveHandler; +}; + +type CesiumPointQueryCallbacks = Pick< + CesiumPointQueryOptions, + "onBeforePointCreate" | "onPointCreate" | "onLineFinish" | "onPointerMove" +>; + +export const useCesiumPointQuery = ( + scene: Scene | null, + { + enabled = true, + hideCursorWhileEnabled = true, + pointClickDelayMs = POINT_CLICK_DELAY_MS, + onBeforePointCreate, + onPointCreate, + onLineFinish, + onPointerMove, + }: CesiumPointQueryOptions = {} +) => { + const pendingPointerMovePositionRef = useRef(null); + const pointerMoveFrameRef = useRef(null); + const callbacksRef = useRef({}); + callbacksRef.current = { + onBeforePointCreate, + onPointCreate, + onLineFinish, + onPointerMove, + }; + + useEffect(() => { + if (!scene || scene.isDestroyed()) return; + + scene.canvas.style.cursor = enabled && hideCursorWhileEnabled ? "none" : ""; + return () => { + if (!scene.isDestroyed()) { + scene.canvas.style.cursor = ""; + } + }; + }, [scene, enabled, hideCursorWhileEnabled]); + + useEffect(() => { + if (!scene || scene.isDestroyed() || !enabled) { + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + pendingPointerMovePositionRef.current = null; + callbacksRef.current.onPointerMove?.( + null, + CLEARED_POINTER_POSITION, + null + ); + return; + } + + const handler = new ScreenSpaceEventHandler(scene.canvas); + let clickTimeoutId: number | undefined; + let previousClickPosition: Cartesian2 | null = null; + let latestClickPosition: Cartesian2 | null = null; + + const clearCandidatePointerState = () => { + pendingPointerMovePositionRef.current = null; + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + callbacksRef.current.onPointerMove?.( + null, + CLEARED_POINTER_POSITION, + null + ); + scene.requestRender(); + }; + + const queuePointerMove = (screenPosition: Cartesian2) => { + pendingPointerMovePositionRef.current = Cartesian2.clone( + screenPosition, + new Cartesian2() + ); + + if (pointerMoveFrameRef.current === null) { + pointerMoveFrameRef.current = + window.requestAnimationFrame(flushPointerMove); + } + }; + + const handleCanvasPointerLeave = (event: MouseEvent) => { + const relatedTarget = event.relatedTarget; + if ( + relatedTarget instanceof Element && + (relatedTarget.closest(INTERACTIVE_POINT_LABEL_SELECTOR) || + relatedTarget.closest(LABEL_OVERLAY_CONTAINER_SELECTOR)) + ) { + return; + } + clearCandidatePointerState(); + }; + + const handleCanvasBlur = () => { + clearCandidatePointerState(); + }; + + const handleWindowBlur = () => { + clearCandidatePointerState(); + }; + + const handleDocumentVisibilityChange = () => { + if (document.visibilityState !== "visible") { + clearCandidatePointerState(); + } + }; + + const handleWindowPointerMove = (event: PointerEvent) => { + const canvasRect = scene.canvas.getBoundingClientRect(); + const insideCanvasBounds = + event.clientX >= canvasRect.left && + event.clientX <= canvasRect.right && + event.clientY >= canvasRect.top && + event.clientY <= canvasRect.bottom; + + if (!insideCanvasBounds) { + return; + } + + queuePointerMove( + new Cartesian2( + event.clientX - canvasRect.left, + event.clientY - canvasRect.top + ) + ); + }; + + scene.canvas.addEventListener("mouseleave", handleCanvasPointerLeave); + scene.canvas.addEventListener("blur", handleCanvasBlur); + window.addEventListener("blur", handleWindowBlur); + window.addEventListener("pointermove", handleWindowPointerMove, true); + document.addEventListener( + "visibilitychange", + handleDocumentVisibilityChange + ); + + const flushPointerMove = () => { + pointerMoveFrameRef.current = null; + if (!scene || scene.isDestroyed()) { + pendingPointerMovePositionRef.current = null; + return; + } + + const pendingPosition = pendingPointerMovePositionRef.current; + pendingPointerMovePositionRef.current = null; + if (!pendingPosition) { + return; + } + + const pickedPosition = pickScenePositionAtScreenPosition( + scene, + pendingPosition + ); + const sampledSurfaceNormal = pickedPosition + ? sampleSurfaceNormalAtScreenPosition( + scene, + pendingPosition, + pickedPosition + ) + : null; + callbacksRef.current.onPointerMove?.( + pickedPosition ?? null, + pendingPosition, + sampledSurfaceNormal + ); + + if ( + pendingPointerMovePositionRef.current && + pointerMoveFrameRef.current === null + ) { + pointerMoveFrameRef.current = + window.requestAnimationFrame(flushPointerMove); + } + }; + + const createPointAt = (screenPosition: Cartesian2) => { + const pickedPosition = pickScenePositionAtScreenPosition( + scene, + screenPosition + ); + + if ( + callbacksRef.current.onBeforePointCreate && + !callbacksRef.current.onBeforePointCreate( + pickedPosition ?? null, + screenPosition + ) + ) { + scene.requestRender(); + return; + } + + if (!pickedPosition) { + return; + } + + callbacksRef.current.onPointCreate?.({ + screenPosition, + pickedPositionECEF: pickedPosition, + globePositionECEF: pickGlobePositionAtScreenPosition( + scene, + screenPosition + ), + }); + + scene.requestRender(); + }; + + handler.setInputAction((event: { position: Cartesian2 }) => { + previousClickPosition = latestClickPosition + ? Cartesian2.clone(latestClickPosition, new Cartesian2()) + : null; + latestClickPosition = Cartesian2.clone(event.position, new Cartesian2()); + if (clickTimeoutId !== undefined) { + window.clearTimeout(clickTimeoutId); + } + clickTimeoutId = window.setTimeout(() => { + createPointAt(event.position); + clickTimeoutId = undefined; + }, pointClickDelayMs); + }, ScreenSpaceEventType.LEFT_CLICK); + + handler.setInputAction((event: { position: Cartesian2 }) => { + if (!isSameDoubleClickArea(previousClickPosition, event.position)) { + return; + } + + if (clickTimeoutId !== undefined) { + window.clearTimeout(clickTimeoutId); + clickTimeoutId = undefined; + } + callbacksRef.current.onLineFinish?.(); + }, ScreenSpaceEventType.LEFT_DOUBLE_CLICK); + + handler.setInputAction((event: { endPosition: Cartesian2 }) => { + queuePointerMove(event.endPosition); + }, ScreenSpaceEventType.MOUSE_MOVE); + + return () => { + if (clickTimeoutId !== undefined) { + window.clearTimeout(clickTimeoutId); + clickTimeoutId = undefined; + } + scene.canvas.removeEventListener("mouseleave", handleCanvasPointerLeave); + scene.canvas.removeEventListener("blur", handleCanvasBlur); + window.removeEventListener("blur", handleWindowBlur); + window.removeEventListener("pointermove", handleWindowPointerMove, true); + document.removeEventListener( + "visibilitychange", + handleDocumentVisibilityChange + ); + if (pointerMoveFrameRef.current !== null) { + window.cancelAnimationFrame(pointerMoveFrameRef.current); + pointerMoveFrameRef.current = null; + } + pendingPointerMovePositionRef.current = null; + handler.destroy(); + }; + }, [scene, enabled, pointClickDelayMs]); +}; + +export default useCesiumPointQuery; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumSceneVisibilityIndex.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumSceneVisibilityIndex.ts similarity index 98% rename from libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumSceneVisibilityIndex.ts rename to libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumSceneVisibilityIndex.ts index 3ed58170bc..2e89f7a554 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumSceneVisibilityIndex.ts +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumSceneVisibilityIndex.ts @@ -14,12 +14,10 @@ import { type Cartesian2, type Scene, } from "@carma/cesium"; +import { isPointInViewport } from "@carma-mapping/annotations/core"; import type { CssPixelPosition } from "@carma/units/types"; -import { - isPointInViewport, - isPointOccluded, -} from "../utils/occlusionDetection"; +import { isPointOccluded } from "./occlusionDetection"; import { DEFAULT_OCCLUSION_TOLERANCE_METERS, DEFAULT_VIEWPORT_PADDING_HORIZONTAL, @@ -37,7 +35,7 @@ import { type PointEntry, type RegisteredPoint, type VisibilityRegistry, -} from "../utils/sceneVisibilityIndex"; +} from "./sceneVisibilityIndex"; export type SceneVisibilityIndexedPoint = { id: string; @@ -130,7 +128,7 @@ export const useCesiumSceneVisibilityIndex = ( } as CssPixelPosition; const isInViewport = isPointInViewport( - canvasPosition, + screenPosition, scene.canvas.clientWidth, scene.canvas.clientHeight, viewportPaddingHorizontal, diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/useCesiumAreaLabelViewProjector.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumViewProjector.ts similarity index 92% rename from libraries/mapping/annotations/cesium/src/lib/hooks/utils/useCesiumAreaLabelViewProjector.ts rename to libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumViewProjector.ts index ef91f3ff0c..cb68f0fa34 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/useCesiumAreaLabelViewProjector.ts +++ b/libraries/mapping/annotations/cesium/src/lib/hooks/scene/useCesiumViewProjector.ts @@ -9,7 +9,6 @@ import { type Cartesian3Json, type Matrix4ConstructorArgs, } from "@carma/cesium"; -import { type AreaLabelViewProjector } from "@carma-mapping/annotations/core"; import type { CssPixelPosition } from "@carma/units/types"; const WORLD_POINT_SCRATCH = new Cartesian3(); @@ -18,9 +17,7 @@ const MATRIX4_ARRAY_SCRATCH = new Array(16).fill( 0 ) as Matrix4ConstructorArgs; -export const useCesiumAreaLabelViewProjector = ( - scene: Scene | null -): AreaLabelViewProjector => { +export const useCesiumViewProjector = (scene: Scene | null) => { const getViewState = useCallback(() => { if (!scene || scene.isDestroyed()) return null; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useAnnotationMoveGizmoAdapter.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useAnnotationMoveGizmoAdapter.ts deleted file mode 100644 index 29aba4bcc4..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useAnnotationMoveGizmoAdapter.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { useMemo } from "react"; - -import { Cartesian3, type Scene } from "@carma/cesium"; -import { useCesiumPointMoveGizmo } from "@carma-mapping/gizmo/cesium"; - -import { type PointAnnotationEntry } from "../types/AnnotationTypes"; - -export type AnnotationMoveGizmoAdapterOptions = { - scene: Scene | null; - points: PointAnnotationEntry[]; - moveGizmoPointId?: string | null; - moveGizmoAxisDirection?: Cartesian3 | null; - moveGizmoAxisTitle?: string | null; - moveGizmoPreferredAxisId?: string | null; - moveGizmoAxisCandidates?: Array<{ - id: string; - direction: Cartesian3; - color?: string; - title?: string | null; - }> | null; - moveGizmoSnapPlaneDragToGround?: boolean; - moveGizmoShowRotationHandle?: boolean; - radius: number; - onMoveGizmoPointPositionChange?: ( - pointId: string, - nextPosition: Cartesian3 - ) => void; - onMoveGizmoDragStateChange?: (isDragging: boolean) => void; - onMoveGizmoAxisChange?: ( - axisDirection: Cartesian3, - axisTitle?: string | null - ) => void; - onMoveGizmoExit?: () => void; -}; - -export const useAnnotationMoveGizmoAdapter = ({ - scene, - points, - moveGizmoPointId = null, - moveGizmoAxisDirection = null, - moveGizmoAxisTitle = null, - moveGizmoPreferredAxisId = null, - moveGizmoAxisCandidates = null, - moveGizmoSnapPlaneDragToGround = false, - moveGizmoShowRotationHandle = true, - radius, - onMoveGizmoPointPositionChange, - onMoveGizmoDragStateChange, - onMoveGizmoAxisChange, - onMoveGizmoExit, -}: AnnotationMoveGizmoAdapterOptions) => { - const moveGizmoPoints = useMemo( - () => - points.map((point) => { - if (!point.verticalOffsetAnchorECEF) { - return point; - } - return { - ...point, - geometryECEF: new Cartesian3( - point.verticalOffsetAnchorECEF.x, - point.verticalOffsetAnchorECEF.y, - point.verticalOffsetAnchorECEF.z - ), - }; - }), - [points] - ); - - useCesiumPointMoveGizmo(scene, { - points: moveGizmoPoints, - movePointId: moveGizmoPointId, - axisDirection: moveGizmoAxisDirection, - axisTitle: moveGizmoAxisTitle, - preferredAxisId: moveGizmoPreferredAxisId, - axisCandidates: moveGizmoAxisCandidates, - snapPlaneDragToGround: moveGizmoSnapPlaneDragToGround, - showRotationHandle: moveGizmoShowRotationHandle, - radius, - onPointPositionChange: onMoveGizmoPointPositionChange, - onDragStateChange: onMoveGizmoDragStateChange, - onAxisDirectionChange: onMoveGizmoAxisChange, - onExit: onMoveGizmoExit, - }); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumDistanceVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumDistanceVisualizer.ts deleted file mode 100644 index 41d2c8a6c1..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumDistanceVisualizer.ts +++ /dev/null @@ -1,1733 +0,0 @@ -/* @refresh reset */ -import { - createElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { - BoundingSphere, - Cartesian3, - Color, - SceneTransforms, - defined, - getDegreesFromCartesian, - getArcPointsInSpannedPlane, - type Scene, -} from "@carma/cesium"; -import { clamp } from "@carma-commons/math"; -import { - applyMidpointMarkerOverlayLayout, - formatNumber, - getCustomPointAnnotationName, - hasVisibleDistanceRelationComponentLines, - isDistanceRelationHorizontalLineVisible, - isDistanceRelationVerticalLineVisible, - applyRightAngleCornerOverlayLayout, - MidpointMarkerOverlay, - REFERENCE_LINE_EPSILON_METERS, - RightAngleCornerOverlay, - resolveDistanceRelation, - type ResolvedDistanceRelation, - type DistanceRelationRenderContext, - useDistancePairLabelOverlays, -} from "@carma-mapping/annotations/core"; -import type { CssPixelPosition } from "@carma/units/types"; -import { - createLineVisualizer, - type LineVisualizer, -} from "@carma-mapping/engines/cesium/legacy"; -import { - useLabelOverlay, - useLineVisualizers, - type LineVisualizerData, -} from "@carma-providers/label-overlay"; - -import { - type DirectLineLabelMode, - type PointDistanceRelation, - type PointAnnotationEntry, - type ReferenceLineLabelKind, -} from "../types/AnnotationTypes"; - -export type CesiumDistanceVisualizerOptions = { - distanceRelations?: PointDistanceRelation[]; - onDistanceLineLabelToggle?: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - onDistanceLineClick?: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - onDistanceRelationMidpointClick?: (relationId: string) => void; - lineLabelMinDistancePx?: number; - onDistanceRelationCornerClick?: (relationId: string) => void; - cumulativeDistanceByRelationId?: Readonly>; - pointMarkerBadgeByPointId?: Readonly< - Record< - string, - { - text: string; - backgroundColor?: string; - textColor?: string; - } - > - >; - livePreviewDistanceLine?: { - anchorPointECEF: Cartesian3; - targetPointECEF: Cartesian3; - showDirectLine: boolean; - showVerticalLine: boolean; - showHorizontalLine: boolean; - } | null; - distanceRelationRenderContext: DistanceRelationRenderContext; - renderDomVisuals?: boolean; - renderCesiumCoreVisuals?: boolean; -}; - -// EN component color: light mix of the standard East (red) and North (green) axis colors. -const REFERENCE_COMPONENT_HORIZONTAL_COLOR = "rgba(188, 194, 102, 0.95)"; -// U component color: lighter blue for better readability and a softer look. -const REFERENCE_COMPONENT_VERTICAL_COLOR = "rgba(111, 168, 255, 0.96)"; -const REFERENCE_COMPONENT_ARC_COLOR = "rgba(246, 248, 255, 0.95)"; -const REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX = 1.25; -const CORNER_OVERLAY_ID_PREFIX = "distance-right-angle-corner"; -const MIDPOINT_OVERLAY_ID_PREFIX = "distance-edge-midpoint"; -const CORNER_OVERLAY_MIN_BOX_PX = 20; -const CORNER_OVERLAY_PADDING_PX = 6; -const CORNER_OVERLAY_TARGET_RADIUS_PX = 20; -const CORNER_OVERLAY_DOT_RADIUS_PX = - REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX / 2; -const CORNER_OVERLAY_SEGMENTS = 20; -const MIDPOINT_MARKER_HIT_TARGET_PX = 14; -const MIDPOINT_MARKER_TICK_LENGTH_PX = 8; -const MIDPOINT_MARKER_TICK_WIDTH_PX = 1.25; -const LABEL_REFERENCE_MIN_DISTANCE_PX = 24; -const LABEL_REFERENCE_MAX_DISTANCE_PX = 48; -const LABEL_INSIDE_BLEND_FACTOR = 0.35; -const VERTICAL_COMPONENT_LABEL_OFFSET_PX = 8; -const VERTICAL_LABEL_SIDE_SWITCH_THRESHOLD_PX = 4; - -const resolveStableSideSign = ( - signedDistance: number, - previousSign: -1 | 1 | undefined, - flipThresholdPx = VERTICAL_LABEL_SIDE_SWITCH_THRESHOLD_PX -): -1 | 1 => { - if (!Number.isFinite(signedDistance)) return previousSign ?? 1; - const nextSign: -1 | 1 = signedDistance >= 0 ? 1 : -1; - if (!previousSign || previousSign === nextSign) return nextSign; - if (Math.abs(signedDistance) < flipThresholdPx) return previousSign; - return nextSign; -}; - -const destroyLineVisualizerMap = (lineRefs: { - current: Record; -}) => { - Object.values(lineRefs.current).forEach((lineVisualizer) => { - lineVisualizer.destroy(); - }); - lineRefs.current = {}; -}; - -const destroyLineVisualizerRef = (lineRef: { - current: LineVisualizer | null; -}) => { - if (!lineRef.current) return; - lineRef.current.destroy(); - lineRef.current = null; -}; - -export const useCesiumDistanceVisualizer = ( - scene: Scene | null, - points: PointAnnotationEntry[], - { - distanceRelations = [], - onDistanceLineLabelToggle, - onDistanceLineClick, - onDistanceRelationMidpointClick, - lineLabelMinDistancePx = 50, - onDistanceRelationCornerClick, - cumulativeDistanceByRelationId, - pointMarkerBadgeByPointId, - livePreviewDistanceLine = null, - distanceRelationRenderContext, - renderDomVisuals = false, - renderCesiumCoreVisuals = true, - }: CesiumDistanceVisualizerOptions -) => { - const directLineRefs = useRef>({}); - const verticalLineRefs = useRef>({}); - const horizontalLineRefs = useRef>({}); - const previewDirectLineRef = useRef(null); - const previewVerticalLineRef = useRef(null); - const previewHorizontalLineRef = useRef(null); - const cornerOverlayIdsRef = useRef([]); - const midpointOverlayIdsRef = useRef([]); - const verticalLabelSideByRelationIdRef = useRef>({}); - const previewVerticalLabelSideRef = useRef<-1 | 1>(1); - const [cameraPitch, setCameraPitch] = useState(-Math.PI / 4); - - const { addLabelOverlayElement, removeLabelOverlayElement } = - useLabelOverlay(); - - const pointsById = useMemo(() => { - const map = new Map(); - points.forEach((point) => { - map.set(point.id, point); - }); - return map; - }, [points]); - const enclosedPointLabelById = useMemo(() => { - const labelById: Record = {}; - points.forEach((point, index) => { - labelById[point.id] = - getCustomPointAnnotationName(point.name) ?? - pointMarkerBadgeByPointId?.[point.id]?.text ?? - `${index + 1}`; - }); - return labelById; - }, [pointMarkerBadgeByPointId, points]); - const defaultPointLabelById = useMemo(() => { - const labelById: Record = {}; - points.forEach((point, index) => { - labelById[point.id] = - pointMarkerBadgeByPointId?.[point.id]?.text ?? `${index + 1}`; - }); - return labelById; - }, [pointMarkerBadgeByPointId, points]); - - const splitMarkerRelationIdSet = - distanceRelationRenderContext.polygonEdgeRelationIds; - const planarPolygonSharedEdgeRelationIdSet = - distanceRelationRenderContext.planarPolygonSharedEdgeRelationIds; - const midpointTickRelationIdSet = - distanceRelationRenderContext.midpointTickRelationIds; - - useEffect(() => { - if (livePreviewDistanceLine) return; - previewVerticalLabelSideRef.current = 1; - }, [livePreviewDistanceLine]); - - useEffect(() => { - if (!scene || scene.isDestroyed()) return; - const camera = scene.camera; - - const updatePitch = () => { - const nextPitch = camera.pitch; - setCameraPitch((prev) => - Math.abs(nextPitch - prev) > 0.001 ? nextPitch : prev - ); - }; - - updatePitch(); - const removeChangedListener = camera.changed.addEventListener(updatePitch); - const removeMoveEndListener = camera.moveEnd.addEventListener(updatePitch); - - return () => { - removeChangedListener?.(); - removeMoveEndListener?.(); - }; - }, [scene]); - - const edgeRelationOwnerGroupIdSet = - distanceRelationRenderContext.focusedRelationIds; - const selectedOrActiveOpenPolylineEdgeRelationIdSet = - distanceRelationRenderContext.selectedOrActiveOpenPolylineRelationIds; - const duplicateFacadeOpposingEdgeRelationIdSet = - distanceRelationRenderContext.duplicateFacadeOpposingRelationIds; - - const resolvedRelations = useMemo( - () => - distanceRelations - .map((relation) => resolveDistanceRelation(relation, pointsById)) - .filter((relation): relation is ResolvedDistanceRelation => - Boolean(relation) - ), - [distanceRelations, pointsById] - ); - - useEffect(() => { - const activeRelationIdSet = new Set( - resolvedRelations.map(({ relation }) => relation.id) - ); - Object.keys(verticalLabelSideByRelationIdRef.current).forEach( - (relationId) => { - if (!activeRelationIdSet.has(relationId)) { - delete verticalLabelSideByRelationIdRef.current[relationId]; - } - } - ); - }, [resolvedRelations]); - - const distancePairLabelEntries = useMemo( - () => - resolvedRelations - .filter( - ({ relation }) => - relation.showDirectLine && - !splitMarkerRelationIdSet.has(relation.id) - ) - .map(({ relation, pointA, pointB }) => { - const higherPoint = - pointA.geometryWGS84.altitude >= pointB.geometryWGS84.altitude - ? pointA - : pointB; - const lowerPoint = higherPoint.id === pointA.id ? pointB : pointA; - const higherLabel = defaultPointLabelById[higherPoint.id]; - const lowerLabel = defaultPointLabelById[lowerPoint.id]; - if (!higherLabel || !lowerLabel) return null; - if (higherLabel === lowerLabel) { - // Avoid duplicate compact badges like "C" + "C" for the same - // standalone distance component. - return null; - } - - return { - relationId: relation.id, - anchorPointId: higherPoint.id, - text: `${higherLabel} ↔ ${lowerLabel}`, - hasCompanionPointLabel: !higherPoint.distanceAdhocNode, - }; - }) - .filter( - ( - entry - ): entry is { - relationId: string; - anchorPointId: string; - text: string; - hasCompanionPointLabel: boolean; - } => Boolean(entry) - ), - [defaultPointLabelById, resolvedRelations, splitMarkerRelationIdSet] - ); - - const distancePairLabelObstacles = useMemo( - () => - points.map((point) => ({ - id: `point-label-obstacle-${point.id}`, - anchorPointId: point.id, - text: enclosedPointLabelById[point.id] ?? "", - })), - [enclosedPointLabelById, points] - ); - - const resolvePointCanvasPositionById = useCallback( - (pointId: string) => { - if (!scene || scene.isDestroyed()) return null; - const point = pointsById.get(pointId); - if (!point) return null; - const anchor = SceneTransforms.worldToWindowCoordinates( - scene, - point.geometryECEF - ); - if (!defined(anchor)) return null; - return { x: anchor.x, y: anchor.y } as CssPixelPosition; - }, - [pointsById, scene] - ); - - const viewportWidth = Math.max( - 1, - scene?.canvas.clientWidth || scene?.canvas.width || 1 - ); - const viewportHeight = Math.max( - 1, - scene?.canvas.clientHeight || scene?.canvas.height || 1 - ); - - useDistancePairLabelOverlays({ - entries: renderDomVisuals ? distancePairLabelEntries : [], - obstacles: renderDomVisuals ? distancePairLabelObstacles : [], - cameraPitch, - viewportWidth, - viewportHeight, - resolveAnchorCanvasPosition: resolvePointCanvasPositionById, - addLabelOverlayElement, - removeLabelOverlayElement, - }); - - const overlayLines = useMemo(() => { - if (!renderDomVisuals) { - return []; - } - if (!scene || scene.isDestroyed()) { - return []; - } - - const lines: LineVisualizerData[] = []; - - resolvedRelations.forEach( - ({ - relation, - pointA, - pointB, - anchorPoint, - targetPoint, - auxiliaryPoint, - }) => { - const getWorldToScreen = ( - position: Cartesian3 - ): CssPixelPosition | null => { - if (!scene || scene.isDestroyed()) return null; - const p = SceneTransforms.worldToWindowCoordinates(scene, position); - return defined(p) ? ({ x: p.x, y: p.y } as CssPixelPosition) : null; - }; - const highestPoint = - pointA.geometryWGS84.altitude >= pointB.geometryWGS84.altitude - ? pointA - : pointB; - - type ScreenTriangleData = { - anchor: CssPixelPosition; - target: CssPixelPosition; - aux: CssPixelPosition; - centroid: CssPixelPosition; - highest: CssPixelPosition; - }; - - let cachedTriangleFrameNumber: number | null = null; - let cachedTriangle: ScreenTriangleData | null = null; - - const getSceneFrameNumber = (): number | null => { - const frameNumber = ( - scene as unknown as { frameState?: { frameNumber?: number } } - ).frameState?.frameNumber; - return typeof frameNumber === "number" ? frameNumber : null; - }; - - const computeScreenTriangle = (): ScreenTriangleData | null => { - const anchor = getWorldToScreen(anchorPoint.geometryECEF); - const target = getWorldToScreen(targetPoint.geometryECEF); - const aux = getWorldToScreen(auxiliaryPoint); - const highest = getWorldToScreen(highestPoint.geometryECEF); - if (!anchor || !target || !aux || !highest) return null; - return { - anchor, - target, - aux, - highest, - centroid: { - x: (anchor.x + target.x + aux.x) / 3, - y: (anchor.y + target.y + aux.y) / 3, - } as CssPixelPosition, - }; - }; - - const getScreenTriangle = (): ScreenTriangleData | null => { - const frameNumber = getSceneFrameNumber(); - if ( - frameNumber !== null && - frameNumber === cachedTriangleFrameNumber - ) { - return cachedTriangle; - } - - const triangle = computeScreenTriangle(); - if (frameNumber !== null) { - cachedTriangleFrameNumber = frameNumber; - cachedTriangle = triangle; - } - return triangle; - }; - - const getScreenAnchor = (): CssPixelPosition | null => - getScreenTriangle()?.anchor ?? null; - const getScreenTarget = (): CssPixelPosition | null => - getScreenTriangle()?.target ?? null; - const getScreenAux = (): CssPixelPosition | null => - getScreenTriangle()?.aux ?? null; - - const buildStableOutsideReferencePoint = ( - start: CssPixelPosition, - end: CssPixelPosition, - insidePoint: CssPixelPosition - ): CssPixelPosition | null => { - const dx = end.x - start.x; - const dy = end.y - start.y; - const lineLength = Math.hypot(dx, dy); - if (lineLength <= 1e-3) return null; - const midX = (start.x + end.x) * 0.5; - const midY = (start.y + end.y) * 0.5; - const normalX = -dy / lineLength; - const normalY = dx / lineLength; - const dot = - (insidePoint.x - midX) * normalX + (insidePoint.y - midY) * normalY; - const insideSign = dot >= 0 ? 1 : -1; - const refDistancePx = clamp( - lineLength * 0.2, - LABEL_REFERENCE_MIN_DISTANCE_PX, - LABEL_REFERENCE_MAX_DISTANCE_PX - ); - return { - x: midX + normalX * insideSign * refDistancePx, - y: midY + normalY * insideSign * refDistancePx, - } as CssPixelPosition; - }; - - const getStableInsidePointForDirectAndHorizontal = - (): CssPixelPosition | null => { - const triangle = getScreenTriangle(); - if (!triangle) return null; - const auxHeight = targetPoint.geometryWGS84.altitude; - const highestHeight = highestPoint.geometryWGS84.altitude; - const elevationDriverPoint = - auxHeight < highestHeight - REFERENCE_LINE_EPSILON_METERS - ? triangle.highest - : triangle.aux; - return { - x: - elevationDriverPoint.x + - (triangle.centroid.x - elevationDriverPoint.x) * - LABEL_INSIDE_BLEND_FACTOR, - y: - elevationDriverPoint.y + - (triangle.centroid.y - elevationDriverPoint.y) * - LABEL_INSIDE_BLEND_FACTOR, - } as CssPixelPosition; - }; - - const getDirectLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const triangle = getScreenTriangle(); - const insidePoint = getStableInsidePointForDirectAndHorizontal(); - if (!triangle || !insidePoint) return null; - return buildStableOutsideReferencePoint( - triangle.anchor, - triangle.target, - insidePoint - ); - }; - - const getHorizontalLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const triangle = getScreenTriangle(); - const insidePoint = getStableInsidePointForDirectAndHorizontal(); - if (!triangle || !insidePoint) return null; - return buildStableOutsideReferencePoint( - triangle.aux, - triangle.target, - insidePoint - ); - }; - - const getVerticalLineScreenData = (): { - start: CssPixelPosition; - end: CssPixelPosition; - inside: CssPixelPosition; - insideSign: -1 | 1; - midX: number; - midY: number; - normalX: number; - normalY: number; - lineLength: number; - } | null => { - const triangle = getScreenTriangle(); - if (!triangle) return null; - - let start = triangle.anchor; - let end = triangle.aux; - const inside = triangle.target; - - const recompute = ( - s: CssPixelPosition, - e: CssPixelPosition - ): { - midX: number; - midY: number; - normalX: number; - normalY: number; - lineLength: number; - insideDot: number; - } | null => { - const dx = e.x - s.x; - const dy = e.y - s.y; - const lineLength = Math.hypot(dx, dy); - if (lineLength <= 1e-3) return null; - const midX = (s.x + e.x) * 0.5; - const midY = (s.y + e.y) * 0.5; - const normalX = -dy / lineLength; - const normalY = dx / lineLength; - const insideDot = - (inside.x - midX) * normalX + (inside.y - midY) * normalY; - return { - midX, - midY, - normalX, - normalY, - lineLength, - insideDot, - }; - }; - - let edgeData = recompute(start, end); - if (!edgeData) return null; - - const stableInsideSign = resolveStableSideSign( - edgeData.insideDot, - verticalLabelSideByRelationIdRef.current[relation.id] - ); - verticalLabelSideByRelationIdRef.current[relation.id] = - stableInsideSign; - - // Canonical direction for vertical line labels: - // keep triangle interior on the clockwise/right side of the directed edge. - if (stableInsideSign < 0) { - start = triangle.aux; - end = triangle.anchor; - edgeData = recompute(start, end); - if (!edgeData) return null; - } - - return { - start, - end, - inside, - insideSign: stableInsideSign, - midX: edgeData.midX, - midY: edgeData.midY, - normalX: edgeData.normalX, - normalY: edgeData.normalY, - lineLength: edgeData.lineLength, - }; - }; - - const getVerticalLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const edge = getVerticalLineScreenData(); - if (!edge) return null; - const refDistancePx = clamp( - edge.lineLength * 0.2, - LABEL_REFERENCE_MIN_DISTANCE_PX, - LABEL_REFERENCE_MAX_DISTANCE_PX - ); - - // Use an inside-side reference point; overlay logic places the label on - // the opposite side, i.e. outside the triangle. - const nextReferencePoint = { - x: edge.midX + edge.normalX * edge.insideSign * refDistancePx, - y: edge.midY + edge.normalY * edge.insideSign * refDistancePx, - } as CssPixelPosition; - return nextReferencePoint; - }; - - const verticalDistanceMeters = Cartesian3.distance( - anchorPoint.geometryECEF, - auxiliaryPoint - ); - const horizontalDistanceMeters = Cartesian3.distance( - auxiliaryPoint, - targetPoint.geometryECEF - ); - const isPolygonEdgeRelation = splitMarkerRelationIdSet.has(relation.id); - const isSelectedOrActiveEdgeRelation = - edgeRelationOwnerGroupIdSet.has(relation.id) || - selectedOrActiveOpenPolylineEdgeRelationIdSet.has(relation.id); - const forceComponentLabelsForSelectedOrActivePolylineEdges = - isPolygonEdgeRelation && isSelectedOrActiveEdgeRelation; - const showVerticalLabel = - (forceComponentLabelsForSelectedOrActivePolylineEdges || - (relation.labelVisibilityByKind?.vertical ?? true)) && - verticalDistanceMeters > REFERENCE_LINE_EPSILON_METERS; - const showHorizontalLabel = - (forceComponentLabelsForSelectedOrActivePolylineEdges || - (relation.labelVisibilityByKind?.horizontal ?? true)) && - horizontalDistanceMeters > REFERENCE_LINE_EPSILON_METERS; - - if (relation.showDirectLine) { - const forceSegmentLabelsForVisiblePolylineEdges = - isPolygonEdgeRelation && isSelectedOrActiveEdgeRelation; - const directLabelMode: DirectLineLabelMode = - forceSegmentLabelsForVisiblePolylineEdges - ? "segment" - : relation.directLabelMode ?? "segment"; - const directLabelVisibilityEnabled = - forceSegmentLabelsForVisiblePolylineEdges - ? true - : relation.labelVisibilityByKind?.direct ?? true; - const shouldShowPolygonEdgeLengthLabel = - !isPolygonEdgeRelation || - forceSegmentLabelsForVisiblePolylineEdges || - edgeRelationOwnerGroupIdSet.has(relation.id); - const segmentDistanceMeters = Cartesian3.distance( - pointA.geometryECEF, - pointB.geometryECEF - ); - const cumulativeDistanceMeters = - cumulativeDistanceByRelationId?.[relation.id] ?? - segmentDistanceMeters; - const directLabelDistanceMeters = - directLabelMode === "cumulative" - ? cumulativeDistanceMeters - : segmentDistanceMeters; - const showDirectLabel = - directLabelVisibilityEnabled && - directLabelMode !== "none" && - !planarPolygonSharedEdgeRelationIdSet.has(relation.id) && - shouldShowPolygonEdgeLengthLabel && - !duplicateFacadeOpposingEdgeRelationIdSet.has(relation.id); - const onDirectLineClick = onDistanceLineClick - ? () => onDistanceLineClick(relation.id, "direct") - : undefined; - const onDirectLabelClick = onDistanceLineLabelToggle - ? () => onDistanceLineLabelToggle(relation.id, "direct") - : undefined; - lines.push({ - id: `reference-direct-${relation.id}`, - getCanvasLine: () => { - const start = getScreenAnchor(); - const end = getScreenTarget(); - if (!start || !end) return null; - return { start, end }; - }, - getLabelOutsideReferencePoint: getDirectLabelOutsideReferencePoint, - stroke: "rgba(255, 255, 255, 0.9)", - strokeWidth: 1.5, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: showDirectLabel - ? `${formatNumber(directLabelDistanceMeters)} m` - : undefined, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelMinLineLengthPx: forceSegmentLabelsForVisiblePolylineEdges - ? 0 - : lineLabelMinDistancePx, - onLineClick: onDirectLineClick, - onLabelClick: onDirectLabelClick, - }); - } - - if (isDistanceRelationVerticalLineVisible(relation)) { - const onVerticalLineClick = onDistanceLineLabelToggle - ? () => onDistanceLineLabelToggle(relation.id, "vertical") - : undefined; - lines.push({ - id: `reference-vertical-${relation.id}`, - getCanvasLine: () => { - const edge = getVerticalLineScreenData(); - if (!edge) return null; - return { start: edge.start, end: edge.end }; - }, - getLabelOutsideReferencePoint: - getVerticalLabelOutsideReferencePoint, - stroke: REFERENCE_COMPONENT_VERTICAL_COLOR, - strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: showVerticalLabel - ? `${formatNumber(verticalDistanceMeters)} m` - : undefined, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelRotationMode: "clockwise", - labelOffsetPx: VERTICAL_COMPONENT_LABEL_OFFSET_PX, - labelDominantBaseline: "alphabetic", - labelMinLineLengthPx: - forceComponentLabelsForSelectedOrActivePolylineEdges - ? 0 - : lineLabelMinDistancePx, - onLineClick: onVerticalLineClick, - }); - } - - if (isDistanceRelationHorizontalLineVisible(relation)) { - const onHorizontalLineClick = onDistanceLineLabelToggle - ? () => onDistanceLineLabelToggle(relation.id, "horizontal") - : undefined; - lines.push({ - id: `reference-horizontal-${relation.id}`, - getCanvasLine: () => { - const start = getScreenAux(); - const end = getScreenTarget(); - if (!start || !end) return null; - return { start, end }; - }, - getLabelOutsideReferencePoint: - getHorizontalLabelOutsideReferencePoint, - stroke: REFERENCE_COMPONENT_HORIZONTAL_COLOR, - strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: showHorizontalLabel - ? `${formatNumber(horizontalDistanceMeters)} m` - : undefined, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelMinLineLengthPx: - forceComponentLabelsForSelectedOrActivePolylineEdges - ? 0 - : lineLabelMinDistancePx, - onLineClick: onHorizontalLineClick, - }); - } - } - ); - - if (scene && !scene.isDestroyed() && livePreviewDistanceLine) { - const { - anchorPointECEF, - targetPointECEF, - showDirectLine, - showVerticalLine, - showHorizontalLine, - } = livePreviewDistanceLine; - - if ( - Cartesian3.distance(anchorPointECEF, targetPointECEF) > - REFERENCE_LINE_EPSILON_METERS - ) { - const anchorWGS84 = getDegreesFromCartesian(anchorPointECEF); - const targetWGS84 = getDegreesFromCartesian(targetPointECEF); - const auxiliaryPointECEF = Cartesian3.fromDegrees( - anchorWGS84.longitude, - anchorWGS84.latitude, - targetWGS84.altitude ?? 0 - ); - - const getPreviewScreenAnchor = () => { - if (!scene || scene.isDestroyed()) return null; - const anchor = SceneTransforms.worldToWindowCoordinates( - scene, - anchorPointECEF - ); - if (!defined(anchor)) return null; - return { x: anchor.x, y: anchor.y } as CssPixelPosition; - }; - - const getPreviewScreenTarget = () => { - if (!scene || scene.isDestroyed()) return null; - const target = SceneTransforms.worldToWindowCoordinates( - scene, - targetPointECEF - ); - if (!defined(target)) return null; - return { x: target.x, y: target.y } as CssPixelPosition; - }; - - const getPreviewScreenAux = () => { - if (!scene || scene.isDestroyed()) return null; - const auxiliary = SceneTransforms.worldToWindowCoordinates( - scene, - auxiliaryPointECEF - ); - if (!defined(auxiliary)) return null; - return { x: auxiliary.x, y: auxiliary.y } as CssPixelPosition; - }; - - type PreviewScreenTriangleData = { - anchor: CssPixelPosition; - target: CssPixelPosition; - aux: CssPixelPosition; - centroid: CssPixelPosition; - }; - - let cachedPreviewTriangleFrameNumber: number | null = null; - let cachedPreviewTriangle: PreviewScreenTriangleData | null = null; - - const getSceneFrameNumber = (): number | null => { - const frameNumber = ( - scene as unknown as { frameState?: { frameNumber?: number } } - ).frameState?.frameNumber; - return typeof frameNumber === "number" ? frameNumber : null; - }; - - const getPreviewScreenTriangle = - (): PreviewScreenTriangleData | null => { - const frameNumber = getSceneFrameNumber(); - if ( - frameNumber !== null && - frameNumber === cachedPreviewTriangleFrameNumber - ) { - return cachedPreviewTriangle; - } - - const anchor = getPreviewScreenAnchor(); - const target = getPreviewScreenTarget(); - const aux = getPreviewScreenAux(); - if (!anchor || !target || !aux) return null; - const triangle = { - anchor, - target, - aux, - centroid: { - x: (anchor.x + target.x + aux.x) / 3, - y: (anchor.y + target.y + aux.y) / 3, - } as CssPixelPosition, - }; - if (frameNumber !== null) { - cachedPreviewTriangleFrameNumber = frameNumber; - cachedPreviewTriangle = triangle; - } - return triangle; - }; - - const buildStableOutsideReferencePoint = ( - start: CssPixelPosition, - end: CssPixelPosition, - insidePoint: CssPixelPosition - ): CssPixelPosition | null => { - const dx = end.x - start.x; - const dy = end.y - start.y; - const lineLength = Math.hypot(dx, dy); - if (lineLength <= 1e-3) return null; - const midX = (start.x + end.x) * 0.5; - const midY = (start.y + end.y) * 0.5; - const normalX = -dy / lineLength; - const normalY = dx / lineLength; - const dot = - (insidePoint.x - midX) * normalX + (insidePoint.y - midY) * normalY; - const insideSign = dot >= 0 ? 1 : -1; - const refDistancePx = clamp( - lineLength * 0.2, - LABEL_REFERENCE_MIN_DISTANCE_PX, - LABEL_REFERENCE_MAX_DISTANCE_PX - ); - return { - x: midX + normalX * insideSign * refDistancePx, - y: midY + normalY * insideSign * refDistancePx, - } as CssPixelPosition; - }; - - const getStableInsidePointForDirectAndHorizontal = - (): CssPixelPosition | null => { - const triangle = getPreviewScreenTriangle(); - if (!triangle) return null; - return { - x: - triangle.aux.x + - (triangle.centroid.x - triangle.aux.x) * - LABEL_INSIDE_BLEND_FACTOR, - y: - triangle.aux.y + - (triangle.centroid.y - triangle.aux.y) * - LABEL_INSIDE_BLEND_FACTOR, - } as CssPixelPosition; - }; - - const getPreviewDirectLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const triangle = getPreviewScreenTriangle(); - const insidePoint = getStableInsidePointForDirectAndHorizontal(); - if (!triangle || !insidePoint) return null; - return buildStableOutsideReferencePoint( - triangle.anchor, - triangle.target, - insidePoint - ); - }; - - const getPreviewHorizontalLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const triangle = getPreviewScreenTriangle(); - const insidePoint = getStableInsidePointForDirectAndHorizontal(); - if (!triangle || !insidePoint) return null; - return buildStableOutsideReferencePoint( - triangle.aux, - triangle.target, - insidePoint - ); - }; - - const getPreviewVerticalLineScreenData = (): { - start: CssPixelPosition; - end: CssPixelPosition; - inside: CssPixelPosition; - insideSign: -1 | 1; - midX: number; - midY: number; - normalX: number; - normalY: number; - lineLength: number; - } | null => { - const triangle = getPreviewScreenTriangle(); - if (!triangle) return null; - - let start = triangle.anchor; - let end = triangle.aux; - const inside = triangle.target; - - const recompute = ( - s: CssPixelPosition, - e: CssPixelPosition - ): { - midX: number; - midY: number; - normalX: number; - normalY: number; - lineLength: number; - insideDot: number; - } | null => { - const dx = e.x - s.x; - const dy = e.y - s.y; - const lineLength = Math.hypot(dx, dy); - if (lineLength <= 1e-3) return null; - const midX = (s.x + e.x) * 0.5; - const midY = (s.y + e.y) * 0.5; - const normalX = -dy / lineLength; - const normalY = dx / lineLength; - const insideDot = - (inside.x - midX) * normalX + (inside.y - midY) * normalY; - return { - midX, - midY, - normalX, - normalY, - lineLength, - insideDot, - }; - }; - - let edgeData = recompute(start, end); - if (!edgeData) return null; - - const stableInsideSign = resolveStableSideSign( - edgeData.insideDot, - previewVerticalLabelSideRef.current - ); - previewVerticalLabelSideRef.current = stableInsideSign; - - if (stableInsideSign < 0) { - start = triangle.aux; - end = triangle.anchor; - edgeData = recompute(start, end); - if (!edgeData) return null; - } - - return { - start, - end, - inside, - insideSign: stableInsideSign, - midX: edgeData.midX, - midY: edgeData.midY, - normalX: edgeData.normalX, - normalY: edgeData.normalY, - lineLength: edgeData.lineLength, - }; - }; - - const getPreviewVerticalLabelOutsideReferencePoint = - (): CssPixelPosition | null => { - const edge = getPreviewVerticalLineScreenData(); - if (!edge) return null; - const refDistancePx = clamp( - edge.lineLength * 0.2, - LABEL_REFERENCE_MIN_DISTANCE_PX, - LABEL_REFERENCE_MAX_DISTANCE_PX - ); - - const nextReferencePoint = { - x: edge.midX + edge.normalX * edge.insideSign * refDistancePx, - y: edge.midY + edge.normalY * edge.insideSign * refDistancePx, - } as CssPixelPosition; - return nextReferencePoint; - }; - - const directDistanceMeters = Cartesian3.distance( - anchorPointECEF, - targetPointECEF - ); - if (showDirectLine) { - lines.push({ - id: "reference-preview-direct", - getCanvasLine: () => { - const start = getPreviewScreenAnchor(); - const end = getPreviewScreenTarget(); - if (!start || !end) return null; - return { start, end }; - }, - getLabelOutsideReferencePoint: - getPreviewDirectLabelOutsideReferencePoint, - stroke: "rgba(255, 255, 255, 0.9)", - strokeWidth: 1.5, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: `${formatNumber(directDistanceMeters)} m`, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelMinLineLengthPx: lineLabelMinDistancePx, - }); - } - - const verticalDistanceMeters = Cartesian3.distance( - anchorPointECEF, - auxiliaryPointECEF - ); - const horizontalDistanceMeters = Cartesian3.distance( - auxiliaryPointECEF, - targetPointECEF - ); - - if ( - showVerticalLine && - verticalDistanceMeters > REFERENCE_LINE_EPSILON_METERS - ) { - lines.push({ - id: "reference-preview-vertical", - getCanvasLine: () => { - const edge = getPreviewVerticalLineScreenData(); - if (!edge) return null; - return { start: edge.start, end: edge.end }; - }, - getLabelOutsideReferencePoint: - getPreviewVerticalLabelOutsideReferencePoint, - stroke: REFERENCE_COMPONENT_VERTICAL_COLOR, - strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: `${formatNumber(verticalDistanceMeters)} m`, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelRotationMode: "clockwise", - labelOffsetPx: VERTICAL_COMPONENT_LABEL_OFFSET_PX, - labelDominantBaseline: "alphabetic", - labelMinLineLengthPx: lineLabelMinDistancePx, - }); - } - - if ( - showHorizontalLine && - horizontalDistanceMeters > REFERENCE_LINE_EPSILON_METERS - ) { - lines.push({ - id: "reference-preview-horizontal", - getCanvasLine: () => { - const start = getPreviewScreenAux(); - const end = getPreviewScreenTarget(); - if (!start || !end) return null; - return { start, end }; - }, - getLabelOutsideReferencePoint: - getPreviewHorizontalLabelOutsideReferencePoint, - stroke: REFERENCE_COMPONENT_HORIZONTAL_COLOR, - strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, - strokeDasharray: "6 8", - hitTargetStrokeWidth: 10, - labelText: `${formatNumber(horizontalDistanceMeters)} m`, - labelColor: "#000000", - labelStroke: "rgba(255, 255, 255, 0.95)", - labelFontSize: 12, - labelFontFamily: "Arial, sans-serif", - labelFontWeight: "400", - labelMinLineLengthPx: lineLabelMinDistancePx, - }); - } - } - } - - return lines; - }, [ - lineLabelMinDistancePx, - onDistanceLineLabelToggle, - onDistanceLineClick, - cumulativeDistanceByRelationId, - planarPolygonSharedEdgeRelationIdSet, - duplicateFacadeOpposingEdgeRelationIdSet, - livePreviewDistanceLine, - resolvedRelations, - scene, - edgeRelationOwnerGroupIdSet, - selectedOrActiveOpenPolylineEdgeRelationIdSet, - splitMarkerRelationIdSet, - renderDomVisuals, - ]); - - useLineVisualizers(overlayLines, renderDomVisuals && overlayLines.length > 0); - - const rightAngleCornerContent = useMemo( - () => - createElement(RightAngleCornerOverlay, { - strokeColor: REFERENCE_COMPONENT_ARC_COLOR, - strokeWidthPx: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, - dotRadiusPx: CORNER_OVERLAY_DOT_RADIUS_PX, - }), - [] - ); - - useEffect(() => { - cornerOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - cornerOverlayIdsRef.current = []; - - if (!renderDomVisuals) { - return; - } - - if (!scene || scene.isDestroyed()) { - return; - } - - const nextCornerOverlayIds: string[] = []; - - resolvedRelations - .filter(({ relation }) => - hasVisibleDistanceRelationComponentLines(relation) - ) - .forEach(({ relation, anchorPoint, targetPoint, auxiliaryPoint }) => { - const overlayId = `${CORNER_OVERLAY_ID_PREFIX}-${relation.id}`; - - addLabelOverlayElement({ - id: overlayId, - content: rightAngleCornerContent, - onClick: onDistanceRelationCornerClick - ? () => onDistanceRelationCornerClick(relation.id) - : undefined, - updatePosition: (elementDiv) => { - if (!scene || scene.isDestroyed()) return false; - - const auxiliaryPointScreen = - SceneTransforms.worldToWindowCoordinates(scene, auxiliaryPoint); - const verticalPointScreen = - SceneTransforms.worldToWindowCoordinates( - scene, - anchorPoint.geometryECEF - ); - const horizontalPointScreen = - SceneTransforms.worldToWindowCoordinates( - scene, - targetPoint.geometryECEF - ); - - if ( - !defined(auxiliaryPointScreen) || - !defined(verticalPointScreen) || - !defined(horizontalPointScreen) - ) { - return false; - } - - const verticalLengthMeters = Cartesian3.distance( - anchorPoint.geometryECEF, - auxiliaryPoint - ); - const horizontalLengthMeters = Cartesian3.distance( - auxiliaryPoint, - targetPoint.geometryECEF - ); - if ( - verticalLengthMeters <= REFERENCE_LINE_EPSILON_METERS || - horizontalLengthMeters <= REFERENCE_LINE_EPSILON_METERS - ) { - return false; - } - - const drawingBufferWidth = scene.drawingBufferWidth; - const drawingBufferHeight = scene.drawingBufferHeight; - if (drawingBufferWidth <= 0 || drawingBufferHeight <= 0) { - return false; - } - - let metersPerPixel = Number.NaN; - try { - metersPerPixel = scene.camera.getPixelSize( - new BoundingSphere(auxiliaryPoint, 1), - drawingBufferWidth, - drawingBufferHeight - ); - } catch { - metersPerPixel = Number.NaN; - } - - if (!Number.isFinite(metersPerPixel) || metersPerPixel <= 0) { - const cameraDistanceMeters = Math.max( - Cartesian3.distance(scene.camera.position, auxiliaryPoint), - 1 - ); - const fovRad = - (scene.camera.frustum as { fov?: number }).fov ?? Math.PI / 3; - metersPerPixel = Math.max( - (cameraDistanceMeters * Math.tan(fovRad / 2) * 2) / - Math.max(drawingBufferHeight, 1), - 1e-6 - ); - } - - const arcRadiusPx = CORNER_OVERLAY_TARGET_RADIUS_PX; - const arcRadiusMeters = arcRadiusPx * metersPerPixel; - - const arcPointsWorld = getArcPointsInSpannedPlane( - auxiliaryPoint, - anchorPoint.geometryECEF, - targetPoint.geometryECEF, - arcRadiusMeters, - CORNER_OVERLAY_SEGMENTS - ); - if (!arcPointsWorld || arcPointsWorld.length < 2) { - return false; - } - - const arcMidpointWorld = - arcPointsWorld[Math.floor(arcPointsWorld.length / 2)]; - if (!arcMidpointWorld) return false; - const dotWorld = Cartesian3.midpoint( - auxiliaryPoint, - arcMidpointWorld, - new Cartesian3() - ); - const dotScreen = SceneTransforms.worldToWindowCoordinates( - scene, - dotWorld - ); - if (!defined(dotScreen)) return false; - - const arcPointsScreen = arcPointsWorld - .map((worldPoint) => - SceneTransforms.worldToWindowCoordinates(scene, worldPoint) - ) - .filter(defined); - if (arcPointsScreen.length < 2) { - return false; - } - - const minX = Math.min(...arcPointsScreen.map((point) => point.x)); - const maxX = Math.max(...arcPointsScreen.map((point) => point.x)); - const minY = Math.min(...arcPointsScreen.map((point) => point.y)); - const maxY = Math.max(...arcPointsScreen.map((point) => point.y)); - const width = Math.max( - CORNER_OVERLAY_MIN_BOX_PX, - maxX - minX + CORNER_OVERLAY_PADDING_PX * 2 - ); - const height = Math.max( - CORNER_OVERLAY_MIN_BOX_PX, - maxY - minY + CORNER_OVERLAY_PADDING_PX * 2 - ); - - const pathData = arcPointsScreen - .map((point, index) => { - const x = point.x - minX + CORNER_OVERLAY_PADDING_PX; - const y = point.y - minY + CORNER_OVERLAY_PADDING_PX; - return `${index === 0 ? "M" : "L"} ${x} ${y}`; - }) - .join(" "); - const isCornerClickable = Boolean(onDistanceRelationCornerClick); - - applyRightAngleCornerOverlayLayout({ - elementDiv, - pathData, - dotScreen: { - x: dotScreen.x, - y: dotScreen.y, - } as CssPixelPosition, - minX, - minY, - width, - height, - paddingPx: CORNER_OVERLAY_PADDING_PX, - clickable: isCornerClickable, - }); - - return true; - }, - }); - - nextCornerOverlayIds.push(overlayId); - }); - - cornerOverlayIdsRef.current = nextCornerOverlayIds; - - return () => { - nextCornerOverlayIds.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - cornerOverlayIdsRef.current = []; - }; - }, [ - addLabelOverlayElement, - onDistanceRelationCornerClick, - removeLabelOverlayElement, - resolvedRelations, - rightAngleCornerContent, - renderDomVisuals, - scene, - ]); - - const midpointMarkerContent = useMemo( - () => - createElement(MidpointMarkerOverlay, { - tickLengthPx: MIDPOINT_MARKER_TICK_LENGTH_PX, - tickWidthPx: MIDPOINT_MARKER_TICK_WIDTH_PX, - tickColor: "rgba(255, 255, 255, 0.95)", - }), - [] - ); - - useEffect(() => { - midpointOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - midpointOverlayIdsRef.current = []; - - if (!renderDomVisuals) { - return; - } - - if (!scene || scene.isDestroyed()) { - return; - } - - const nextMidpointOverlayIds: string[] = []; - - resolvedRelations - .filter(({ relation }) => { - if (midpointTickRelationIdSet.size === 0) { - return false; - } - if (!relation.showDirectLine) return false; - return midpointTickRelationIdSet.has(relation.id); - }) - .forEach(({ relation, pointA, pointB }) => { - const overlayId = `${MIDPOINT_OVERLAY_ID_PREFIX}-${relation.id}`; - addLabelOverlayElement({ - id: overlayId, - zIndex: 11, - content: midpointMarkerContent, - onClick: onDistanceRelationMidpointClick - ? () => onDistanceRelationMidpointClick(relation.id) - : undefined, - updatePosition: (elementDiv) => { - if (!scene || scene.isDestroyed()) return false; - const start = SceneTransforms.worldToWindowCoordinates( - scene, - pointA.geometryECEF - ); - const end = SceneTransforms.worldToWindowCoordinates( - scene, - pointB.geometryECEF - ); - if (!defined(start) || !defined(end)) return false; - - const midpointWorld = Cartesian3.midpoint( - pointA.geometryECEF, - pointB.geometryECEF, - new Cartesian3() - ); - const center = SceneTransforms.worldToWindowCoordinates( - scene, - midpointWorld - ); - if (!defined(center)) return false; - const angleDeg = - (Math.atan2(end.y - start.y, end.x - start.x) * 180) / Math.PI + - 90; - applyMidpointMarkerOverlayLayout({ - elementDiv, - center: { x: center.x, y: center.y } as CssPixelPosition, - angleDeg, - hitTargetPx: MIDPOINT_MARKER_HIT_TARGET_PX, - clickable: Boolean(onDistanceRelationMidpointClick), - }); - return true; - }, - }); - nextMidpointOverlayIds.push(overlayId); - }); - - midpointOverlayIdsRef.current = nextMidpointOverlayIds; - - return () => { - nextMidpointOverlayIds.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - midpointOverlayIdsRef.current = []; - }; - }, [ - addLabelOverlayElement, - midpointMarkerContent, - onDistanceRelationMidpointClick, - removeLabelOverlayElement, - resolvedRelations, - renderDomVisuals, - scene, - midpointTickRelationIdSet, - ]); - - const relationsWithDirectLine = useMemo( - () => resolvedRelations.filter(({ relation }) => relation.showDirectLine), - [resolvedRelations] - ); - - const relationsWithVerticalLine = useMemo( - () => - resolvedRelations.filter(({ relation }) => - isDistanceRelationVerticalLineVisible(relation) - ), - [resolvedRelations] - ); - - const relationsWithHorizontalLine = useMemo( - () => - resolvedRelations.filter(({ relation }) => - isDistanceRelationHorizontalLineVisible(relation) - ), - [resolvedRelations] - ); - - useEffect(() => { - if (!scene) return; - - if (!renderCesiumCoreVisuals) { - destroyLineVisualizerMap(directLineRefs); - if (!scene.isDestroyed()) { - scene.requestRender(); - } - return; - } - - destroyLineVisualizerMap(directLineRefs); - - if (relationsWithDirectLine.length === 0) { - scene.requestRender(); - return; - } - - relationsWithDirectLine.forEach(({ relation, pointA, pointB }) => { - const lineVisualizer = createLineVisualizer( - `reference-line-${relation.id}`, - { - start: pointA.geometryECEF, - end: pointB.geometryECEF, - color: Color.WHITE, - width: 1, - dashed: false, - } - ); - directLineRefs.current[relation.id] = lineVisualizer; - lineVisualizer.attach(scene, () => scene.requestRender()); - }); - scene.requestRender(); - - return () => { - destroyLineVisualizerMap(directLineRefs); - if (!scene || scene.isDestroyed()) return; - scene.requestRender(); - }; - }, [relationsWithDirectLine, renderCesiumCoreVisuals, scene]); - - useEffect(() => { - if (!scene) return; - - if (!renderCesiumCoreVisuals) { - destroyLineVisualizerMap(verticalLineRefs); - destroyLineVisualizerMap(horizontalLineRefs); - if (!scene.isDestroyed()) { - scene.requestRender(); - } - return; - } - - destroyLineVisualizerMap(verticalLineRefs); - destroyLineVisualizerMap(horizontalLineRefs); - - if ( - relationsWithVerticalLine.length === 0 && - relationsWithHorizontalLine.length === 0 - ) { - scene.requestRender(); - return; - } - - relationsWithVerticalLine.forEach( - ({ relation, anchorPoint, auxiliaryPoint }) => { - const verticalLineVisualizer = createLineVisualizer( - `reference-vertical-line-${relation.id}`, - { - start: anchorPoint.geometryECEF, - end: auxiliaryPoint, - color: Color.fromCssColorString(REFERENCE_COMPONENT_VERTICAL_COLOR), - width: 1, - dashed: false, - } - ); - verticalLineRefs.current[relation.id] = verticalLineVisualizer; - verticalLineVisualizer.attach(scene, () => scene.requestRender()); - } - ); - - relationsWithHorizontalLine.forEach( - ({ relation, targetPoint, auxiliaryPoint }) => { - const horizontalLineVisualizer = createLineVisualizer( - `reference-horizontal-line-${relation.id}`, - { - start: auxiliaryPoint, - end: targetPoint.geometryECEF, - color: Color.fromCssColorString( - REFERENCE_COMPONENT_HORIZONTAL_COLOR - ), - width: 1, - dashed: false, - } - ); - horizontalLineRefs.current[relation.id] = horizontalLineVisualizer; - horizontalLineVisualizer.attach(scene, () => scene.requestRender()); - } - ); - scene.requestRender(); - - return () => { - destroyLineVisualizerMap(verticalLineRefs); - destroyLineVisualizerMap(horizontalLineRefs); - if (!scene || scene.isDestroyed()) return; - scene.requestRender(); - }; - }, [ - relationsWithHorizontalLine, - relationsWithVerticalLine, - renderCesiumCoreVisuals, - scene, - ]); - - useEffect(() => { - if (!scene) return; - - if (!renderCesiumCoreVisuals) { - destroyLineVisualizerRef(previewDirectLineRef); - destroyLineVisualizerRef(previewVerticalLineRef); - destroyLineVisualizerRef(previewHorizontalLineRef); - if (!scene.isDestroyed()) { - scene.requestRender(); - } - return; - } - - destroyLineVisualizerRef(previewDirectLineRef); - destroyLineVisualizerRef(previewVerticalLineRef); - destroyLineVisualizerRef(previewHorizontalLineRef); - - if (!livePreviewDistanceLine) { - scene.requestRender(); - return; - } - - const { - anchorPointECEF, - targetPointECEF, - showDirectLine, - showVerticalLine, - showHorizontalLine, - } = livePreviewDistanceLine; - - if ( - Cartesian3.distance(anchorPointECEF, targetPointECEF) <= - REFERENCE_LINE_EPSILON_METERS - ) { - scene.requestRender(); - return; - } - - const anchorWGS84 = getDegreesFromCartesian(anchorPointECEF); - const targetWGS84 = getDegreesFromCartesian(targetPointECEF); - const auxiliaryPointECEF = Cartesian3.fromDegrees( - anchorWGS84.longitude, - anchorWGS84.latitude, - targetWGS84.altitude ?? 0 - ); - - if (showDirectLine) { - const lineVisualizer = createLineVisualizer("reference-preview-direct", { - start: anchorPointECEF, - end: targetPointECEF, - color: Color.WHITE, - width: 1, - dashed: false, - }); - previewDirectLineRef.current = lineVisualizer; - lineVisualizer.attach(scene, () => scene.requestRender()); - } - - const verticalDistanceMeters = Cartesian3.distance( - anchorPointECEF, - auxiliaryPointECEF - ); - if ( - showVerticalLine && - verticalDistanceMeters > REFERENCE_LINE_EPSILON_METERS - ) { - const verticalLineVisualizer = createLineVisualizer( - "reference-preview-vertical", - { - start: anchorPointECEF, - end: auxiliaryPointECEF, - color: Color.fromCssColorString(REFERENCE_COMPONENT_VERTICAL_COLOR), - width: 1, - dashed: false, - } - ); - previewVerticalLineRef.current = verticalLineVisualizer; - verticalLineVisualizer.attach(scene, () => scene.requestRender()); - } - - const horizontalDistanceMeters = Cartesian3.distance( - auxiliaryPointECEF, - targetPointECEF - ); - if ( - showHorizontalLine && - horizontalDistanceMeters > REFERENCE_LINE_EPSILON_METERS - ) { - const horizontalLineVisualizer = createLineVisualizer( - "reference-preview-horizontal", - { - start: auxiliaryPointECEF, - end: targetPointECEF, - color: Color.fromCssColorString(REFERENCE_COMPONENT_HORIZONTAL_COLOR), - width: 1, - dashed: false, - } - ); - previewHorizontalLineRef.current = horizontalLineVisualizer; - horizontalLineVisualizer.attach(scene, () => scene.requestRender()); - } - - scene.requestRender(); - - return () => { - destroyLineVisualizerRef(previewDirectLineRef); - destroyLineVisualizerRef(previewVerticalLineRef); - destroyLineVisualizerRef(previewHorizontalLineRef); - if (!scene || scene.isDestroyed()) return; - scene.requestRender(); - }; - }, [livePreviewDistanceLine, renderCesiumCoreVisuals, scene]); - - useEffect(() => { - return () => { - cornerOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - cornerOverlayIdsRef.current = []; - midpointOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - midpointOverlayIdsRef.current = []; - destroyLineVisualizerMap(directLineRefs); - destroyLineVisualizerMap(verticalLineRefs); - destroyLineVisualizerMap(horizontalLineRefs); - destroyLineVisualizerRef(previewDirectLineRef); - destroyLineVisualizerRef(previewVerticalLineRef); - destroyLineVisualizerRef(previewHorizontalLineRef); - }; - }, [removeLabelOverlayElement]); -}; - -export default useCesiumDistanceVisualizer; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumGroundAreaVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumGroundAreaVisualizer.ts deleted file mode 100644 index f83b198dca..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumGroundAreaVisualizer.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useGroundAreaLabelVisualizer } from "@carma-mapping/annotations/core"; - -import { type GroundAreaVisualizerOptions } from "./areaVisualizer.types"; -import { useCesiumPolygonAreaPrimitives } from "./useCesiumPolygonAreaPrimitives"; -import { useCesiumAreaLabelViewProjector } from "./utils/useCesiumAreaLabelViewProjector"; - -export const useCesiumGroundAreaVisualizer = ( - options: GroundAreaVisualizerOptions -) => { - const { - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - groundPolygonPreviewGroups, - } = options; - const viewProjector = useCesiumAreaLabelViewProjector(scene); - - useGroundAreaLabelVisualizer({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - groundPolygonPreviewGroups, - }); - - useCesiumPolygonAreaPrimitives({ - scene, - focusedPolygonGroupId, - polygonPreviewGroups: groundPolygonPreviewGroups, - }); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumOverlaySync.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumOverlaySync.ts deleted file mode 100644 index 3cb8ad9ad2..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumOverlaySync.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useCallback, useEffect, useRef } from "react"; -import { - Cartesian3, - CesiumMath, - Matrix4, - VERSION as CESIUM_RUNTIME_VERSION, - type Scene, -} from "@carma/cesium"; -import { useCesiumContext } from "@carma-mapping/engines/cesium"; - -type FrustumLike = { - clone?: () => unknown; - equalsEpsilon?: (other: unknown, epsilon: number) => boolean; -}; - -type PrivateCameraRaw = { - _viewMatrix: Matrix4; - _position: Cartesian3; - _direction: Cartesian3; - _up: Cartesian3; - _right: Cartesian3; - _transform: Matrix4; - frustum: FrustumLike; - moveStart: { addEventListener: (cb: () => void) => () => void }; - moveEnd: { addEventListener: (cb: () => void) => () => void }; - changed: { addEventListener: (cb: () => void) => () => void }; -}; - -type CameraSnapshot = { - viewMatrix: Matrix4; - frustumClone: unknown; - drawingBufferWidth: number; - drawingBufferHeight: number; -}; - -const CAMERA_CHANGE_EPSILON = CesiumMath.EPSILON15; -const EXPECTED_CESIUM_RUNTIME_VERSION = "1.134.1"; - -let hasValidatedPrivateCesiumContracts = false; - -const asPrivateCameraRaw = (scene: Scene) => - scene.camera as unknown as Partial; - -const assertPrivateCesiumContracts = (scene: Scene) => { - if (hasValidatedPrivateCesiumContracts) return; - - if (CESIUM_RUNTIME_VERSION !== EXPECTED_CESIUM_RUNTIME_VERSION) { - throw new Error( - `[FATAL][MEASUREMENTS][CESIUM_PRIVATE_API] Unsupported Cesium runtime version.\nExpected: ${EXPECTED_CESIUM_RUNTIME_VERSION}\nDetected: ${String( - CESIUM_RUNTIME_VERSION - )}\nThis hook relies on undocumented camera internals and must be reviewed for this version.` - ); - } - - const camera = asPrivateCameraRaw(scene); - const hasCoreInternals = - Boolean(camera._viewMatrix) && - Boolean(camera._position) && - Boolean(camera._direction) && - Boolean(camera._up) && - Boolean(camera._right) && - Boolean(camera._transform); - const hasFrustumInternals = - typeof camera.frustum?.clone === "function" && - typeof camera.frustum?.equalsEpsilon === "function"; - const hasCameraEvents = - typeof camera.moveStart?.addEventListener === "function" && - typeof camera.moveEnd?.addEventListener === "function" && - typeof camera.changed?.addEventListener === "function"; - const hasSceneViewInternals = - typeof ( - scene as unknown as { - _view?: { checkForCameraUpdates?: unknown }; - } - )._view?.checkForCameraUpdates === "function"; - - if ( - !hasCoreInternals || - !hasFrustumInternals || - !hasCameraEvents || - !hasSceneViewInternals - ) { - throw new Error( - "[FATAL][MEASUREMENTS][CESIUM_PRIVATE_API] Private Cesium camera/view internals changed. Overlay sync contract is invalid and requires adaptation." - ); - } - - hasValidatedPrivateCesiumContracts = true; -}; - -const captureCameraSnapshot = (scene: Scene): CameraSnapshot => { - const camera = asPrivateCameraRaw(scene) as PrivateCameraRaw; - return { - viewMatrix: Matrix4.clone(camera._viewMatrix, new Matrix4()), - frustumClone: camera.frustum.clone?.() ?? null, - drawingBufferWidth: scene.drawingBufferWidth, - drawingBufferHeight: scene.drawingBufferHeight, - }; -}; - -const hasSceneCameraChanged = ( - scene: Scene, - previousSnapshot: CameraSnapshot | null -) => { - if (!previousSnapshot) return true; - - if ( - scene.drawingBufferWidth !== previousSnapshot.drawingBufferWidth || - scene.drawingBufferHeight !== previousSnapshot.drawingBufferHeight - ) { - return true; - } - - const camera = asPrivateCameraRaw(scene) as PrivateCameraRaw; - if ( - !Matrix4.equalsEpsilon( - camera._viewMatrix, - previousSnapshot.viewMatrix, - CAMERA_CHANGE_EPSILON - ) - ) { - return true; - } - - const frustumEquals = camera.frustum.equalsEpsilon?.( - previousSnapshot.frustumClone, - CAMERA_CHANGE_EPSILON - ); - if (!frustumEquals) { - return true; - } - - return false; -}; - -export const useCesiumOverlaySync = () => { - const { getScene } = useCesiumContext(); - const scene = getScene(); - const overlayUpdateRef = useRef<(() => void) | null>(null); - const overlayUpdateQueuedRef = useRef(true); - const previousCameraSnapshotRef = useRef(null); - - useEffect(() => { - if (!scene || scene.isDestroyed()) return; - - assertPrivateCesiumContracts(scene); - - const queueOverlayUpdate = () => { - overlayUpdateQueuedRef.current = true; - }; - - const onPreRender = () => { - if (!overlayUpdateRef.current) return; - const cameraChanged = hasSceneCameraChanged( - scene, - previousCameraSnapshotRef.current - ); - if (!overlayUpdateQueuedRef.current && !cameraChanged) return; - overlayUpdateRef.current(); - overlayUpdateQueuedRef.current = false; - previousCameraSnapshotRef.current = captureCameraSnapshot(scene); - }; - - const camera = asPrivateCameraRaw(scene) as PrivateCameraRaw; - const removePreRenderListener = - scene.preRender.addEventListener(onPreRender); - const removeMoveStartListener = - camera.moveStart.addEventListener(queueOverlayUpdate); - const removeMoveEndListener = - camera.moveEnd.addEventListener(queueOverlayUpdate); - const removeCameraChangedListener = - camera.changed.addEventListener(queueOverlayUpdate); - - return () => { - removePreRenderListener(); - removeMoveStartListener(); - removeMoveEndListener(); - removeCameraChangedListener(); - previousCameraSnapshotRef.current = null; - }; - }, [scene]); - - const requestUpdateCallback = useCallback((fn: () => void) => { - overlayUpdateRef.current = fn; - overlayUpdateQueuedRef.current = true; - previousCameraSnapshotRef.current = null; - }, []); - - return requestUpdateCallback; -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPlanarAreaVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPlanarAreaVisualizer.ts deleted file mode 100644 index 3159fc9979..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPlanarAreaVisualizer.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { usePlanarAreaLabelVisualizer } from "@carma-mapping/annotations/core"; - -import { type PlanarAreaVisualizerOptions } from "./areaVisualizer.types"; -import { useCesiumPolygonAreaPrimitives } from "./useCesiumPolygonAreaPrimitives"; -import { useCesiumAreaLabelViewProjector } from "./utils/useCesiumAreaLabelViewProjector"; - -export const useCesiumPlanarAreaVisualizer = ( - options: PlanarAreaVisualizerOptions -) => { - const { - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - planarPolygonPreviewGroups, - } = options; - const viewProjector = useCesiumAreaLabelViewProjector(scene); - - usePlanarAreaLabelVisualizer({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - planarPolygonPreviewGroups, - }); - - useCesiumPolygonAreaPrimitives({ - scene, - focusedPolygonGroupId, - polygonPreviewGroups: planarPolygonPreviewGroups, - }); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointDomVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointDomVisualizer.ts deleted file mode 100644 index d49737d754..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointDomVisualizer.ts +++ /dev/null @@ -1,533 +0,0 @@ -import { - createElement, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from "react"; - -import { - Cartesian3, - SceneTransforms, - defined, - type Scene, -} from "@carma/cesium"; -import { formatNumber } from "@carma-mapping/annotations/core"; -import { - createPlacement, - getPerspectiveStemAngleMagnitude, - type PointLabelData, - resolvePointLabelLayoutConfig, - type LineVisualizerData, - useLabelOverlay, - useLineVisualizers, - usePointLabels, -} from "@carma-providers/label-overlay"; -import type { CssPixelPosition } from "@carma/units/types"; - -import { - isPointAnnotationEntry, - type AnnotationCollection, -} from "../types/AnnotationTypes"; -import { useCesiumPointLabels } from "./useCesiumPointLabels"; -import { type CesiumPointVisualizerOptions } from "./useCesiumPointVisualizer"; - -const LIVE_PREVIEW_HEIGHT_LABEL_ID = "measurement-live-preview-height"; -const LIVE_PREVIEW_CROSSHAIR_ID = "measurement-live-preview-crosshair"; -const LIVE_PREVIEW_VERTICAL_OFFSET_STEM_ID = - "measurement-live-preview-vertical-offset-stem"; - -const CROSSHAIR_STROKE_COLOR = "rgba(255, 255, 255, 0.96)"; -const CROSSHAIR_CONTRAST_FILTER = - "drop-shadow(0 0 1px rgba(0, 0, 0, 1)) drop-shadow(0 0 2px rgba(0, 0, 0, 0.95))"; -const CROSSHAIR_THICKNESS_PX = 3; -const CROSSHAIR_CENTER_DOT_SIZE_PX = 1; -const CROSSHAIR_CENTER_GAP_PX = 5; -const CROSSHAIR_FAR_DASH_LENGTH_PX = 12; -const CROSSHAIR_INNER_TIP_PX = CROSSHAIR_THICKNESS_PX / 2; -const CROSSHAIR_HALF_EXTENT_PX = - CROSSHAIR_CENTER_GAP_PX + CROSSHAIR_FAR_DASH_LENGTH_PX; -const CROSSHAIR_SIZE_PX = - CROSSHAIR_HALF_EXTENT_PX * 2 + CROSSHAIR_CENTER_DOT_SIZE_PX; -const CROSSHAIR_CENTER_PX = CROSSHAIR_HALF_EXTENT_PX; -const CROSSHAIR_ANCHOR_OFFSET_Y_PX = 1; - -const ELEVATION_NEUTRAL_THRESHOLD_METERS = 0.03; -const ELEVATION_GLYPH_UP = "↥"; -const ELEVATION_GLYPH_DOWN = "↧"; - -const LIVE_PREVIEW_HEIGHT_LABEL_ANCHOR_DISTANCE_PX = 24; -const LIVE_PREVIEW_HEIGHT_LABEL_STEM_START_DISTANCE_PX = 8; -const LIVE_PREVIEW_HEIGHT_LABEL_STEM_DISTANCE_PX = Math.max( - 0, - LIVE_PREVIEW_HEIGHT_LABEL_ANCHOR_DISTANCE_PX - - LIVE_PREVIEW_HEIGHT_LABEL_STEM_START_DISTANCE_PX -); -const LIVE_PREVIEW_PILL_STEM_EXTRA_DISTANCE_PX = 4; - -const formatMeters = (value: number): string => `${formatNumber(value)}m`; - -const formatLivePreviewElevationText = ( - pointHeightMeters: number, - referenceElevation: number, - hasReferenceElevation: boolean -): string => { - if (!hasReferenceElevation) { - return formatMeters(pointHeightMeters); - } - - const elevationDelta = pointHeightMeters - referenceElevation; - const elevationText = formatMeters(elevationDelta); - - if (Math.abs(elevationDelta) < ELEVATION_NEUTRAL_THRESHOLD_METERS) { - return elevationText; - } - - return `${elevationText} ${ - elevationDelta > 0 ? ELEVATION_GLYPH_UP : ELEVATION_GLYPH_DOWN - }`; -}; - -export const useCesiumPointDomVisualizer = ( - scene: Scene | null, - annotations: AnnotationCollection = [], - { - showLabels = false, - referenceElevation = 0, - selectedPointId = null, - selectedPointIds = [], - pointDragPlaneByPointId, - onPointPlaneDragStart, - onPointPlaneDragPositionChange, - onPointPlaneDragEnd, - hiddenPointLabelIds, - fullyHiddenPointIds, - markerlessPointIds, - pillMarkerPointIds, - suppressCompactLabelPointIds, - onPointClick, - onPointDoubleClick, - onPointLongPress, - onPointHoverChange, - onPointVerticalOffsetStemLongPress, - selectionModeEnabled = false, - selectionRectangleModeEnabled = false, - selectionAdditiveMode = false, - onPointRectangleSelect, - pointLongPressDurationMs = 300, - occlusionChecksEnabled = true, - labelLayoutConfig, - distanceToReferenceByPointId, - pointLabelIndexByPointId, - pointMarkerBadgeByPointId, - referenceLabelPointId = null, - polylinePointLabelTextByPointId, - labelInputPromptPointId = null, - markerOnlyOverlayNodeInteractions = false, - livePreviewPointECEF = null, - livePreviewVerticalOffsetAnchorECEF = null, - livePreviewDistanceLine = null, - livePreviewReferenceElevation = 0, - livePreviewHasReferenceElevation = false, - suppressLivePreviewLabelOverlay = false, - moveGizmoPointId = null, - moveGizmoMarkerSizeScale = 1, - moveGizmoLabelDistanceScale = 1, - moveGizmoIsDragging = false, - renderDomVisuals = true, - }: CesiumPointVisualizerOptions -) => { - const { addLabelOverlayElement, removeLabelOverlayElement } = - useLabelOverlay(); - const livePreviewPointRef = useRef(null); - const livePreviewElevatedPointRef = useRef(null); - const livePreviewAuxAnchorRef = useRef(null); - - const hasLivePreviewPoint = Boolean(livePreviewPointECEF); - const hasLivePreviewAuxAnchor = Boolean(livePreviewVerticalOffsetAnchorECEF); - const showLivePreviewCrosshair = - hasLivePreviewPoint && !hasLivePreviewAuxAnchor; - const [cameraPitch, setCameraPitch] = useState(-Math.PI / 4); - const pointLabelsEnabled = showLabels && renderDomVisuals; - - livePreviewPointRef.current = - livePreviewVerticalOffsetAnchorECEF ?? livePreviewPointECEF; - livePreviewElevatedPointRef.current = livePreviewPointECEF; - livePreviewAuxAnchorRef.current = livePreviewVerticalOffsetAnchorECEF; - - const livePreviewLabelLayoutConfig = useMemo( - () => resolvePointLabelLayoutConfig(labelLayoutConfig), - [labelLayoutConfig] - ); - - const livePreviewHeightLabelPlacement = useMemo(() => { - return createPlacement( - "left", - LIVE_PREVIEW_HEIGHT_LABEL_STEM_DISTANCE_PX, - getPerspectiveStemAngleMagnitude( - cameraPitch, - livePreviewLabelLayoutConfig - ) - ); - }, [cameraPitch, livePreviewLabelLayoutConfig]); - - useEffect(() => { - if ( - !renderDomVisuals || - !scene || - scene.isDestroyed() || - !hasLivePreviewPoint - ) { - return; - } - - const camera = scene.camera; - const updatePitch = () => { - const currentPitch = camera.pitch; - setCameraPitch((previousPitch) => - Math.abs(currentPitch - previousPitch) > 0.001 - ? currentPitch - : previousPitch - ); - }; - - updatePitch(); - const removeChangedListener = camera.changed.addEventListener(updatePitch); - const removeMoveEndListener = camera.moveEnd.addEventListener(updatePitch); - - return () => { - removeChangedListener?.(); - removeMoveEndListener?.(); - }; - }, [scene, hasLivePreviewPoint, renderDomVisuals]); - - const points = useMemo( - () => annotations.filter(isPointAnnotationEntry), - [annotations] - ); - - useCesiumPointLabels( - scene, - points, - pointLabelsEnabled, - referenceElevation, - selectedPointId, - selectedPointIds, - moveGizmoPointId, - moveGizmoIsDragging, - onPointClick, - onPointDoubleClick, - onPointLongPress, - onPointHoverChange, - onPointVerticalOffsetStemLongPress, - selectionModeEnabled, - selectionRectangleModeEnabled, - selectionAdditiveMode, - onPointRectangleSelect, - pointLongPressDurationMs, - occlusionChecksEnabled, - labelLayoutConfig, - distanceToReferenceByPointId, - pointLabelIndexByPointId, - referenceLabelPointId, - polylinePointLabelTextByPointId, - hiddenPointLabelIds, - fullyHiddenPointIds, - markerlessPointIds, - pillMarkerPointIds, - pointDragPlaneByPointId, - onPointPlaneDragStart, - onPointPlaneDragPositionChange, - onPointPlaneDragEnd, - moveGizmoMarkerSizeScale, - moveGizmoLabelDistanceScale, - labelInputPromptPointId, - pointMarkerBadgeByPointId, - suppressCompactLabelPointIds, - markerOnlyOverlayNodeInteractions - ); - - const livePreviewHeightLabelData = useMemo(() => { - if ( - !renderDomVisuals || - suppressLivePreviewLabelOverlay || - !scene || - scene.isDestroyed() || - !livePreviewPointECEF - ) { - return []; - } - - const cartographic = - scene.globe.ellipsoid.cartesianToCartographic(livePreviewPointECEF); - if (!cartographic) { - return []; - } - - const pointHeightMeters = cartographic.height ?? 0; - const showsDistancePreview = - livePreviewDistanceLine?.previewTotalDistanceMeters !== undefined; - const text = showsDistancePreview - ? formatMeters(livePreviewDistanceLine.previewTotalDistanceMeters) - : formatLivePreviewElevationText( - pointHeightMeters, - livePreviewReferenceElevation, - livePreviewHasReferenceElevation - ); - - return [ - { - id: LIVE_PREVIEW_HEIGHT_LABEL_ID, - getCanvasPosition: () => { - if (!scene || scene.isDestroyed()) { - return null; - } - const elevatedPoint = livePreviewElevatedPointRef.current; - if (!elevatedPoint) { - return null; - } - const canvasPosition = SceneTransforms.worldToWindowCoordinates( - scene, - elevatedPoint - ); - if (!defined(canvasPosition)) { - return null; - } - return { - x: canvasPosition.x, - y: canvasPosition.y, - } as CssPixelPosition; - }, - content: text, - collapse: true, - fullBorder: showsDistancePreview, - resizeMode: "fast-grow-slow-shrink", - pitch: cameraPitch, - labelAngleRad: livePreviewHeightLabelPlacement.angleRad, - labelAttach: livePreviewHeightLabelPlacement.attach, - hideMarker: true, - labelDistance: - livePreviewHeightLabelPlacement.distance + - LIVE_PREVIEW_PILL_STEM_EXTRA_DISTANCE_PX, - stemStartDistance: LIVE_PREVIEW_HEIGHT_LABEL_STEM_START_DISTANCE_PX, - }, - ]; - }, [ - cameraPitch, - livePreviewHeightLabelPlacement, - renderDomVisuals, - suppressLivePreviewLabelOverlay, - scene, - livePreviewPointECEF, - livePreviewDistanceLine, - livePreviewHasReferenceElevation, - livePreviewReferenceElevation, - ]); - - const livePreviewVerticalOffsetStemLines = useMemo< - LineVisualizerData[] - >(() => { - if ( - !renderDomVisuals || - !scene || - scene.isDestroyed() || - !hasLivePreviewPoint || - !hasLivePreviewAuxAnchor - ) { - return []; - } - - return [ - { - id: LIVE_PREVIEW_VERTICAL_OFFSET_STEM_ID, - stroke: "rgba(255, 255, 255, 1)", - strokeWidth: 2, - strokeDasharray: "0 3", - strokeDashoffset: 0, - opacity: 0.9, - visible: true, - getCanvasLine: () => { - if (!scene || scene.isDestroyed()) { - return null; - } - const elevatedPoint = livePreviewElevatedPointRef.current; - const auxAnchorPoint = livePreviewAuxAnchorRef.current; - if (!elevatedPoint || !auxAnchorPoint) { - return null; - } - const start = SceneTransforms.worldToWindowCoordinates( - scene, - elevatedPoint - ); - const end = SceneTransforms.worldToWindowCoordinates( - scene, - auxAnchorPoint - ); - if (!defined(start) || !defined(end)) { - return null; - } - return { - start: { x: start.x, y: start.y } as CssPixelPosition, - end: { x: end.x, y: end.y } as CssPixelPosition, - }; - }, - } satisfies LineVisualizerData, - ]; - }, [renderDomVisuals, scene, hasLivePreviewPoint, hasLivePreviewAuxAnchor]); - - useLineVisualizers( - livePreviewVerticalOffsetStemLines, - renderDomVisuals && livePreviewVerticalOffsetStemLines.length > 0 - ); - - usePointLabels( - livePreviewHeightLabelData, - renderDomVisuals && hasLivePreviewPoint && !suppressLivePreviewLabelOverlay, - undefined, - undefined, - { - transitionDurationMs: 0, - } - ); - - const livePreviewCrosshairContent = useMemo(() => { - const crosshairStrokeBlendStyle = { - backgroundColor: CROSSHAIR_STROKE_COLOR, - }; - - return createElement( - "div", - { - style: { - position: "relative", - width: `${CROSSHAIR_SIZE_PX}px`, - height: `${CROSSHAIR_SIZE_PX}px`, - pointerEvents: "none", - filter: CROSSHAIR_CONTRAST_FILTER, - }, - }, - createElement("div", { - key: "center-dot", - style: { - position: "absolute", - left: `${CROSSHAIR_CENTER_PX}px`, - top: `${CROSSHAIR_CENTER_PX}px`, - width: `${CROSSHAIR_CENTER_DOT_SIZE_PX}px`, - height: `${CROSSHAIR_CENTER_DOT_SIZE_PX}px`, - transform: "translate(-50%, -50%)", - ...crosshairStrokeBlendStyle, - }, - }), - createElement("div", { - key: "h-right-dash", - style: { - position: "absolute", - left: `${CROSSHAIR_CENTER_PX + CROSSHAIR_CENTER_GAP_PX}px`, - top: `${CROSSHAIR_CENTER_PX}px`, - width: `${CROSSHAIR_FAR_DASH_LENGTH_PX}px`, - height: `${CROSSHAIR_THICKNESS_PX}px`, - transform: "translateY(-50%)", - clipPath: `polygon(0 50%, ${CROSSHAIR_INNER_TIP_PX}px 0, 100% 0, 100% 100%, ${CROSSHAIR_INNER_TIP_PX}px 100%)`, - ...crosshairStrokeBlendStyle, - }, - }), - createElement("div", { - key: "h-left-dash", - style: { - position: "absolute", - left: `${ - CROSSHAIR_CENTER_PX - - CROSSHAIR_CENTER_GAP_PX - - CROSSHAIR_FAR_DASH_LENGTH_PX - }px`, - top: `${CROSSHAIR_CENTER_PX}px`, - width: `${CROSSHAIR_FAR_DASH_LENGTH_PX}px`, - height: `${CROSSHAIR_THICKNESS_PX}px`, - transform: "translateY(-50%)", - clipPath: `polygon(0 0, calc(100% - ${CROSSHAIR_INNER_TIP_PX}px) 0, 100% 50%, calc(100% - ${CROSSHAIR_INNER_TIP_PX}px) 100%, 0 100%)`, - ...crosshairStrokeBlendStyle, - }, - }), - createElement("div", { - key: "v-bottom-dash", - style: { - position: "absolute", - left: `${CROSSHAIR_CENTER_PX}px`, - top: `${CROSSHAIR_CENTER_PX + CROSSHAIR_CENTER_GAP_PX}px`, - width: `${CROSSHAIR_THICKNESS_PX}px`, - height: `${CROSSHAIR_FAR_DASH_LENGTH_PX}px`, - transform: "translateX(-50%)", - clipPath: `polygon(0 ${CROSSHAIR_INNER_TIP_PX}px, 50% 0, 100% ${CROSSHAIR_INNER_TIP_PX}px, 100% 100%, 0 100%)`, - ...crosshairStrokeBlendStyle, - }, - }), - createElement("div", { - key: "v-top-dash", - style: { - position: "absolute", - left: `${CROSSHAIR_CENTER_PX}px`, - top: `${ - CROSSHAIR_CENTER_PX - - CROSSHAIR_CENTER_GAP_PX - - CROSSHAIR_FAR_DASH_LENGTH_PX - }px`, - width: `${CROSSHAIR_THICKNESS_PX}px`, - height: `${CROSSHAIR_FAR_DASH_LENGTH_PX}px`, - transform: "translateX(-50%)", - clipPath: `polygon(0 0, 100% 0, 100% calc(100% - ${CROSSHAIR_INNER_TIP_PX}px), 50% 100%, 0 calc(100% - ${CROSSHAIR_INNER_TIP_PX}px))`, - ...crosshairStrokeBlendStyle, - }, - }) - ); - }, []); - - const getLivePreviewCanvasPosition = - useCallback((): CssPixelPosition | null => { - if (!scene || scene.isDestroyed()) { - return null; - } - const position = livePreviewPointRef.current; - if (!position) { - return null; - } - const canvasPosition = SceneTransforms.worldToWindowCoordinates( - scene, - position - ); - if (!defined(canvasPosition)) { - return null; - } - return { - x: canvasPosition.x, - y: canvasPosition.y + CROSSHAIR_ANCHOR_OFFSET_Y_PX, - } as CssPixelPosition; - }, [scene]); - - useEffect(() => { - if (!renderDomVisuals || !scene || scene.isDestroyed()) { - removeLabelOverlayElement(LIVE_PREVIEW_CROSSHAIR_ID); - return; - } - - addLabelOverlayElement({ - id: LIVE_PREVIEW_CROSSHAIR_ID, - zIndex: 22, - getCanvasPosition: getLivePreviewCanvasPosition, - content: livePreviewCrosshairContent, - visible: showLivePreviewCrosshair, - }); - - return () => { - removeLabelOverlayElement(LIVE_PREVIEW_CROSSHAIR_ID); - }; - }, [ - scene, - renderDomVisuals, - showLivePreviewCrosshair, - addLabelOverlayElement, - removeLabelOverlayElement, - getLivePreviewCanvasPosition, - livePreviewCrosshairContent, - ]); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointQuery.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointQuery.ts deleted file mode 100644 index 2789dbbe53..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointQuery.ts +++ /dev/null @@ -1,405 +0,0 @@ -import { useEffect, useRef } from "react"; - -import { - Cartesian2, - Cartesian3, - ScreenSpaceEventHandler, - ScreenSpaceEventType, - type Scene, -} from "@carma/cesium"; -import { - POINTER_NORMAL_EPSILON_SQUARED, - getLocalUpDirectionECEF, -} from "./utils/pointSurfaceMath"; - -const POINT_CLICK_DELAY_MS = 220; -const POINTER_NORMAL_SAMPLE_OFFSET_PX = 2; -const CLEARED_POINTER_POSITION = new Cartesian2(Number.NaN, Number.NaN); - -const pickPositionWithMeasurementFillBypass = ( - scene: Scene, - screenPosition: Cartesian2 -): Cartesian3 | null => scene.pickPosition(screenPosition) ?? null; - -const pickGlobePosition = ( - scene: Scene, - screenPosition: Cartesian2 -): Cartesian3 | null => { - const pickRay = scene.camera.getPickRay(screenPosition); - if (!pickRay) return null; - return scene.globe.pick(pickRay, scene) ?? null; -}; - -const estimateSurfaceNormalAtPointer = ( - scene: Scene, - screenPosition: Cartesian2, - centerPosition: Cartesian3 -): Cartesian3 => { - const rightPosition = pickPositionWithMeasurementFillBypass( - scene, - new Cartesian2( - screenPosition.x + POINTER_NORMAL_SAMPLE_OFFSET_PX, - screenPosition.y - ) - ); - const leftPosition = pickPositionWithMeasurementFillBypass( - scene, - new Cartesian2( - screenPosition.x - POINTER_NORMAL_SAMPLE_OFFSET_PX, - screenPosition.y - ) - ); - const upPosition = pickPositionWithMeasurementFillBypass( - scene, - new Cartesian2( - screenPosition.x, - screenPosition.y - POINTER_NORMAL_SAMPLE_OFFSET_PX - ) - ); - const downPosition = pickPositionWithMeasurementFillBypass( - scene, - new Cartesian2( - screenPosition.x, - screenPosition.y + POINTER_NORMAL_SAMPLE_OFFSET_PX - ) - ); - - if (!rightPosition || !leftPosition || !upPosition || !downPosition) { - return getLocalUpDirectionECEF(centerPosition); - } - - const tangentX = Cartesian3.subtract( - rightPosition, - leftPosition, - new Cartesian3() - ); - const tangentY = Cartesian3.subtract( - downPosition, - upPosition, - new Cartesian3() - ); - if ( - Cartesian3.magnitudeSquared(tangentX) <= POINTER_NORMAL_EPSILON_SQUARED || - Cartesian3.magnitudeSquared(tangentY) <= POINTER_NORMAL_EPSILON_SQUARED - ) { - return getLocalUpDirectionECEF(centerPosition); - } - - const sampledNormal = Cartesian3.cross(tangentX, tangentY, new Cartesian3()); - if ( - Cartesian3.magnitudeSquared(sampledNormal) <= POINTER_NORMAL_EPSILON_SQUARED - ) { - return getLocalUpDirectionECEF(centerPosition); - } - - const normalizedNormal = Cartesian3.normalize( - sampledNormal, - new Cartesian3() - ); - const localUp = getLocalUpDirectionECEF(centerPosition); - if (Cartesian3.dot(normalizedNormal, localUp) < 0) { - return Cartesian3.negate(normalizedNormal, new Cartesian3()); - } - - return normalizedNormal; -}; - -export type CesiumPointQueryCreatePayload = { - screenPosition: Cartesian2; - pickedPositionECEF: Cartesian3; - anchorPositionECEF: Cartesian3; - geometryPositionECEF: Cartesian3; - localUpDirectionECEF: Cartesian3; - verticalOffsetMeters: number; - hasVerticalOffsetStem: boolean; -}; - -export type CesiumPointQueryPointerMoveHandler = ( - positionECEF: Cartesian3 | null, - screenPosition: Cartesian2, - surfaceNormalECEF?: Cartesian3 | null -) => void; - -export type CesiumPointQueryOptions = { - enabled?: boolean; - hideCursorWhileEnabled?: boolean; - pointClickDelayMs?: number; - verticalOffsetMeters?: number; - preferGlobeAnchorForVerticalOffset?: boolean; - onBeforePointCreate?: ( - positionECEF: Cartesian3 | null, - screenPosition: Cartesian2 - ) => boolean; - onPointCreate?: (payload: CesiumPointQueryCreatePayload) => void; - onLineFinish?: () => void; - onPointerMove?: CesiumPointQueryPointerMoveHandler; -}; - -export const useCesiumPointQuery = ( - scene: Scene | null, - { - enabled = true, - hideCursorWhileEnabled = true, - pointClickDelayMs = POINT_CLICK_DELAY_MS, - verticalOffsetMeters = 0, - preferGlobeAnchorForVerticalOffset = false, - onBeforePointCreate, - onPointCreate, - onLineFinish, - onPointerMove, - }: CesiumPointQueryOptions = {} -) => { - const handlerRef = useRef(null); - const pendingPointerMovePositionRef = useRef(null); - const pointerMoveFrameRef = useRef(null); - const onBeforePointCreateRef = useRef(onBeforePointCreate); - const onPointCreateRef = useRef(onPointCreate); - const onLineFinishRef = useRef(onLineFinish); - const onPointerMoveRef = useRef(onPointerMove); - - useEffect(() => { - onBeforePointCreateRef.current = onBeforePointCreate; - }, [onBeforePointCreate]); - - useEffect(() => { - onPointCreateRef.current = onPointCreate; - }, [onPointCreate]); - - useEffect(() => { - onLineFinishRef.current = onLineFinish; - }, [onLineFinish]); - - useEffect(() => { - onPointerMoveRef.current = onPointerMove; - }, [onPointerMove]); - - useEffect(() => { - if (!scene || scene.isDestroyed()) return; - - scene.canvas.style.cursor = enabled && hideCursorWhileEnabled ? "none" : ""; - return () => { - if (!scene.isDestroyed()) { - scene.canvas.style.cursor = ""; - } - }; - }, [scene, enabled, hideCursorWhileEnabled]); - - useEffect(() => { - if (!scene || scene.isDestroyed() || !enabled) { - if (pointerMoveFrameRef.current !== null) { - window.cancelAnimationFrame(pointerMoveFrameRef.current); - pointerMoveFrameRef.current = null; - } - pendingPointerMovePositionRef.current = null; - onPointerMoveRef.current?.(null, CLEARED_POINTER_POSITION, null); - if (handlerRef.current) { - handlerRef.current.destroy(); - handlerRef.current = null; - } - return; - } - - const handler = new ScreenSpaceEventHandler(scene.canvas); - handlerRef.current = handler; - let clickTimeoutId: number | undefined; - - const clearLivePreview = () => { - pendingPointerMovePositionRef.current = null; - if (pointerMoveFrameRef.current !== null) { - window.cancelAnimationFrame(pointerMoveFrameRef.current); - pointerMoveFrameRef.current = null; - } - onPointerMoveRef.current?.(null, CLEARED_POINTER_POSITION, null); - scene.requestRender(); - }; - - const handleCanvasPointerLeave = () => { - clearLivePreview(); - }; - - const handleCanvasBlur = () => { - clearLivePreview(); - }; - - const handleWindowBlur = () => { - clearLivePreview(); - }; - - const handleDocumentVisibilityChange = () => { - if (document.visibilityState !== "visible") { - clearLivePreview(); - } - }; - - scene.canvas.addEventListener("mouseleave", handleCanvasPointerLeave); - scene.canvas.addEventListener("blur", handleCanvasBlur); - window.addEventListener("blur", handleWindowBlur); - document.addEventListener( - "visibilitychange", - handleDocumentVisibilityChange - ); - - const flushPointerMove = () => { - pointerMoveFrameRef.current = null; - if (!scene || scene.isDestroyed()) { - pendingPointerMovePositionRef.current = null; - return; - } - - const pendingPosition = pendingPointerMovePositionRef.current; - pendingPointerMovePositionRef.current = null; - if (!pendingPosition) { - return; - } - - const pickedPosition = pickPositionWithMeasurementFillBypass( - scene, - pendingPosition - ); - const safeVerticalOffsetMeters = Number.isFinite(verticalOffsetMeters) - ? verticalOffsetMeters - : 0; - const hasVerticalOffsetStem = Math.abs(safeVerticalOffsetMeters) > 1e-9; - const previewAnchorPosition = pickedPosition; - const sampledSurfaceNormal = previewAnchorPosition - ? hasVerticalOffsetStem - ? getLocalUpDirectionECEF(previewAnchorPosition) - : estimateSurfaceNormalAtPointer( - scene, - pendingPosition, - previewAnchorPosition - ) - : null; - onPointerMoveRef.current?.( - previewAnchorPosition ?? null, - pendingPosition, - sampledSurfaceNormal - ); - - if ( - pendingPointerMovePositionRef.current && - pointerMoveFrameRef.current === null - ) { - pointerMoveFrameRef.current = - window.requestAnimationFrame(flushPointerMove); - } - }; - - const createPointAt = (screenPosition: Cartesian2) => { - const pickedPosition = pickPositionWithMeasurementFillBypass( - scene, - screenPosition - ); - - if ( - onBeforePointCreateRef.current && - !onBeforePointCreateRef.current(pickedPosition ?? null, screenPosition) - ) { - scene.requestRender(); - return; - } - - if (!pickedPosition) { - return; - } - - const safeVerticalOffsetMeters = Number.isFinite(verticalOffsetMeters) - ? verticalOffsetMeters - : 0; - const hasVerticalOffsetStem = Math.abs(safeVerticalOffsetMeters) > 1e-9; - const anchorPosition = - hasVerticalOffsetStem && preferGlobeAnchorForVerticalOffset - ? pickGlobePosition(scene, screenPosition) - : pickedPosition; - if (!anchorPosition) { - scene.requestRender(); - return; - } - - const localUpDirectionECEF = getLocalUpDirectionECEF(anchorPosition); - const offsetVectorECEF = Cartesian3.multiplyByScalar( - localUpDirectionECEF, - safeVerticalOffsetMeters, - new Cartesian3() - ); - const geometryPositionECEF = Cartesian3.add( - anchorPosition, - offsetVectorECEF, - new Cartesian3() - ); - - onPointCreateRef.current?.({ - screenPosition, - pickedPositionECEF: pickedPosition, - anchorPositionECEF: anchorPosition, - geometryPositionECEF, - localUpDirectionECEF, - verticalOffsetMeters: safeVerticalOffsetMeters, - hasVerticalOffsetStem, - }); - - scene.requestRender(); - }; - - handler.setInputAction((event: { position: Cartesian2 }) => { - if (clickTimeoutId !== undefined) { - window.clearTimeout(clickTimeoutId); - } - clickTimeoutId = window.setTimeout(() => { - createPointAt(event.position); - clickTimeoutId = undefined; - }, pointClickDelayMs); - }, ScreenSpaceEventType.LEFT_CLICK); - - handler.setInputAction(() => { - if (clickTimeoutId !== undefined) { - window.clearTimeout(clickTimeoutId); - clickTimeoutId = undefined; - } - onLineFinishRef.current?.(); - }, ScreenSpaceEventType.LEFT_DOUBLE_CLICK); - - handler.setInputAction((event: { endPosition: Cartesian2 }) => { - pendingPointerMovePositionRef.current = Cartesian2.clone( - event.endPosition, - new Cartesian2() - ); - - if (pointerMoveFrameRef.current === null) { - pointerMoveFrameRef.current = - window.requestAnimationFrame(flushPointerMove); - } - }, ScreenSpaceEventType.MOUSE_MOVE); - - return () => { - if (clickTimeoutId !== undefined) { - window.clearTimeout(clickTimeoutId); - clickTimeoutId = undefined; - } - scene.canvas.removeEventListener("mouseleave", handleCanvasPointerLeave); - scene.canvas.removeEventListener("blur", handleCanvasBlur); - window.removeEventListener("blur", handleWindowBlur); - document.removeEventListener( - "visibilitychange", - handleDocumentVisibilityChange - ); - if (pointerMoveFrameRef.current !== null) { - window.cancelAnimationFrame(pointerMoveFrameRef.current); - pointerMoveFrameRef.current = null; - } - pendingPointerMovePositionRef.current = null; - if (handlerRef.current) { - handlerRef.current.destroy(); - handlerRef.current = null; - } - }; - }, [ - scene, - enabled, - pointClickDelayMs, - verticalOffsetMeters, - preferGlobeAnchorForVerticalOffset, - ]); -}; - -export default useCesiumPointQuery; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointVisualizer.ts deleted file mode 100644 index 6befb95526..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointVisualizer.ts +++ /dev/null @@ -1,611 +0,0 @@ -/* @refresh reset */ -import { useEffect, useMemo, useRef } from "react"; - -import { - CarmaTransforms, - Cartesian3, - Color, - Matrix4, - Primitive, - SceneTransforms, - defined, - type Scene, -} from "@carma/cesium"; -import { - createDisc, - createRing, -} from "@carma-mapping/engines/cesium/primitives"; -import { - isPointAnnotationEntry, - AnnotationCollection, - type PlanarPolygonPlane, - type PointAnnotationEntry, -} from "../types/AnnotationTypes"; -import { - type CesiumLabelLayoutConfigOverrides, - type PointMarkerBadge, -} from "./useCesiumPointLabels"; -import { useAnnotationMoveGizmoAdapter } from "./useAnnotationMoveGizmoAdapter"; -import { - type LivePreviewDiscSample, - getAveragedLivePreviewDiscNormal, - pushLivePreviewDiscSample, -} from "./utils/livePreviewDiscNormalSmoothing"; -import { - POINTER_NORMAL_EPSILON_SQUARED, - getLocalUpDirectionECEF, -} from "./utils/pointSurfaceMath"; - -const LIVE_PREVIEW_DISC_RADIUS_SCALE = 1.4; -const LIVE_PREVIEW_DISC_ALPHA = 0.66; -const LIVE_PREVIEW_DISC_SCREEN_RADIUS_PX = 48; -const LIVE_PREVIEW_DISC_SMOOTHING_SAMPLE_COUNT = 10; -const LIVE_PREVIEW_DISC_SMOOTHING_WINDOW_MS = 300; -const SELECTED_DISC_SCREEN_RADIUS_PX = 50; -const DISC_PROJECTION_SCALE_SAMPLE_COUNT = 16; -const DISC_MIN_WORLD_RADIUS = 1e-3; -const DISC_MIN_PROJECTED_PIXEL_PER_WORLD = 1e-6; - -type LivePreviewDiscQueuedInput = { - pointRef: Cartesian3 | null; - surfaceNormalRef: Cartesian3 | null; -}; - -const safeRemovePrimitive = ( - scene: Scene | null, - primitive: Primitive | null | undefined -) => { - if (!scene || !primitive) return; - try { - if (!scene.isDestroyed()) { - scene.primitives.remove(primitive); - } - } catch { - // Scene/primitive teardown may race while effects are cleaning up. - } -}; - -const safeCall = (callback: (() => void) | null | undefined) => { - if (!callback) return; - try { - callback(); - } catch { - // Listener removal can race with scene/widget teardown. - } -}; - -const createPlaneBasis = (normal: Cartesian3) => { - const up = Cartesian3.normalize(normal, new Cartesian3()); - const reference = - Math.abs(Cartesian3.dot(up, Cartesian3.UNIT_Z)) > 0.9 - ? Cartesian3.UNIT_X - : Cartesian3.UNIT_Z; - const xAxis = Cartesian3.normalize( - Cartesian3.cross(up, reference, new Cartesian3()), - new Cartesian3() - ); - const yAxis = Cartesian3.normalize( - Cartesian3.cross(xAxis, up, new Cartesian3()), - new Cartesian3() - ); - return { xAxis, yAxis }; -}; - -const resolveDiscNormal = ( - origin: Cartesian3, - preferredNormal: Cartesian3 | null | undefined -): Cartesian3 => { - if ( - preferredNormal && - Cartesian3.magnitudeSquared(preferredNormal) > - POINTER_NORMAL_EPSILON_SQUARED - ) { - return Cartesian3.normalize(preferredNormal, new Cartesian3()); - } - return getLocalUpDirectionECEF(origin); -}; - -const createOrientedDiscModelMatrix = ( - origin: Cartesian3, - planeNormal: Cartesian3, - radius: number, - result?: Matrix4 -): Matrix4 => { - const safeRadius = Math.max(radius, DISC_MIN_WORLD_RADIUS); - const normalizedNormal = Cartesian3.normalize(planeNormal, new Cartesian3()); - const planeBasis = createPlaneBasis(normalizedNormal); - return CarmaTransforms.createBasisScaleTranslationMatrix( - origin, - planeBasis.xAxis, - planeBasis.yAxis, - normalizedNormal, - safeRadius, - safeRadius, - 1, - result - ); -}; - -const getDiscWorldRadius = ( - scene: Scene, - origin: Cartesian3, - planeNormal: Cartesian3, - configuredWorldRadius: number, - fixedScreenRadiusPx?: number -): number => { - const baseRadius = Math.max(configuredWorldRadius, DISC_MIN_WORLD_RADIUS); - if (fixedScreenRadiusPx === undefined) { - return baseRadius; - } - - const anchorCanvasPosition = SceneTransforms.worldToWindowCoordinates( - scene, - origin - ); - if (!defined(anchorCanvasPosition)) { - return baseRadius; - } - - const planeBasis = createPlaneBasis(planeNormal); - let pixelPerWorldMax = 0; - for (let i = 0; i < DISC_PROJECTION_SCALE_SAMPLE_COUNT; i += 1) { - const t = (i / DISC_PROJECTION_SCALE_SAMPLE_COUNT) * Math.PI * 2; - const sampleDirection = Cartesian3.add( - Cartesian3.multiplyByScalar( - planeBasis.xAxis, - Math.cos(t), - new Cartesian3() - ), - Cartesian3.multiplyByScalar( - planeBasis.yAxis, - Math.sin(t), - new Cartesian3() - ), - new Cartesian3() - ); - const sampleWorld = Cartesian3.add( - origin, - sampleDirection, - new Cartesian3() - ); - const sampleCanvas = SceneTransforms.worldToWindowCoordinates( - scene, - sampleWorld - ); - if (!defined(sampleCanvas)) continue; - - const dx = sampleCanvas.x - anchorCanvasPosition.x; - const dy = sampleCanvas.y - anchorCanvasPosition.y; - const d = Math.hypot(dx, dy); - if (Number.isFinite(d) && d > pixelPerWorldMax) { - pixelPerWorldMax = d; - } - } - - if (pixelPerWorldMax <= DISC_MIN_PROJECTED_PIXEL_PER_WORLD) { - return baseRadius; - } - - return Math.max( - fixedScreenRadiusPx / pixelPerWorldMax, - DISC_MIN_WORLD_RADIUS - ); -}; - -export type CesiumPointVisualizerOptions = { - showMarkers?: boolean; - showLabels?: boolean; - radius: number; - referenceElevation?: number; - selectedPointId?: string | null; - selectedPointIds?: string[]; - pointDragPlaneByPointId?: Readonly>; - onPointPlaneDragStart?: (pointId: string) => void; - onPointPlaneDragPositionChange?: ( - pointId: string, - nextPosition: Cartesian3 - ) => void; - onPointPlaneDragEnd?: (pointId: string) => void; - hiddenPointLabelIds?: ReadonlySet; - fullyHiddenPointIds?: ReadonlySet; - markerlessPointIds?: ReadonlySet; - pillMarkerPointIds?: ReadonlySet; - suppressCompactLabelPointIds?: ReadonlySet; - showSelectedDisc?: boolean; - onPointClick?: (pointId: string) => void; - onPointDoubleClick?: (pointId: string) => void; - onPointLongPress?: (pointId: string) => void; - onPointHoverChange?: (pointId: string, hovered: boolean) => void; - onPointVerticalOffsetStemLongPress?: (pointId: string) => void; - selectionModeEnabled?: boolean; - selectionRectangleModeEnabled?: boolean; - selectionAdditiveMode?: boolean; - onPointRectangleSelect?: (pointIds: string[], additive: boolean) => void; - pointLongPressDurationMs?: number; - occlusionChecksEnabled?: boolean; - labelLayoutConfig?: CesiumLabelLayoutConfigOverrides; - distanceToReferenceByPointId?: Readonly>; - pointLabelIndexByPointId?: Readonly>; - pointMarkerBadgeByPointId?: Readonly>; - referenceLabelPointId?: string | null; - polylinePointLabelTextByPointId?: Readonly>; - labelInputPromptPointId?: string | null; - markerOnlyOverlayNodeInteractions?: boolean; - livePreviewPointECEF?: Cartesian3 | null; - livePreviewSurfaceNormalECEF?: Cartesian3 | null; - livePreviewVerticalOffsetAnchorECEF?: Cartesian3 | null; - livePreviewDistanceLine?: { - anchorPointECEF: Cartesian3; - targetPointECEF: Cartesian3; - showDirectLine: boolean; - showVerticalLine: boolean; - showHorizontalLine: boolean; - previewTotalDistanceMeters?: number; - } | null; - livePreviewReferenceElevation?: number; - livePreviewHasReferenceElevation?: boolean; - suppressLivePreviewLabelOverlay?: boolean; - moveGizmoPointId?: string | null; - moveGizmoAxisDirection?: Cartesian3 | null; - moveGizmoAxisTitle?: string | null; - moveGizmoPreferredAxisId?: string | null; - moveGizmoAxisCandidates?: Array<{ - id: string; - direction: Cartesian3; - color?: string; - title?: string | null; - }> | null; - moveGizmoMarkerSizeScale?: number; - moveGizmoLabelDistanceScale?: number; - moveGizmoSnapPlaneDragToGround?: boolean; - moveGizmoShowRotationHandle?: boolean; - moveGizmoIsDragging?: boolean; - onMoveGizmoPointPositionChange?: ( - pointId: string, - nextPosition: Cartesian3 - ) => void; - onMoveGizmoDragStateChange?: (isDragging: boolean) => void; - onMoveGizmoAxisChange?: ( - axisDirection: Cartesian3, - axisTitle?: string | null - ) => void; - onMoveGizmoExit?: () => void; - renderDomVisuals?: boolean; - renderCesiumCoreVisuals?: boolean; -}; - -export const useCesiumPointVisualizer = ( - scene: Scene | null, - annotations: AnnotationCollection = [], - { - radius, - selectedPointId = null, - showSelectedDisc = false, - livePreviewPointECEF = null, - livePreviewSurfaceNormalECEF = null, - livePreviewVerticalOffsetAnchorECEF = null, - moveGizmoPointId = null, - moveGizmoAxisDirection = null, - moveGizmoAxisTitle = null, - moveGizmoPreferredAxisId = null, - moveGizmoAxisCandidates = null, - moveGizmoSnapPlaneDragToGround = false, - moveGizmoShowRotationHandle = true, - onMoveGizmoPointPositionChange, - onMoveGizmoDragStateChange, - onMoveGizmoAxisChange, - onMoveGizmoExit, - renderCesiumCoreVisuals = true, - }: CesiumPointVisualizerOptions -) => { - const selectedDiscRef = useRef(null); - const livePreviewDiscRef = useRef(null); - const removeLivePreviewDiscPostRenderListenerRef = useRef< - (() => void) | null - >(null); - const removeSelectedDiscPostRenderListenerRef = useRef<(() => void) | null>( - null - ); - const livePreviewPointRef = useRef(null); - const livePreviewSurfaceNormalRef = useRef(null); - const livePreviewDiscSamplesRef = useRef([]); - const livePreviewDiscLastQueuedInputRef = - useRef(null); - const livePreviewDiscColor = useMemo( - () => Color.WHITE.withAlpha(LIVE_PREVIEW_DISC_ALPHA), - [] - ); - - livePreviewPointRef.current = - livePreviewVerticalOffsetAnchorECEF ?? livePreviewPointECEF; - livePreviewSurfaceNormalRef.current = livePreviewSurfaceNormalECEF; - - const points: PointAnnotationEntry[] = useMemo( - () => annotations.filter(isPointAnnotationEntry), - [annotations] - ); - - useAnnotationMoveGizmoAdapter({ - scene: renderCesiumCoreVisuals ? scene : null, - points, - moveGizmoPointId: renderCesiumCoreVisuals ? moveGizmoPointId : null, - moveGizmoAxisDirection: renderCesiumCoreVisuals - ? moveGizmoAxisDirection - : null, - moveGizmoAxisTitle: renderCesiumCoreVisuals ? moveGizmoAxisTitle : null, - moveGizmoPreferredAxisId: renderCesiumCoreVisuals - ? moveGizmoPreferredAxisId - : null, - moveGizmoAxisCandidates: renderCesiumCoreVisuals - ? moveGizmoAxisCandidates - : null, - moveGizmoSnapPlaneDragToGround: renderCesiumCoreVisuals - ? moveGizmoSnapPlaneDragToGround - : false, - moveGizmoShowRotationHandle: renderCesiumCoreVisuals - ? moveGizmoShowRotationHandle - : false, - radius, - onMoveGizmoPointPositionChange, - onMoveGizmoDragStateChange, - onMoveGizmoAxisChange, - onMoveGizmoExit, - }); - - useEffect(() => { - if (!scene) return; - - safeCall(removeLivePreviewDiscPostRenderListenerRef.current); - removeLivePreviewDiscPostRenderListenerRef.current = null; - - const livePreviewDiscRadius = Math.max( - radius * LIVE_PREVIEW_DISC_RADIUS_SCALE, - 0.1 - ); - const averagedNormal = new Cartesian3(); - - const clearLivePreviewDisc = () => { - if (livePreviewDiscRef.current) { - safeRemovePrimitive(scene, livePreviewDiscRef.current); - } - livePreviewDiscRef.current = null; - livePreviewDiscSamplesRef.current = []; - livePreviewDiscLastQueuedInputRef.current = null; - }; - - const ensureLivePreviewDisc = () => { - const center = livePreviewPointRef.current; - if (!center) { - clearLivePreviewDisc(); - return null; - } - - let disc = livePreviewDiscRef.current; - if (!disc) { - const nextDisc = createRing("measurement-live-pointer-preview", { - radius: 1, - innerRadius: 0.5, - color: livePreviewDiscColor, - segments: 20, - }); - scene.primitives.add(nextDisc); - livePreviewDiscRef.current = nextDisc; - disc = nextDisc; - } - return disc; - }; - - const shouldQueueCurrentDiscSample = () => { - const currentInput: LivePreviewDiscQueuedInput = { - pointRef: livePreviewPointRef.current, - surfaceNormalRef: livePreviewSurfaceNormalRef.current, - }; - const previousInput = livePreviewDiscLastQueuedInputRef.current; - const hasInputChanged = - !previousInput || - previousInput.pointRef !== currentInput.pointRef || - previousInput.surfaceNormalRef !== currentInput.surfaceNormalRef; - if (!hasInputChanged) { - return false; - } - livePreviewDiscLastQueuedInputRef.current = currentInput; - return true; - }; - - const queueDiscSample = (normal: Cartesian3) => { - pushLivePreviewDiscSample({ - samples: livePreviewDiscSamplesRef.current, - normal, - maxSampleCount: LIVE_PREVIEW_DISC_SMOOTHING_SAMPLE_COUNT, - timestampMs: performance.now(), - }); - }; - - const getAveragedDiscNormal = (fallbackNormal: Cartesian3) => { - return getAveragedLivePreviewDiscNormal({ - samples: livePreviewDiscSamplesRef.current, - fallbackNormal, - result: averagedNormal, - epsilonSquared: POINTER_NORMAL_EPSILON_SQUARED, - maxSampleAgeMs: LIVE_PREVIEW_DISC_SMOOTHING_WINDOW_MS, - nowMs: performance.now(), - }); - }; - - if (!renderCesiumCoreVisuals) { - clearLivePreviewDisc(); - if (!scene.isDestroyed()) { - scene.requestRender(); - } - return; - } - - ensureLivePreviewDisc(); - - const updateLivePreviewDisc = () => { - if (scene.isDestroyed()) { - return; - } - - const center = livePreviewPointRef.current; - if (!center) { - clearLivePreviewDisc(); - return; - } - - const discNormal = resolveDiscNormal( - center, - livePreviewSurfaceNormalRef.current - ); - const sampledRadius = getDiscWorldRadius( - scene, - center, - discNormal, - livePreviewDiscRadius, - LIVE_PREVIEW_DISC_SCREEN_RADIUS_PX - ); - const activeDisc = livePreviewDiscRef.current ?? ensureLivePreviewDisc(); - if (!activeDisc) { - return; - } - - if (shouldQueueCurrentDiscSample()) { - queueDiscSample(discNormal); - } - const averagedNormal = getAveragedDiscNormal(discNormal); - activeDisc.modelMatrix = createOrientedDiscModelMatrix( - center, - averagedNormal, - sampledRadius, - activeDisc.modelMatrix - ); - }; - - updateLivePreviewDisc(); - - removeLivePreviewDiscPostRenderListenerRef.current = - scene.postRender.addEventListener(updateLivePreviewDisc); - scene.requestRender(); - }, [renderCesiumCoreVisuals, scene, radius, livePreviewDiscColor]); - - useEffect(() => { - if (!scene || scene.isDestroyed()) return; - scene.requestRender(); - }, [scene, livePreviewPointECEF, livePreviewVerticalOffsetAnchorECEF]); - - useEffect(() => { - return () => { - safeCall(removeLivePreviewDiscPostRenderListenerRef.current); - removeLivePreviewDiscPostRenderListenerRef.current = null; - safeCall(removeSelectedDiscPostRenderListenerRef.current); - removeSelectedDiscPostRenderListenerRef.current = null; - if (livePreviewDiscRef.current) { - safeRemovePrimitive(scene, livePreviewDiscRef.current); - livePreviewDiscRef.current = null; - } - livePreviewDiscSamplesRef.current = []; - if (selectedDiscRef.current) { - safeRemovePrimitive(scene, selectedDiscRef.current); - selectedDiscRef.current = null; - } - }; - }, [scene]); - - useEffect(() => { - if (!scene) return; - - safeCall(removeSelectedDiscPostRenderListenerRef.current); - removeSelectedDiscPostRenderListenerRef.current = null; - - const clearSelectedDisc = () => { - if (selectedDiscRef.current) { - safeRemovePrimitive(scene, selectedDiscRef.current); - selectedDiscRef.current = null; - } - }; - - if (!renderCesiumCoreVisuals) { - clearSelectedDisc(); - if (!scene.isDestroyed()) { - scene.requestRender(); - } - return; - } - - if (!showSelectedDisc || !selectedPointId) { - clearSelectedDisc(); - scene.requestRender(); - return; - } - - const moveGizmoOnSelectedPoint = - moveGizmoPointId !== null && moveGizmoPointId === selectedPointId; - if (moveGizmoOnSelectedPoint) { - clearSelectedDisc(); - scene.requestRender(); - return; - } - - const selectedPoint = points.find((point) => point.id === selectedPointId); - if (!selectedPoint) { - clearSelectedDisc(); - scene.requestRender(); - return; - } - - if (!selectedDiscRef.current) { - selectedDiscRef.current = createDisc("selectedGuide", { - radius: 1, - color: Color.WHITE.withAlpha(0.5), - segments: 24, - }); - scene.primitives.add(selectedDiscRef.current); - } - - const updateSelectedDisc = () => { - const activeDisc = selectedDiscRef.current; - if (!activeDisc || scene.isDestroyed()) return; - const discNormal = resolveDiscNormal( - selectedPoint.geometryECEF, - moveGizmoAxisDirection - ); - const discWorldRadius = getDiscWorldRadius( - scene, - selectedPoint.geometryECEF, - discNormal, - radius, - SELECTED_DISC_SCREEN_RADIUS_PX - ); - activeDisc.modelMatrix = createOrientedDiscModelMatrix( - selectedPoint.geometryECEF, - discNormal, - discWorldRadius, - activeDisc.modelMatrix - ); - }; - - updateSelectedDisc(); - removeSelectedDiscPostRenderListenerRef.current = - scene.postRender.addEventListener(() => { - if (scene.isDestroyed()) return; - updateSelectedDisc(); - }); - scene.requestRender(); - - return () => { - safeCall(removeSelectedDiscPostRenderListenerRef.current); - removeSelectedDiscPostRenderListenerRef.current = null; - }; - }, [ - scene, - points, - selectedPointId, - radius, - showSelectedDisc, - moveGizmoAxisDirection, - moveGizmoPointId, - renderCesiumCoreVisuals, - ]); -}; - -export default useCesiumPointVisualizer; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolygonAreaPrimitives.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolygonAreaPrimitives.ts deleted file mode 100644 index e4f83ce7d9..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolygonAreaPrimitives.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, useMemo, useRef } from "react"; - -import { - Cartesian3, - ClassificationType, - Color, - ColorGeometryInstanceAttribute, - GeometryInstance, - GroundPrimitive, - PerInstanceColorAppearance, - PolygonGeometry, - PolygonHierarchy, - Primitive, - PrimitiveCollection, -} from "@carma/cesium"; - -import { - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, - type PlanarPolygonGroup, -} from "../types/AnnotationTypes"; -import { createAnchoredCoplanarPolygonGeometry } from "../utils/createAnchoredCoplanarPolygonGeometry"; -import { type CesiumPolygonAreaPrimitivesOptions } from "./areaVisualizer.types"; - -const POLYGON_FILL_ALPHA = 0.25; -const POLYGON_FILL_SELECTED_ALPHA = 0.35; -type PlanarAreaMeasurementType = - | typeof ANNOTATION_TYPE_AREA_GROUND - | typeof ANNOTATION_TYPE_AREA_PLANAR - | typeof ANNOTATION_TYPE_AREA_VERTICAL; - -const POLYGON_FILL_RGB_BY_MEASUREMENT_TYPE: Readonly< - Record -> = { - [ANNOTATION_TYPE_AREA_VERTICAL]: [0.44, 0.66, 1.0], - [ANNOTATION_TYPE_AREA_GROUND]: [0.42, 0.74, 0.48], - [ANNOTATION_TYPE_AREA_PLANAR]: [0.94, 0.87, 0.57], -}; - -const getPolygonFillCesiumColor = ( - group: Pick, - isSelected: boolean -): Color => { - const alpha = isSelected ? POLYGON_FILL_SELECTED_ALPHA : POLYGON_FILL_ALPHA; - const resolvedMeasurementType = group.measurementKind; - const [red, green, blue] = - POLYGON_FILL_RGB_BY_MEASUREMENT_TYPE[resolvedMeasurementType]; - return new Color(red, green, blue, alpha); -}; - -export const useCesiumPolygonAreaPrimitives = ({ - scene, - polygonPreviewGroups, - focusedPolygonGroupId, -}: CesiumPolygonAreaPrimitivesOptions) => { - const primitiveCollectionRef = useRef(null); - const groundPrimitivesRef = useRef([]); - const relevantGroups = useMemo( - () => polygonPreviewGroups, - [polygonPreviewGroups] - ); - - useEffect(() => { - if (!scene || scene.isDestroyed()) return; - - if (primitiveCollectionRef.current) { - scene.primitives.remove(primitiveCollectionRef.current); - primitiveCollectionRef.current = null; - } - groundPrimitivesRef.current.forEach((groundPrimitive) => { - scene.groundPrimitives.remove(groundPrimitive); - }); - groundPrimitivesRef.current = []; - - if (relevantGroups.length === 0) return; - - const collection = new PrimitiveCollection(); - let hasCoplanarPolygonPrimitive = false; - const nextGroundPrimitives: GroundPrimitive[] = []; - - for (const { group, vertexPoints } of relevantGroups) { - if (vertexPoints.length < 3) continue; - - const geometryVertexPoints = vertexPoints.map((point) => - Cartesian3.clone(point) - ); - const isSelected = group.id === focusedPolygonGroupId; - const fillColor = getPolygonFillCesiumColor(group, isSelected); - const isGroundSurface = - group.measurementKind === ANNOTATION_TYPE_AREA_GROUND; - - if (isGroundSurface) { - const groundGeometry = new PolygonGeometry({ - polygonHierarchy: new PolygonHierarchy(geometryVertexPoints), - vertexFormat: PerInstanceColorAppearance.VERTEX_FORMAT, - }); - - const groundInstance = new GeometryInstance({ - geometry: groundGeometry, - id: { polygonGroupId: group.id }, - attributes: { - color: ColorGeometryInstanceAttribute.fromColor(fillColor), - }, - }); - - const groundPrimitive = new GroundPrimitive({ - geometryInstances: [groundInstance], - appearance: new PerInstanceColorAppearance({ - flat: true, - translucent: true, - }), - asynchronous: false, - releaseGeometryInstances: false, - classificationType: ClassificationType.BOTH, - }); - - scene.groundPrimitives.add(groundPrimitive); - nextGroundPrimitives.push(groundPrimitive); - continue; - } - - const anchoredCoplanarGeometry = - createAnchoredCoplanarPolygonGeometry(geometryVertexPoints); - if (!anchoredCoplanarGeometry) continue; - - const instance = new GeometryInstance({ - geometry: anchoredCoplanarGeometry.geometry, - id: { polygonGroupId: group.id }, - attributes: { - color: ColorGeometryInstanceAttribute.fromColor(fillColor), - }, - }); - - collection.add( - new Primitive({ - geometryInstances: [instance], - modelMatrix: anchoredCoplanarGeometry.modelMatrix, - appearance: new PerInstanceColorAppearance({ - flat: true, - translucent: true, - }), - asynchronous: false, - }) - ); - hasCoplanarPolygonPrimitive = true; - } - - groundPrimitivesRef.current = nextGroundPrimitives; - - if (hasCoplanarPolygonPrimitive) { - primitiveCollectionRef.current = collection; - scene.primitives.add(collection); - } - - scene.requestRender(); - - return () => { - if (primitiveCollectionRef.current && !scene.isDestroyed()) { - scene.primitives.remove(primitiveCollectionRef.current); - primitiveCollectionRef.current = null; - } - groundPrimitivesRef.current.forEach((groundPrimitive) => { - scene.groundPrimitives.remove(groundPrimitive); - }); - groundPrimitivesRef.current = []; - scene.requestRender(); - }; - }, [focusedPolygonGroupId, relevantGroups, scene]); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolylineVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolylineVisualizer.ts deleted file mode 100644 index 4f58d0b666..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPolylineVisualizer.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { createElement, useEffect, useMemo, useRef } from "react"; - -import { Color, SceneTransforms, type Scene, defined } from "@carma/cesium"; -import { - createLineVisualizer, - type LineVisualizer, -} from "@carma-mapping/engines/cesium/legacy"; -import { - buildPolylinePreviewCornerMarkers, - buildPolylinePreviewEdgeSegments, - POLYGON_PREVIEW_STROKE, - POLYGON_PREVIEW_STROKE_WIDTH_PX, - type FacadePreviewEdgeSegment, - type PolylinePreviewMeasurement, -} from "@carma-mapping/annotations/core"; -import { - useLabelOverlay, - useLineVisualizers, -} from "@carma-providers/label-overlay"; -import type { CssPixelPosition } from "@carma/units/types"; - -const FACADE_CORNER_OVERLAY_ID_PREFIX = "distance-facade-corner"; -const FACADE_CORNER_MARKER_SIZE_PX = 10; -const FACADE_CORNER_MARKER_STROKE_WIDTH_PX = 1; - -const destroyLineVisualizerMap = (lineRefs: { - current: Record; -}) => { - Object.values(lineRefs.current).forEach((lineVisualizer) => { - lineVisualizer.destroy(); - }); - lineRefs.current = {}; -}; - -export type CesiumPolylineVisualizerOptions = { - scene: Scene | null; - polylineMeasurements: PolylinePreviewMeasurement[]; -}; - -export const useCesiumPolylineVisualizer = ({ - scene, - polylineMeasurements, -}: CesiumPolylineVisualizerOptions): { - facadePreviewEdgeSegments: FacadePreviewEdgeSegment[]; -} => { - const facadePreviewEdgeLineRefs = useRef>({}); - const facadeCornerOverlayIdsRef = useRef([]); - const { addLabelOverlayElement, removeLabelOverlayElement } = - useLabelOverlay(); - - const facadePreviewEdgeSegments = useMemo( - () => buildPolylinePreviewEdgeSegments(polylineMeasurements), - [polylineMeasurements] - ); - - const facadeCornerMarkers = useMemo( - () => buildPolylinePreviewCornerMarkers(polylineMeasurements), - [polylineMeasurements] - ); - const facadePreviewOverlayLines = useMemo( - () => - facadePreviewEdgeSegments.map((segment) => ({ - id: `polygon-preview-edge-${segment.id}`, - getCanvasLine: () => { - if (!scene || scene.isDestroyed()) return null; - const start = SceneTransforms.worldToWindowCoordinates( - scene, - segment.start - ); - const end = SceneTransforms.worldToWindowCoordinates( - scene, - segment.end - ); - if (!defined(start) || !defined(end)) return null; - return { - start: { x: start.x, y: start.y } as CssPixelPosition, - end: { x: end.x, y: end.y } as CssPixelPosition, - }; - }, - stroke: POLYGON_PREVIEW_STROKE, - strokeWidth: POLYGON_PREVIEW_STROKE_WIDTH_PX, - hitTargetStrokeWidth: 10, - })), - [facadePreviewEdgeSegments, scene] - ); - - useLineVisualizers( - facadePreviewOverlayLines, - facadePreviewOverlayLines.length > 0 - ); - - const facadeCornerMarkerContent = useMemo( - () => - createElement("div", { - style: { - width: `${FACADE_CORNER_MARKER_SIZE_PX}px`, - height: `${FACADE_CORNER_MARKER_SIZE_PX}px`, - borderRadius: "50%", - border: `${FACADE_CORNER_MARKER_STROKE_WIDTH_PX}px solid rgba(255, 255, 255, 0.95)`, - background: "transparent", - boxSizing: "border-box", - pointerEvents: "none", - }, - }), - [] - ); - - useEffect(() => { - facadeCornerOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - facadeCornerOverlayIdsRef.current = []; - - if (!scene || scene.isDestroyed()) { - return; - } - - const nextOverlayIds: string[] = []; - facadeCornerMarkers.forEach((marker) => { - const overlayId = `${FACADE_CORNER_OVERLAY_ID_PREFIX}-${marker.id}`; - addLabelOverlayElement({ - id: overlayId, - zIndex: 9, - content: facadeCornerMarkerContent, - updatePosition: (elementDiv) => { - if (!scene || scene.isDestroyed()) return false; - const screenPosition = SceneTransforms.worldToWindowCoordinates( - scene, - marker.position - ); - if (!defined(screenPosition)) return false; - elementDiv.style.position = "absolute"; - elementDiv.style.left = `${screenPosition.x}px`; - elementDiv.style.top = `${screenPosition.y}px`; - elementDiv.style.transform = "translate(-50%, -50%)"; - elementDiv.style.pointerEvents = "none"; - return true; - }, - }); - nextOverlayIds.push(overlayId); - }); - - facadeCornerOverlayIdsRef.current = nextOverlayIds; - - return () => { - nextOverlayIds.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - facadeCornerOverlayIdsRef.current = []; - }; - }, [ - addLabelOverlayElement, - facadeCornerMarkerContent, - facadeCornerMarkers, - removeLabelOverlayElement, - scene, - ]); - - useEffect(() => { - if (!scene) return; - - destroyLineVisualizerMap(facadePreviewEdgeLineRefs); - - if (facadePreviewEdgeSegments.length === 0) { - scene.requestRender(); - return; - } - - const facadeEdgeColor = Color.WHITE; - facadePreviewEdgeSegments.forEach((segment) => { - const lineVisualizer = createLineVisualizer( - `polygon-preview-edge-${segment.id}`, - { - start: segment.start, - end: segment.end, - color: facadeEdgeColor, - width: POLYGON_PREVIEW_STROKE_WIDTH_PX, - dashed: false, - } - ); - facadePreviewEdgeLineRefs.current[segment.id] = lineVisualizer; - lineVisualizer.attach(scene, () => scene.requestRender()); - }); - scene.requestRender(); - - return () => { - destroyLineVisualizerMap(facadePreviewEdgeLineRefs); - if (!scene || scene.isDestroyed()) return; - scene.requestRender(); - }; - }, [facadePreviewEdgeSegments, scene]); - - useEffect(() => { - return () => { - facadeCornerOverlayIdsRef.current.forEach((overlayId) => { - removeLabelOverlayElement(overlayId); - }); - facadeCornerOverlayIdsRef.current = []; - destroyLineVisualizerMap(facadePreviewEdgeLineRefs); - }; - }, [removeLabelOverlayElement]); - - return { - facadePreviewEdgeSegments, - }; -}; - -export default useCesiumPolylineVisualizer; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumVerticalAreaVisualizer.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumVerticalAreaVisualizer.ts deleted file mode 100644 index f01f4f679c..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumVerticalAreaVisualizer.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { useVerticalAreaLabelVisualizer } from "@carma-mapping/annotations/core"; - -import { type VerticalAreaVisualizerOptions } from "./areaVisualizer.types"; -import { useCesiumPolygonAreaPrimitives } from "./useCesiumPolygonAreaPrimitives"; -import { useCesiumAreaLabelViewProjector } from "./utils/useCesiumAreaLabelViewProjector"; - -export const useCesiumVerticalAreaVisualizer = ( - options: VerticalAreaVisualizerOptions -) => { - const { - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - verticalPolygonPreviewGroups, - } = options; - const viewProjector = useCesiumAreaLabelViewProjector(scene); - - useVerticalAreaLabelVisualizer({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - verticalPolygonPreviewGroups, - }); - - useCesiumPolygonAreaPrimitives({ - scene, - focusedPolygonGroupId, - polygonPreviewGroups: verticalPolygonPreviewGroups, - }); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/pointSurfaceMath.ts b/libraries/mapping/annotations/cesium/src/lib/hooks/utils/pointSurfaceMath.ts deleted file mode 100644 index b64cc27368..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/pointSurfaceMath.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - CarmaTransforms, - Cartesian3, - Matrix4, - Transforms, -} from "@carma/cesium"; - -export const POINTER_NORMAL_EPSILON_SQUARED = 1e-8; - -const LOCAL_UP_ENU_FRAME_SCRATCH = new Matrix4(); - -export const getLocalUpDirectionECEF = ( - positionECEF: Cartesian3, - result?: Cartesian3 -): Cartesian3 => { - const localEnuFrame = Transforms.eastNorthUpToFixedFrame( - positionECEF, - undefined, - LOCAL_UP_ENU_FRAME_SCRATCH - ); - const upDirection = CarmaTransforms.matrix4ColumnToCartesian3( - localEnuFrame, - 2 - ); - const target = result ?? new Cartesian3(); - - if ( - Cartesian3.magnitudeSquared(upDirection) <= POINTER_NORMAL_EPSILON_SQUARED - ) { - return Cartesian3.normalize(positionECEF, target); - } - - return Cartesian3.normalize(upDirection, target); -}; diff --git a/libraries/mapping/annotations/cesium/src/lib/index.ts b/libraries/mapping/annotations/cesium/src/lib/index.ts index bb37394577..3ecea68360 100644 --- a/libraries/mapping/annotations/cesium/src/lib/index.ts +++ b/libraries/mapping/annotations/cesium/src/lib/index.ts @@ -1,20 +1,2 @@ -// Types -export * from "./types/AnnotationTypes"; - -// Utils -export * from "./utils/occlusionDetection"; -export * from "./utils/sceneVisibilityIndex"; -export { - upsertCollectionEntry, - replaceLastEntryOfType, - clearTemporaryEntries, - makeTemporaryEntriesPermanent, - buildScreenRectangle, - getScreenRectangleSize, - isPointInsideScreenRectangle, - selectPointIdsInScreenRectangle, - type ScreenRectangle, -} from "@carma-mapping/annotations/core"; - // Hooks export * from "./hooks"; diff --git a/libraries/mapping/annotations/cesium/src/lib/types/AnnotationTypes.ts b/libraries/mapping/annotations/cesium/src/lib/types/AnnotationTypes.ts deleted file mode 100644 index 5f21c99568..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/types/AnnotationTypes.ts +++ /dev/null @@ -1,41 +0,0 @@ -export { - SELECT_TOOL_TYPE, - ANNOTATION_TYPE_POINT, - ANNOTATION_TYPE_DISTANCE, - ANNOTATION_TYPE_POLYLINE, - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, - ANNOTATION_TYPE_LABEL, - LINEAR_SEGMENT_LINE_MODES, - LINEAR_SEGMENT_LINE_MODE_DIRECT, - LINEAR_SEGMENT_LINE_MODE_COMPONENTS, - DEFAULT_LINEAR_SEGMENT_LINE_MODE, - DEFAULT_POINT_LABEL_METRIC_MODE, - PLANAR_POLYGON_SOURCE_KINDS, - isPointAnnotationEntry, -} from "@carma-mapping/annotations/core"; - -export type { - AnnotationMode, - DirectLineLabelMode, - DistanceRelationLabelVisibilityByKind, - LinearSegmentLineMode, - AnnotationGeometryEdge, - AnnotationGeometryPoint, - AnnotationLabelAnchor, - AnnotationLabelAppearance, - PlanarPolygonSourceKind, - PlanarPolygonGroup, - PlanarPolygonGroupVertex, - PlanarPolygonLocalFrame, - PlanarPolygonPlane, - PointDistanceRelation, - PointLabelMetricMode, - PointReferenceLineAnnotation, - ReferenceLineLabelKind, - AnnotationEntry, - PointAnnotationEntry, - AnnotationCollection, - AnnotationPersistenceEnvelopeV2, -} from "@carma-mapping/annotations/core"; diff --git a/libraries/mapping/annotations/cesium/src/lib/utils/createAnchoredCoplanarPolygonGeometry.ts b/libraries/mapping/annotations/cesium/src/lib/utils/createAnchoredCoplanarPolygonGeometry.ts deleted file mode 100644 index 91e59d6985..0000000000 --- a/libraries/mapping/annotations/cesium/src/lib/utils/createAnchoredCoplanarPolygonGeometry.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { - Cartesian3, - CoplanarPolygonGeometry, - Matrix4, - PerInstanceColorAppearance, -} from "@carma/cesium"; - -export type AnchoredCoplanarPolygonGeometry = { - geometry: CoplanarPolygonGeometry; - modelMatrix: Matrix4; -}; - -export const createAnchoredCoplanarPolygonGeometry = ( - positions: Cartesian3[] -): AnchoredCoplanarPolygonGeometry | null => { - const anchor = positions[0]; - if (!anchor || positions.length < 3) return null; - - const localPositions = positions.map((position) => - Cartesian3.subtract(position, anchor, new Cartesian3()) - ); - const geometry = CoplanarPolygonGeometry.fromPositions({ - positions: localPositions, - vertexFormat: PerInstanceColorAppearance.VERTEX_FORMAT, - }); - if (!geometry) return null; - - return { - geometry, - modelMatrix: Matrix4.fromTranslation(anchor, new Matrix4()), - }; -}; diff --git a/libraries/mapping/annotations/core/src/index.ts b/libraries/mapping/annotations/core/src/index.ts index 4f25f25513..a53eec976e 100644 --- a/libraries/mapping/annotations/core/src/index.ts +++ b/libraries/mapping/annotations/core/src/index.ts @@ -1,53 +1,47 @@ -export * from "./lib/distanceOverlayDom"; export * from "./lib/distanceScreenSpace"; export * from "./lib/utils/distanceVisualization"; -export * from "./lib/useDistancePairLabelOverlays"; -export * from "./lib/preview/annotationPreviewVisuals"; -export * from "./lib/visualizers/area-labels"; -export * from "./lib/visualizers/distance/distanceRelationLabel.types"; +export * from "./lib/visualization"; export { EDITABLE_LINE_MEASUREMENT_KINDS, getSplitMarkerRelationIdsByKind, getSplitMarkerRelationIds, getSplitMarkerRelationIdsForGroups, getSplitMarkerRelationIdsByKindForGroups, - getRoofSharedEdgeRelationIds, - type PlanarPolygonGroupLike, + getPlanarSharedEdgeRelationIds, + type PolygonAnnotationLike, type EditableLineMeasurementKind, type EditableLineRelationIdsByKind, } from "./lib/editableLinePolicies"; -export * from "./lib/context/useAnnotationCoreState"; -export * from "./lib/context/AnnotationContextsProvider"; -export * from "./lib/context/AnnotationsContext"; -export * from "./lib/context/annotationModeOptions.types"; -export * from "./lib/context/AnnotationSelectionContext"; -export * from "./lib/context/AnnotationModeOptionsContext"; -export * from "./lib/context/AnnotationVisibilityContext"; -export * from "./lib/context/AnnotationEditContext"; -export * from "./lib/context/hooks/useAnnotationSelectionMutations"; -export * from "./lib/context/hooks/useAnnotationEntryMutations"; -export * from "./lib/context/hooks/useAnnotationVisibilityState"; -export * from "./lib/context/hooks/useAnnotationEditState"; -export * from "./lib/context/hooks/useAnnotationCollectionSelectors"; -export * from "./lib/context/hooks/useAnnotationPointMarkerBadges"; -export * from "./lib/hooks/useAnnotationPointCreation"; export * from "./lib/utils/alphabeticSequence"; export * from "./lib/utils/orderById"; export * from "./lib/utils/annotationNaming"; export * from "./lib/utils/displayFormatting"; export * from "./lib/utils/annotationLabel"; +export * from "./lib/utils/annotationStateEquality"; +export * from "./lib/utils/annotationCollection"; +export * from "./lib/utils/planarMeasurementGroups"; +export * from "./lib/utils/planarGeometry"; +export * from "./lib/utils/derivedPolylinePaths"; +export * from "./lib/utils/distanceRelationDisplay"; +export * from "./lib/utils/measurementRelations"; export * from "./lib/utils/pointGeometryPersistence"; +export * from "./lib/utils/selectionGroupMove"; export * from "./lib/utils/temporaryCollection"; export * from "./lib/utils/annotationPersistence"; export * from "./lib/utils/screenRectangle"; +export * from "./lib/utils/screenViewport"; +export * from "./lib/utils/candidateCapabilities"; +export * from "./lib/utils/candidateRingNormalSmoothing"; export * from "./lib/types/annotationEntry"; export * from "./lib/types/annotationLabel"; export * from "./lib/types/annotationPersistenceTypes"; export * from "./lib/types/distanceRelation"; +export * from "./lib/types/lineType"; export * from "./lib/types/linearSegment"; -export * from "./lib/types/planarTypes"; export * from "./lib/types/annotationTypes"; +export * from "./lib/types/annotationTypes"; +export * from "./lib/types/annotationCandidate"; export * from "./lib/types/annotationCesiumTypes"; +export * from "./lib/types/derivedPolylinePath"; export * from "./lib/types/distanceRelationRenderContext"; export * from "./lib/tools/annotationToolManager"; -export * from "./lib/tools/useSelectionToolState"; diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationContextsProvider.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationContextsProvider.tsx deleted file mode 100644 index 348e385ada..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationContextsProvider.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import type { ReactNode } from "react"; -import { - AnnotationEditContext, - type AnnotationEditContextType, -} from "./AnnotationEditContext"; -import { - AnnotationModeOptionsContext, - type AnnotationModeOptionsContextType, -} from "./AnnotationModeOptionsContext"; -import { - AnnotationSelectionContext, - type AnnotationSelectionContextType, -} from "./AnnotationSelectionContext"; -import { - AnnotationVisibilityContext, - type AnnotationVisibilityContextType, -} from "./AnnotationVisibilityContext"; -import { - AnnotationsContext, - type AnnotationsContextType, -} from "./AnnotationsContext"; -import type { BaseAnnotationEntry } from "../types/annotationEntry"; - -type AnnotationContextsProviderProps< - TMode extends string, - TAnnotation extends BaseAnnotationEntry -> = { - annotationsValue: AnnotationsContextType; - selectionValue: AnnotationSelectionContextType; - modeOptionsValue: AnnotationModeOptionsContextType; - visibilityValue: AnnotationVisibilityContextType; - editValue: AnnotationEditContextType; - children: ReactNode; -}; - -export const AnnotationContextsProvider = < - TMode extends string, - TAnnotation extends BaseAnnotationEntry ->({ - annotationsValue, - selectionValue, - modeOptionsValue, - visibilityValue, - editValue, - children, -}: AnnotationContextsProviderProps) => ( - - - - - - {children} - - - - - -); diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationEditContext.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationEditContext.tsx deleted file mode 100644 index 51d371a281..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationEditContext.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { createContext, useContext } from "react"; - -export type AnnotationEditContextType = { - lockedEditMeasurementId: string | null; - clearLockedEditMeasurementId: () => void; -}; - -export const AnnotationEditContext = createContext< - AnnotationEditContextType | undefined ->(undefined); - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotationEdit = (): AnnotationEditContextType => { - const context = useContext(AnnotationEditContext); - if (!context) { - throw new Error( - "useAnnotationEdit must be used within a AnnotationEditContext.Provider" - ); - } - return context; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationModeOptionsContext.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationModeOptionsContext.tsx deleted file mode 100644 index dc7d84986e..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationModeOptionsContext.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { - createContext, - useContext, - type Dispatch, - type SetStateAction, -} from "react"; -import type { - PlanarToolCreationMode, - PolygonSurfacePreset, -} from "./annotationModeOptions.types"; -import type { LinearSegmentLineMode } from "../types/linearSegment"; -import type { PlanarPolygonGroup } from "../types/planarTypes"; - -export type AnnotationModeOptionsContextType = { - planarPolygonGroups: PlanarPolygonGroup[]; - polylineGroups: PlanarPolygonGroup[]; - areaPolygonGroups: PlanarPolygonGroup[]; - planarSurfacePolygonGroups: PlanarPolygonGroup[]; - verticalPolygonGroups: PlanarPolygonGroup[]; - distanceModeStickyToFirstPoint: boolean; - setDistanceModeStickyToFirstPoint: Dispatch>; - distanceCreationLineVisibility: { - direct: boolean; - vertical: boolean; - horizontal: boolean; - }; - setDistanceCreationLineVisibilityByKind: ( - kind: "direct" | "vertical" | "horizontal", - visible: boolean - ) => void; - polylineVerticalOffsetMeters: number; - setPolylineVerticalOffsetMeters: Dispatch>; - polylineSegmentLineMode: LinearSegmentLineMode; - setPolylineSegmentLineMode: Dispatch>; - planarToolCreationMode: PlanarToolCreationMode; - setPlanarToolCreationMode: Dispatch>; - polygonSurfaceTypePreset: PolygonSurfacePreset; - setPolygonSurfaceTypePreset: Dispatch>; -}; - -export const AnnotationModeOptionsContext = createContext< - AnnotationModeOptionsContextType | undefined ->(undefined); - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotationModeOptions = - (): AnnotationModeOptionsContextType => { - const context = useContext(AnnotationModeOptionsContext); - if (!context) { - throw new Error( - "useAnnotationModeOptions must be used within a AnnotationModeOptionsContext.Provider" - ); - } - return context; - }; diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationSelectionContext.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationSelectionContext.tsx deleted file mode 100644 index 0078601e2f..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationSelectionContext.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { - createContext, - useContext, - type Dispatch, - type SetStateAction, -} from "react"; - -export type AnnotationSelectionContextType = { - selectedMeasurementId: string | null; - selectedMeasurementIds: string[]; - selectMeasurementById: (id: string | null) => void; - selectMeasurementIds: (ids: string[], additive?: boolean) => void; - selectionModeActive: boolean; - setSelectionModeActive: Dispatch>; - selectModeAdditive: boolean; - setSelectModeAdditive: Dispatch>; - selectModeRectangle: boolean; - setSelectModeRectangle: Dispatch>; - effectiveSelectModeAdditive: boolean; -}; - -export const AnnotationSelectionContext = createContext< - AnnotationSelectionContextType | undefined ->(undefined); - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotationSelection = (): AnnotationSelectionContextType => { - const context = useContext(AnnotationSelectionContext); - if (!context) { - throw new Error( - "useAnnotationSelection must be used within a AnnotationSelectionContext.Provider" - ); - } - return context; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationVisibilityContext.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationVisibilityContext.tsx deleted file mode 100644 index 3eb4a0a9f6..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationVisibilityContext.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { - createContext, - useContext, - type Dispatch, - type SetStateAction, -} from "react"; - -export type AnnotationVisibilityContextType = { - hideMeasurementsOfType: Set; - setHideMeasurementsOfType: Dispatch>>; - hideLabelsOfType: Set; - setHideLabelsOfType: Dispatch>>; -}; - -export const AnnotationVisibilityContext = createContext< - AnnotationVisibilityContextType | undefined ->(undefined); - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotationVisibility = < - TMode extends string = string ->(): AnnotationVisibilityContextType => { - const context = useContext(AnnotationVisibilityContext); - if (!context) { - throw new Error( - "useAnnotationVisibility must be used within a AnnotationVisibilityContext.Provider" - ); - } - return context as unknown as AnnotationVisibilityContextType; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/AnnotationsContext.tsx b/libraries/mapping/annotations/core/src/lib/context/AnnotationsContext.tsx deleted file mode 100644 index c86e057d2d..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/AnnotationsContext.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { - createContext, - useContext, - type Dispatch, - type SetStateAction, -} from "react"; -import type { BaseAnnotationEntry } from "../types/annotationEntry"; -import type { AnnotationLabelAppearance } from "../types/annotationLabel"; - -export type AnnotationListType = - | TMode - | "pointMeasure" - | "distanceMeasure" - | "pointLabel"; - -export type AnnotationCreatePayload< - TMeasurement extends BaseAnnotationEntry = BaseAnnotationEntry -> = Omit & { - id?: string; - timestamp?: number; -}; - -export type AnnotationsContextType< - TMode extends string = string, - TMeasurement extends BaseAnnotationEntry = BaseAnnotationEntry -> = { - annotationMode: TMode; - setAnnotationMode: Dispatch>; - annotations: TMeasurement[]; - liveAnnotationCandidate: TMeasurement | null; - annotationsByType: (type: AnnotationListType) => TMeasurement[]; - getAnnotationsForNavigation: () => TMeasurement[]; - getAnnotationIndexByType: ( - type: AnnotationListType, - id: string | null | undefined - ) => number; - getAnnotationOrderByType: ( - type: AnnotationListType, - id: string | null | undefined - ) => number | null; - getNextAnnotationOrderByType: (type: AnnotationListType) => number; - addAnnotation: (payload: AnnotationCreatePayload) => string; - updateAnnotationById: (id: string, patch: Partial) => void; - deleteAnnotationById: (id: string) => void; - deleteAnnotationsByIds: (ids: string[]) => void; - setAnnotations: Dispatch>; - updateAnnotationNameById: (id: string, name: string) => void; - updatePointLabelAppearanceById: ( - id: string, - appearance: AnnotationLabelAppearance | undefined - ) => void; - toggleAnnotationLockById: (id: string) => void; - clearAnnotationsByIds: (ids: string[]) => void; - deleteSelectedPointAnnotations: () => void; - setPointAnnotationElevationById: ( - id: string, - elevationMeters: number - ) => void; - setPointAnnotationCoordinatesById: ( - id: string, - latitude: number, - longitude: number, - elevationMeters?: number - ) => void; - temporaryMode: boolean; - setTemporaryMode: Dispatch>; - pointVerticalOffsetMeters: number; - setPointVerticalOffsetMeters: Dispatch>; - pointLabelOnCreate: boolean; - setPointLabelOnCreate: Dispatch>; - labelInputPromptPointId: string | null; - confirmPointLabelInputById: (id: string) => void; - showLabels: boolean; - setShowLabels: Dispatch>; -}; - -export const AnnotationsContext = createContext< - AnnotationsContextType | undefined ->(undefined); - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotations = < - TMode extends string = string, - TMeasurement extends BaseAnnotationEntry = BaseAnnotationEntry ->(): AnnotationsContextType => { - const context = useContext(AnnotationsContext); - if (!context) { - throw new Error( - "useAnnotations must be used within an AnnotationsContext.Provider" - ); - } - return context as AnnotationsContextType; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/annotationModeOptions.types.ts b/libraries/mapping/annotations/core/src/lib/context/annotationModeOptions.types.ts deleted file mode 100644 index 5b0cf1a475..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/annotationModeOptions.types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export const PLANAR_TOOL_CREATION_MODE_POLYLINE = "polyline" as const; -export const PLANAR_TOOL_CREATION_MODE_POLYGON = "polygon" as const; - -const PLANAR_TOOL_CREATION_MODES = [ - PLANAR_TOOL_CREATION_MODE_POLYLINE, - PLANAR_TOOL_CREATION_MODE_POLYGON, -] as const; - -export type PlanarToolCreationMode = - (typeof PLANAR_TOOL_CREATION_MODES)[number]; - -export type PolygonSurfacePreset = "roof" | "facade" | "terrain" | "footprint"; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEditState.ts b/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEditState.ts deleted file mode 100644 index 39b7e4234c..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEditState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useCallback, useState } from "react"; - -export const useAnnotationEditState = () => { - const [lockedEditMeasurementId, setLockedEditMeasurementId] = useState< - string | null - >(null); - - const clearLockedEditMeasurementId = useCallback(() => { - setLockedEditMeasurementId(null); - }, []); - - return { - lockedEditMeasurementId, - setLockedEditMeasurementId, - clearLockedEditMeasurementId, - }; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationSelectionMutations.ts b/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationSelectionMutations.ts deleted file mode 100644 index 235a4c6bbe..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationSelectionMutations.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - useCallback, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; - -type UseMeasurementSelectionMutationsParams = { - selectableMeasurementIds: ReadonlySet; - selectedMeasurementIdRef: MutableRefObject; - setSelectedMeasurementId: Dispatch>; - setSelectedMeasurementIds: Dispatch>; - onPrimarySelectionChange?: ( - nextPrimaryId: string | null, - previousPrimaryId: string | null - ) => void; - onSelectionIdsChange?: ( - nextIds: string[], - nextPrimaryId: string | null, - previousPrimaryId: string | null - ) => void; -}; - -const getUniqueIds = (ids: string[]) => Array.from(new Set(ids)); - -export const useAnnotationSelectionMutations = ({ - selectableMeasurementIds, - selectedMeasurementIdRef, - setSelectedMeasurementId, - setSelectedMeasurementIds, - onPrimarySelectionChange, - onSelectionIdsChange, -}: UseMeasurementSelectionMutationsParams) => { - const selectMeasurementIds = useCallback( - (ids: string[], additive: boolean = false) => { - const uniqueIncomingIds = getUniqueIds( - ids.filter((id) => selectableMeasurementIds.has(id)) - ); - - setSelectedMeasurementIds((prev) => { - const next = additive - ? getUniqueIds([...prev, ...uniqueIncomingIds]) - : uniqueIncomingIds; - const nextPrimaryId = next[next.length - 1] ?? null; - const previousPrimaryId = selectedMeasurementIdRef.current; - - selectedMeasurementIdRef.current = nextPrimaryId; - setSelectedMeasurementId((prevSelectedId) => - prevSelectedId === nextPrimaryId ? prevSelectedId : nextPrimaryId - ); - - if (previousPrimaryId !== nextPrimaryId) { - onPrimarySelectionChange?.(nextPrimaryId, previousPrimaryId); - } - onSelectionIdsChange?.(next, nextPrimaryId, previousPrimaryId); - return next; - }); - }, - [ - onPrimarySelectionChange, - onSelectionIdsChange, - selectableMeasurementIds, - selectedMeasurementIdRef, - setSelectedMeasurementId, - setSelectedMeasurementIds, - ] - ); - - const selectMeasurementById = useCallback( - (id: string | null) => { - if (id !== null && !selectableMeasurementIds.has(id)) { - return; - } - - const previousPrimaryId = selectedMeasurementIdRef.current; - selectedMeasurementIdRef.current = id; - - setSelectedMeasurementId((prev) => (prev === id ? prev : id)); - setSelectedMeasurementIds((prev) => { - const next = - id === null ? [] : prev.length === 1 && prev[0] === id ? prev : [id]; - onSelectionIdsChange?.(next, id, previousPrimaryId); - return next; - }); - - if (previousPrimaryId !== id) { - onPrimarySelectionChange?.(id, previousPrimaryId); - } - }, - [ - onPrimarySelectionChange, - onSelectionIdsChange, - selectableMeasurementIds, - selectedMeasurementIdRef, - setSelectedMeasurementId, - setSelectedMeasurementIds, - ] - ); - - return { - selectMeasurementIds, - selectMeasurementById, - }; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationVisibilityState.ts b/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationVisibilityState.ts deleted file mode 100644 index 758de703aa..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationVisibilityState.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { useState } from "react"; - -export const useAnnotationVisibilityState = () => { - const [hideMeasurementsOfType, setHideMeasurementsOfType] = useState< - Set - >(new Set()); - const [hideLabelsOfType, setHideLabelsOfType] = useState>( - new Set() - ); - - return { - hideMeasurementsOfType, - setHideMeasurementsOfType, - hideLabelsOfType, - setHideLabelsOfType, - }; -}; diff --git a/libraries/mapping/annotations/core/src/lib/context/useAnnotationCoreState.ts b/libraries/mapping/annotations/core/src/lib/context/useAnnotationCoreState.ts deleted file mode 100644 index ef0da9471b..0000000000 --- a/libraries/mapping/annotations/core/src/lib/context/useAnnotationCoreState.ts +++ /dev/null @@ -1,203 +0,0 @@ -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type Dispatch, - type MutableRefObject, - type SetStateAction, -} from "react"; - -export type AnnotationCoreEntry = { - id: string; - name?: string; - locked?: boolean; -}; - -export type UseAnnotationCoreStateParams< - TMode extends string, - TMeasurement extends AnnotationCoreEntry -> = { - initialMode: TMode; - initialMeasurements?: TMeasurement[]; - initialShowLabels?: boolean; - isSelectableMeasurementId?: ( - measurementId: string, - annotations: ReadonlyArray - ) => boolean; -}; - -export type AnnotationCoreState< - TMode extends string, - TMeasurement extends AnnotationCoreEntry -> = { - annotationMode: TMode; - setAnnotationMode: Dispatch>; - annotations: TMeasurement[]; - setAnnotations: Dispatch>; - selectedMeasurementId: string | null; - setSelectedMeasurementId: Dispatch>; - selectedMeasurementIds: string[]; - setSelectedMeasurementIds: Dispatch>; - selectedMeasurementIdRef: MutableRefObject; - selectMeasurementById: (id: string | null) => void; - selectMeasurementIds: (ids: string[], additive?: boolean) => void; - clearSelection: () => void; - updateAnnotationNameById: (id: string, name: string) => void; - toggleAnnotationLockById: (id: string) => void; - showLabels: boolean; - setShowLabels: Dispatch>; -}; - -const getUniqueIds = (ids: string[]) => Array.from(new Set(ids)); - -export const useAnnotationCoreState = < - TMode extends string, - TMeasurement extends AnnotationCoreEntry ->({ - initialMode, - initialMeasurements = [], - initialShowLabels = true, - isSelectableMeasurementId, -}: UseAnnotationCoreStateParams): AnnotationCoreState< - TMode, - TMeasurement -> => { - const [annotationMode, setAnnotationMode] = useState(initialMode); - const [annotations, setAnnotations] = - useState(initialMeasurements); - const [selectedMeasurementId, setSelectedMeasurementId] = useState< - string | null - >(null); - const [selectedMeasurementIds, setSelectedMeasurementIds] = useState< - string[] - >([]); - const [showLabels, setShowLabels] = useState(initialShowLabels); - const selectedMeasurementIdRef = useRef(selectedMeasurementId); - - useEffect(() => { - selectedMeasurementIdRef.current = selectedMeasurementId; - }, [selectedMeasurementId]); - - const measurementIdSet = useMemo( - () => new Set(annotations.map((measurement) => measurement.id)), - [annotations] - ); - - const isSelectableId = useCallback( - (id: string) => { - if (!measurementIdSet.has(id)) return false; - if (!isSelectableMeasurementId) return true; - return isSelectableMeasurementId(id, annotations); - }, - [isSelectableMeasurementId, measurementIdSet, annotations] - ); - - useEffect(() => { - setSelectedMeasurementIds((prev) => { - if (prev.length === 0) return prev; - const filtered = prev.filter((id) => measurementIdSet.has(id)); - if (filtered.length === prev.length) return prev; - return filtered; - }); - setSelectedMeasurementId((prev) => - prev && measurementIdSet.has(prev) ? prev : null - ); - }, [measurementIdSet]); - - const selectMeasurementById = useCallback( - (id: string | null) => { - if (id !== null && !isSelectableId(id)) { - return; - } - - selectedMeasurementIdRef.current = id; - setSelectedMeasurementId((prev) => (prev === id ? prev : id)); - setSelectedMeasurementIds((prev) => { - if (id === null) { - return prev.length === 0 ? prev : []; - } - if (prev.length === 1 && prev[0] === id) { - return prev; - } - return [id]; - }); - }, - [isSelectableId] - ); - - const selectMeasurementIds = useCallback( - (ids: string[], additive: boolean = false) => { - const validIds = getUniqueIds(ids.filter(isSelectableId)); - setSelectedMeasurementIds((prev) => { - const next = additive ? getUniqueIds([...prev, ...validIds]) : validIds; - const nextPrimary = next[next.length - 1] ?? null; - selectedMeasurementIdRef.current = nextPrimary; - setSelectedMeasurementId((prevSelected) => - prevSelected === nextPrimary ? prevSelected : nextPrimary - ); - return next; - }); - }, - [isSelectableId] - ); - - const clearSelection = useCallback(() => { - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId((prev) => (prev === null ? prev : null)); - setSelectedMeasurementIds((prev) => (prev.length === 0 ? prev : [])); - }, []); - - const updateAnnotationNameById = useCallback((id: string, name: string) => { - const trimmedName = name.trim(); - setAnnotations((prev) => { - let hasChanged = false; - const next = prev.map((measurement) => { - if (measurement.id !== id) return measurement; - const currentName = measurement.name ?? ""; - if (currentName === trimmedName) return measurement; - hasChanged = true; - return { - ...measurement, - name: trimmedName, - }; - }); - return hasChanged ? next : prev; - }); - }, []); - - const toggleAnnotationLockById = useCallback((id: string) => { - setAnnotations((prev) => { - let hasChanged = false; - const next = prev.map((measurement) => { - if (measurement.id !== id) return measurement; - hasChanged = true; - return { - ...measurement, - locked: !measurement.locked, - }; - }); - return hasChanged ? next : prev; - }); - }, []); - - return { - annotationMode, - setAnnotationMode, - annotations, - setAnnotations, - selectedMeasurementId, - setSelectedMeasurementId, - selectedMeasurementIds, - setSelectedMeasurementIds, - selectedMeasurementIdRef, - selectMeasurementById, - selectMeasurementIds, - clearSelection, - updateAnnotationNameById, - toggleAnnotationLockById, - showLabels, - setShowLabels, - }; -}; diff --git a/libraries/mapping/annotations/core/src/lib/distanceScreenSpace.ts b/libraries/mapping/annotations/core/src/lib/distanceScreenSpace.ts index 08dc4b2ca0..c6c215ea4a 100644 --- a/libraries/mapping/annotations/core/src/lib/distanceScreenSpace.ts +++ b/libraries/mapping/annotations/core/src/lib/distanceScreenSpace.ts @@ -1,3 +1,4 @@ +import { clamp } from "@carma-commons/math"; import type { CssPixelPosition } from "@carma/units/types"; export type PointDistanceRelationLike = { @@ -6,9 +7,6 @@ export type PointDistanceRelationLike = { showComponentLines?: boolean; }; -const clamp = (value: number, min: number, max: number) => - Math.max(min, Math.min(max, value)); - export const isDistanceRelationVerticalLineVisible = ( relation: PointDistanceRelationLike ) => relation.showVerticalLine ?? relation.showComponentLines ?? false; @@ -26,6 +24,181 @@ export const hasVisibleDistanceRelationComponentLines = ( export const normalizeLabelAngleDeg = (angleDeg: number) => angleDeg > 90 || angleDeg < -90 ? angleDeg + 180 : angleDeg; +export type DistanceScreenTriangle = { + anchor: CssPixelPosition; + target: CssPixelPosition; + aux: CssPixelPosition; + centroid: CssPixelPosition; + highest: CssPixelPosition; +}; + +export type VerticalDistanceLineScreenData = { + start: CssPixelPosition; + end: CssPixelPosition; + insideSign: -1 | 1; + midX: number; + midY: number; + normalX: number; + normalY: number; + lineLength: number; +}; + +const resolveStableSideSign = ( + signedDistance: number, + previousSign: -1 | 1 | undefined, + flipThresholdPx = 4 +): -1 | 1 => { + if (!Number.isFinite(signedDistance)) return previousSign ?? 1; + const nextSign: -1 | 1 = signedDistance >= 0 ? 1 : -1; + if (!previousSign || previousSign === nextSign) return nextSign; + if (Math.abs(signedDistance) < flipThresholdPx) return previousSign; + return nextSign; +}; + +export const buildOutsideReferencePoint2D = ( + start: CssPixelPosition, + end: CssPixelPosition, + insidePoint: CssPixelPosition, + minDistancePx = 24, + maxDistancePx = 48 +): CssPixelPosition | null => { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lineLength = Math.hypot(dx, dy); + if (lineLength <= 1e-3) return null; + const midX = (start.x + end.x) * 0.5; + const midY = (start.y + end.y) * 0.5; + const normalX = -dy / lineLength; + const normalY = dx / lineLength; + const dot = + (insidePoint.x - midX) * normalX + (insidePoint.y - midY) * normalY; + const insideSign = dot >= 0 ? 1 : -1; + const referenceDistancePx = clamp( + lineLength * 0.2, + minDistancePx, + maxDistancePx + ); + return { + x: midX + normalX * insideSign * referenceDistancePx, + y: midY + normalY * insideSign * referenceDistancePx, + } as CssPixelPosition; +}; + +export const buildDistanceTriangleInsidePoint2D = ({ + triangle, + auxiliaryAltitudeMeters, + highestAltitudeMeters, + insideBlendFactor = 0.35, + elevationEpsilonMeters = 0.001, +}: { + triangle: DistanceScreenTriangle; + auxiliaryAltitudeMeters: number; + highestAltitudeMeters: number; + insideBlendFactor?: number; + elevationEpsilonMeters?: number; +}): CssPixelPosition => { + const elevationDriverPoint = + auxiliaryAltitudeMeters < highestAltitudeMeters - elevationEpsilonMeters + ? triangle.highest + : triangle.aux; + return { + x: + elevationDriverPoint.x + + (triangle.centroid.x - elevationDriverPoint.x) * insideBlendFactor, + y: + elevationDriverPoint.y + + (triangle.centroid.y - elevationDriverPoint.y) * insideBlendFactor, + } as CssPixelPosition; +}; + +export const buildVerticalDistanceLineScreenData = ({ + triangle, + previousInsideSign, + flipThresholdPx = 4, +}: { + triangle: DistanceScreenTriangle; + previousInsideSign?: -1 | 1; + flipThresholdPx?: number; +}): VerticalDistanceLineScreenData | null => { + let start = triangle.anchor; + let end = triangle.aux; + const inside = triangle.target; + + const computeEdgeMetrics = ( + nextStart: CssPixelPosition, + nextEnd: CssPixelPosition + ): { + midX: number; + midY: number; + normalX: number; + normalY: number; + lineLength: number; + insideDot: number; + } | null => { + const dx = nextEnd.x - nextStart.x; + const dy = nextEnd.y - nextStart.y; + const lineLength = Math.hypot(dx, dy); + if (lineLength <= 1e-3) return null; + const midX = (nextStart.x + nextEnd.x) * 0.5; + const midY = (nextStart.y + nextEnd.y) * 0.5; + const normalX = -dy / lineLength; + const normalY = dx / lineLength; + const insideDot = (inside.x - midX) * normalX + (inside.y - midY) * normalY; + return { + midX, + midY, + normalX, + normalY, + lineLength, + insideDot, + }; + }; + + let edge = computeEdgeMetrics(start, end); + if (!edge) return null; + + const insideSign = resolveStableSideSign( + edge.insideDot, + previousInsideSign, + flipThresholdPx + ); + + if (insideSign < 0) { + start = triangle.aux; + end = triangle.anchor; + edge = computeEdgeMetrics(start, end); + if (!edge) return null; + } + + return { + start, + end, + insideSign, + midX: edge.midX, + midY: edge.midY, + normalX: edge.normalX, + normalY: edge.normalY, + lineLength: edge.lineLength, + }; +}; + +export const buildVerticalLabelReferencePoint2D = ( + edge: VerticalDistanceLineScreenData, + minDistancePx = 24, + maxDistancePx = 48 +): CssPixelPosition => { + const referenceDistancePx = clamp( + edge.lineLength * 0.2, + minDistancePx, + maxDistancePx + ); + + return { + x: edge.midX + edge.normalX * edge.insideSign * referenceDistancePx, + y: edge.midY + edge.normalY * edge.insideSign * referenceDistancePx, + } as CssPixelPosition; +}; + const isPointOnSegment2D = ( point: CssPixelPosition, start: CssPixelPosition, diff --git a/libraries/mapping/annotations/core/src/lib/editableLinePolicies.ts b/libraries/mapping/annotations/core/src/lib/editableLinePolicies.ts index 1aa44a810a..1bda039a1a 100644 --- a/libraries/mapping/annotations/core/src/lib/editableLinePolicies.ts +++ b/libraries/mapping/annotations/core/src/lib/editableLinePolicies.ts @@ -5,11 +5,11 @@ import { ANNOTATION_TYPE_POLYLINE, type AnnotationType, } from "./types/annotationTypes"; -import type { PlanarPolygonSourceKind } from "./types/planarTypes"; +import type { NodeChainAnnotationType } from "./types/annotationTypes"; -export type PlanarPolygonGroupLike = { +export type PolygonAnnotationLike = { id: string; - measurementKind: PlanarPolygonSourceKind; + type: NodeChainAnnotationType; edgeRelationIds: readonly string[]; }; @@ -40,30 +40,30 @@ const createEditableLineRelationIdsByKind = (): Record< }); const resolveEditableLineMeasurementKind = ( - group: Pick -): EditableLineMeasurementKind => group.measurementKind; + group: Pick +): EditableLineMeasurementKind => group.type; export const getSplitMarkerRelationIdsByKind = ( - planarPolygonGroups: readonly PlanarPolygonGroupLike[] + nodeChainAnnotations: readonly PolygonAnnotationLike[] ): EditableLineRelationIdsByKind => { const byKind = createEditableLineRelationIdsByKind(); - planarPolygonGroups.forEach((group) => { - const measurementKind = resolveEditableLineMeasurementKind(group); + nodeChainAnnotations.forEach((group) => { + const type = resolveEditableLineMeasurementKind(group); group.edgeRelationIds.forEach((edgeRelationId) => { if (!edgeRelationId) return; - byKind[measurementKind].add(edgeRelationId); + byKind[type].add(edgeRelationId); }); }); return byKind; }; export const getSplitMarkerRelationIds = ( - planarPolygonGroups: readonly PlanarPolygonGroupLike[] + nodeChainAnnotations: readonly PolygonAnnotationLike[] ): ReadonlySet => { - const byKind = getSplitMarkerRelationIdsByKind(planarPolygonGroups); + const byKind = getSplitMarkerRelationIdsByKind(nodeChainAnnotations); const allRelationIds = new Set(); - EDITABLE_LINE_MEASUREMENT_KINDS.forEach((measurementKind) => { - byKind[measurementKind].forEach((relationId) => { + EDITABLE_LINE_MEASUREMENT_KINDS.forEach((type) => { + byKind[type].forEach((relationId) => { allRelationIds.add(relationId); }); }); @@ -71,12 +71,12 @@ export const getSplitMarkerRelationIds = ( }; export const getSplitMarkerRelationIdsForGroups = ( - planarPolygonGroups: readonly PlanarPolygonGroupLike[], + nodeChainAnnotations: readonly PolygonAnnotationLike[], groupIds: ReadonlySet ): ReadonlySet => { if (groupIds.size === 0) return new Set(); const relationIds = new Set(); - planarPolygonGroups.forEach((group) => { + nodeChainAnnotations.forEach((group) => { if (!groupIds.has(group.id)) return; group.edgeRelationIds.forEach((relationId) => { if (!relationId) return; @@ -87,32 +87,32 @@ export const getSplitMarkerRelationIdsForGroups = ( }; export const getSplitMarkerRelationIdsByKindForGroups = ( - planarPolygonGroups: readonly PlanarPolygonGroupLike[], + nodeChainAnnotations: readonly PolygonAnnotationLike[], groupIds: ReadonlySet ): EditableLineRelationIdsByKind => { if (groupIds.size === 0) { return createEditableLineRelationIdsByKind(); } const byKind = createEditableLineRelationIdsByKind(); - planarPolygonGroups.forEach((group) => { + nodeChainAnnotations.forEach((group) => { if (!groupIds.has(group.id)) return; - const measurementKind = resolveEditableLineMeasurementKind(group); + const type = resolveEditableLineMeasurementKind(group); group.edgeRelationIds.forEach((relationId) => { if (!relationId) return; - byKind[measurementKind].add(relationId); + byKind[type].add(relationId); }); }); return byKind; }; -export const getRoofSharedEdgeRelationIds = ( - planarPolygonGroups: readonly PlanarPolygonGroupLike[] +export const getPlanarSharedEdgeRelationIds = ( + nodeChainAnnotations: readonly PolygonAnnotationLike[] ): ReadonlySet => { const relationUsageCount = new Map(); const relationMeasurementKinds = new Map>(); - planarPolygonGroups.forEach((group) => { - const measurementKind = resolveEditableLineMeasurementKind(group); + nodeChainAnnotations.forEach((group) => { + const type = resolveEditableLineMeasurementKind(group); group.edgeRelationIds.forEach((edgeRelationId) => { if (!edgeRelationId) return; relationUsageCount.set( @@ -121,14 +121,14 @@ export const getRoofSharedEdgeRelationIds = ( ); const measurementKinds = relationMeasurementKinds.get(edgeRelationId); if (measurementKinds) { - measurementKinds.add(measurementKind); + measurementKinds.add(type); return; } - relationMeasurementKinds.set(edgeRelationId, new Set([measurementKind])); + relationMeasurementKinds.set(edgeRelationId, new Set([type])); }); }); - const sharedRoofEdgeIds = new Set(); + const sharedPlanarEdgeIds = new Set(); relationUsageCount.forEach((count, edgeRelationId) => { const measurementKinds = relationMeasurementKinds.get(edgeRelationId); if (!measurementKinds) return; @@ -137,9 +137,9 @@ export const getRoofSharedEdgeRelationIds = ( measurementKinds.size === 1 && measurementKinds.has(ANNOTATION_TYPE_AREA_PLANAR) ) { - sharedRoofEdgeIds.add(edgeRelationId); + sharedPlanarEdgeIds.add(edgeRelationId); } }); - return sharedRoofEdgeIds; + return sharedPlanarEdgeIds; }; diff --git a/libraries/mapping/annotations/core/src/lib/preview/annotationPreviewVisuals.ts b/libraries/mapping/annotations/core/src/lib/preview/annotationPreviewVisuals.ts deleted file mode 100644 index 93f2968a5d..0000000000 --- a/libraries/mapping/annotations/core/src/lib/preview/annotationPreviewVisuals.ts +++ /dev/null @@ -1,453 +0,0 @@ -import { Cartesian3 } from "@carma/cesium"; - -import { ANNOTATION_TYPE_POLYLINE } from "../types/annotationTypes"; -import { type PlanarPolygonGroup } from "../types/planarTypes"; - -export const POLYGON_PREVIEW_STROKE = "rgba(255, 255, 255, 0.65)"; -export const POLYGON_PREVIEW_STROKE_WIDTH_PX = 1; - -const FACADE_RECTANGLE_COMPONENT_EPSILON_METERS = 0.05; - -type PointWithGeometryECEF = { - geometryECEF: Cartesian3; -}; - -type PreviewPlane = { - anchorECEF: Cartesian3; - normalECEF: Cartesian3; -}; - -const createPlaneFromThreePoints = ( - a: Cartesian3, - b: Cartesian3, - c: Cartesian3 -): PreviewPlane | null => { - const ab = Cartesian3.subtract(b, a, new Cartesian3()); - const ac = Cartesian3.subtract(c, a, new Cartesian3()); - const normal = Cartesian3.cross(ab, ac, new Cartesian3()); - if (Cartesian3.magnitudeSquared(normal) <= 1e-8) return null; - - return { - anchorECEF: Cartesian3.clone(a), - normalECEF: Cartesian3.normalize(normal, new Cartesian3()), - }; -}; - -const projectPointOntoPlane = ( - point: Cartesian3, - plane: PreviewPlane -): Cartesian3 => { - const delta = Cartesian3.subtract(point, plane.anchorECEF, new Cartesian3()); - const distanceAlongNormal = Cartesian3.dot(delta, plane.normalECEF); - return Cartesian3.subtract( - point, - Cartesian3.multiplyByScalar( - plane.normalECEF, - distanceAlongNormal, - new Cartesian3() - ), - new Cartesian3() - ); -}; - -const buildFacadeRectangleCornerFromDiagonal = ( - firstCorner: Cartesian3, - oppositeCorner: Cartesian3 -) => { - const up = Cartesian3.normalize(firstCorner, new Cartesian3()); - const diagonal = Cartesian3.subtract( - oppositeCorner, - firstCorner, - new Cartesian3() - ); - const verticalMeters = Cartesian3.dot(diagonal, up); - const verticalComponent = Cartesian3.multiplyByScalar( - up, - verticalMeters, - new Cartesian3() - ); - const horizontalComponent = Cartesian3.subtract( - diagonal, - verticalComponent, - new Cartesian3() - ); - const horizontalMeters = Cartesian3.magnitude(horizontalComponent); - const verticalAbsoluteMeters = Math.abs(verticalMeters); - - if ( - horizontalMeters < FACADE_RECTANGLE_COMPONENT_EPSILON_METERS || - verticalAbsoluteMeters < FACADE_RECTANGLE_COMPONENT_EPSILON_METERS - ) { - return null; - } - - const adjacentHorizontalCorner = Cartesian3.add( - firstCorner, - horizontalComponent, - new Cartesian3() - ); - const adjacentVerticalCorner = Cartesian3.add( - firstCorner, - verticalComponent, - new Cartesian3() - ); - - const planeUpAnchor = Cartesian3.add(firstCorner, up, new Cartesian3()); - const verticalPlane = createPlaneFromThreePoints( - firstCorner, - planeUpAnchor, - adjacentHorizontalCorner - ); - - return { - adjacentHorizontalCorner: verticalPlane - ? projectPointOntoPlane(adjacentHorizontalCorner, verticalPlane) - : adjacentHorizontalCorner, - adjacentVerticalCorner: verticalPlane - ? projectPointOntoPlane(adjacentVerticalCorner, verticalPlane) - : adjacentVerticalCorner, - }; -}; - -export type PolygonPreviewGroup = { - group: PlanarPolygonGroup; - vertexPoints: Cartesian3[]; -}; - -export type GroundPolygonPreviewGroup = PolygonPreviewGroup & { - group: PlanarPolygonGroup & { - surfaceType?: "terrain" | "footprint"; - }; -}; - -export type VerticalPolygonPreviewGroup = PolygonPreviewGroup & { - group: PlanarPolygonGroup & { - surfaceType: "facade"; - }; -}; - -export type PlanarPolygonPreviewGroup = PolygonPreviewGroup & { - group: PlanarPolygonGroup & { - surfaceType?: "roof"; - }; -}; - -export type FacadePreviewEdgeSegment = { - id: string; - start: Cartesian3; - end: Cartesian3; -}; - -export type FacadePreviewCornerMarker = { - id: string; - position: Cartesian3; -}; - -export type PolylinePreviewMeasurement = { - id: string; - vertexPoints: Cartesian3[]; -}; - -export type PolygonPreviewGroupsBySurface = { - groundPolygonPreviewGroups: GroundPolygonPreviewGroup[]; - verticalPolygonPreviewGroups: VerticalPolygonPreviewGroup[]; - planarPolygonPreviewGroups: PlanarPolygonPreviewGroup[]; -}; - -export type PolygonPreviewBuildParams = { - planarPolygonGroups: PlanarPolygonGroup[]; - pointsById: ReadonlyMap; - facadeRectanglePreviewOppositeByGroupId?: Readonly< - Record - >; - activePlanarPolygonGroupId?: string | null; - livePreviewDistanceLine?: { - anchorPointECEF: Cartesian3; - targetPointECEF: Cartesian3; - showDirectLine: boolean; - showVerticalLine: boolean; - showHorizontalLine: boolean; - } | null; -}; - -const isGroundPolygonPreviewGroup = ( - previewGroup: PolygonPreviewGroup -): previewGroup is GroundPolygonPreviewGroup => { - const surfaceType = previewGroup.group.surfaceType ?? "roof"; - return surfaceType === "footprint" || surfaceType === "terrain"; -}; - -const isVerticalPolygonPreviewGroup = ( - previewGroup: PolygonPreviewGroup -): previewGroup is VerticalPolygonPreviewGroup => - (previewGroup.group.surfaceType ?? "roof") === "facade"; - -const isPlanarPolygonPreviewGroup = ( - previewGroup: PolygonPreviewGroup -): previewGroup is PlanarPolygonPreviewGroup => - (previewGroup.group.surfaceType ?? "roof") === "roof"; - -export const buildPolygonPreviewGroups = ({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, - activePlanarPolygonGroupId, - livePreviewDistanceLine, -}: PolygonPreviewBuildParams): PolygonPreviewGroup[] => - planarPolygonGroups - .map((group) => { - const measurementKind = group.measurementKind; - if (measurementKind === ANNOTATION_TYPE_POLYLINE) { - return null; - } - - if (group.closed && group.vertexPointIds.length >= 3) { - const vertexPoints = group.vertexPointIds - .map((pointId) => pointsById.get(pointId)?.geometryECEF) - .filter((point): point is Cartesian3 => Boolean(point)); - return { - group, - vertexPoints, - }; - } - - if ( - !group.closed && - (group.surfaceType ?? "roof") === "facade" && - group.vertexPointIds.length === 1 - ) { - const firstVertexId = group.vertexPointIds[0] ?? null; - const firstVertex = firstVertexId - ? pointsById.get(firstVertexId)?.geometryECEF - : null; - const previewOppositeCorner = - facadeRectanglePreviewOppositeByGroupId?.[group.id]; - if (!firstVertex || !previewOppositeCorner) { - return null; - } - - const facadeCorners = buildFacadeRectangleCornerFromDiagonal( - firstVertex, - previewOppositeCorner - ); - if (!facadeCorners) { - return null; - } - - return { - group, - vertexPoints: [ - firstVertex, - facadeCorners.adjacentHorizontalCorner, - previewOppositeCorner, - facadeCorners.adjacentVerticalCorner, - ], - }; - } - - if ( - !group.closed && - group.id === activePlanarPolygonGroupId && - ((group.surfaceType ?? "roof") === "footprint" || - (group.surfaceType ?? "roof") === "roof") && - group.vertexPointIds.length >= 2 - ) { - const baseVertexPoints = group.vertexPointIds - .map((pointId) => pointsById.get(pointId)?.geometryECEF) - .filter((point): point is Cartesian3 => Boolean(point)); - if (baseVertexPoints.length < 2) { - return null; - } - - const previewTargetPoint = livePreviewDistanceLine?.showDirectLine - ? livePreviewDistanceLine.targetPointECEF - : null; - const lastBaseVertex = baseVertexPoints[baseVertexPoints.length - 1]; - const previewIncludesHoveredPoint = Boolean( - previewTargetPoint && - lastBaseVertex && - Cartesian3.distanceSquared(lastBaseVertex, previewTargetPoint) > - 1e-6 - ); - const vertexPoints = previewIncludesHoveredPoint - ? [...baseVertexPoints, Cartesian3.clone(previewTargetPoint)] - : baseVertexPoints; - - return vertexPoints.length >= 3 - ? { - group, - vertexPoints, - } - : null; - } - - return null; - }) - .filter( - ( - previewGroup - ): previewGroup is { - group: PlanarPolygonGroup; - vertexPoints: Cartesian3[]; - } => Boolean(previewGroup && previewGroup.vertexPoints.length >= 3) - ); - -export const buildGroundPolygonPreviewGroups = ( - params: PolygonPreviewBuildParams -): GroundPolygonPreviewGroup[] => - buildPolygonPreviewGroups(params).filter(isGroundPolygonPreviewGroup); - -export const buildVerticalPolygonPreviewGroups = ( - params: PolygonPreviewBuildParams -): VerticalPolygonPreviewGroup[] => - buildPolygonPreviewGroups(params).filter(isVerticalPolygonPreviewGroup); - -export const buildPlanarPolygonPreviewGroups = ( - params: PolygonPreviewBuildParams -): PlanarPolygonPreviewGroup[] => - buildPolygonPreviewGroups(params).filter(isPlanarPolygonPreviewGroup); - -export const buildPolygonPreviewGroupsBySurface = ( - params: PolygonPreviewBuildParams -): PolygonPreviewGroupsBySurface => ({ - groundPolygonPreviewGroups: buildGroundPolygonPreviewGroups(params), - verticalPolygonPreviewGroups: buildVerticalPolygonPreviewGroups(params), - planarPolygonPreviewGroups: buildPlanarPolygonPreviewGroups(params), -}); - -export const buildFacadePreviewEdgeSegments = ( - polygonPreviewGroups: PolygonPreviewGroup[] -): FacadePreviewEdgeSegment[] => - polygonPreviewGroups - .filter( - ({ group, vertexPoints }) => - !group.closed && - (group.surfaceType ?? "roof") === "facade" && - vertexPoints.length === 4 - ) - .flatMap(({ group, vertexPoints }) => { - const segments: FacadePreviewEdgeSegment[] = []; - for (let index = 0; index < vertexPoints.length; index += 1) { - const start = vertexPoints[index]; - const end = vertexPoints[(index + 1) % vertexPoints.length]; - if (!start || !end) continue; - segments.push({ - id: `${group.id}:${index}`, - start, - end, - }); - } - return segments; - }); - -export const buildFacadePreviewCornerMarkers = ( - polygonPreviewGroups: PolygonPreviewGroup[] -): FacadePreviewCornerMarker[] => - polygonPreviewGroups - .filter( - ({ group, vertexPoints }) => - !group.closed && - (group.surfaceType ?? "roof") === "facade" && - vertexPoints.length === 4 - ) - .flatMap(({ group, vertexPoints }) => { - const horizontalCorner = vertexPoints[1]; - const verticalCorner = vertexPoints[3]; - if (!horizontalCorner || !verticalCorner) return []; - return [ - { - id: `${group.id}:horizontal`, - position: horizontalCorner, - }, - { - id: `${group.id}:vertical`, - position: verticalCorner, - }, - ]; - }); - -export const buildPolylinePreviewMeasurements = ({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, -}: { - planarPolygonGroups: PlanarPolygonGroup[]; - pointsById: ReadonlyMap; - facadeRectanglePreviewOppositeByGroupId?: Readonly< - Record - >; -}): PolylinePreviewMeasurement[] => - planarPolygonGroups - .map((group) => { - if (group.closed) return null; - if ((group.surfaceType ?? "roof") !== "facade") return null; - if (group.vertexPointIds.length !== 1) return null; - - const firstVertexId = group.vertexPointIds[0] ?? null; - const firstVertex = firstVertexId - ? pointsById.get(firstVertexId)?.geometryECEF - : null; - const previewOppositeCorner = - facadeRectanglePreviewOppositeByGroupId?.[group.id]; - if (!firstVertex || !previewOppositeCorner) { - return null; - } - - const facadeCorners = buildFacadeRectangleCornerFromDiagonal( - firstVertex, - previewOppositeCorner - ); - if (!facadeCorners) { - return null; - } - - return { - id: group.id, - vertexPoints: [ - firstVertex, - facadeCorners.adjacentHorizontalCorner, - previewOppositeCorner, - facadeCorners.adjacentVerticalCorner, - ], - }; - }) - .filter((measurement): measurement is PolylinePreviewMeasurement => - Boolean(measurement && measurement.vertexPoints.length === 4) - ); - -export const buildPolylinePreviewEdgeSegments = ( - polylineMeasurements: PolylinePreviewMeasurement[] -): FacadePreviewEdgeSegment[] => - polylineMeasurements.flatMap(({ id, vertexPoints }) => { - const segments: FacadePreviewEdgeSegment[] = []; - for (let index = 0; index < vertexPoints.length; index += 1) { - const start = vertexPoints[index]; - const end = vertexPoints[(index + 1) % vertexPoints.length]; - if (!start || !end) continue; - segments.push({ - id: `${id}:${index}`, - start, - end, - }); - } - return segments; - }); - -export const buildPolylinePreviewCornerMarkers = ( - polylineMeasurements: PolylinePreviewMeasurement[] -): FacadePreviewCornerMarker[] => - polylineMeasurements.flatMap(({ id, vertexPoints }) => { - const horizontalCorner = vertexPoints[1]; - const verticalCorner = vertexPoints[3]; - if (!horizontalCorner || !verticalCorner) return []; - return [ - { - id: `${id}:horizontal`, - position: horizontalCorner, - }, - { - id: `${id}:vertical`, - position: verticalCorner, - }, - ]; - }); diff --git a/libraries/mapping/annotations/core/src/lib/tools/annotationToolManager.tsx b/libraries/mapping/annotations/core/src/lib/tools/annotationToolManager.tsx index 59a3b378e2..f3a361d8c7 100644 --- a/libraries/mapping/annotations/core/src/lib/tools/annotationToolManager.tsx +++ b/libraries/mapping/annotations/core/src/lib/tools/annotationToolManager.tsx @@ -51,10 +51,10 @@ export const DEFAULT_ANNOTATION_TOOL_MESSAGES: Readonly< "measurement.tool.polyline.tooltip": "Polygonzug messen", "measurement.tool.areaFootprint.label": "Grundriss", "measurement.tool.areaFootprint.tooltip": "Grundriss", - "measurement.tool.areaRoof.label": "Dach", - "measurement.tool.areaRoof.tooltip": "Dachfläche", - "measurement.tool.areaFacade.label": "Fassade", - "measurement.tool.areaFacade.tooltip": "Fassadenfläche", + "measurement.tool.areaPlanar.label": "Planar", + "measurement.tool.areaPlanar.tooltip": "Planare Fläche", + "measurement.tool.areaVertical.label": "Vertikal", + "measurement.tool.areaVertical.tooltip": "Vertikale Fläche", "measurement.tool.label.label": "Anmerkung", "measurement.tool.label.tooltip": "Anmerkung", "measurement.tool.select.label": "Auswahl", @@ -111,8 +111,8 @@ export const defaultAnnotationToolDescriptors: readonly AnnotationToolDescriptor order: 50, icon: , i18n: { - labelKey: "measurement.tool.areaRoof.label", - tooltipKey: "measurement.tool.areaRoof.tooltip", + labelKey: "measurement.tool.areaPlanar.label", + tooltipKey: "measurement.tool.areaPlanar.tooltip", }, }, { @@ -120,8 +120,8 @@ export const defaultAnnotationToolDescriptors: readonly AnnotationToolDescriptor order: 60, icon: , i18n: { - labelKey: "measurement.tool.areaFacade.label", - tooltipKey: "measurement.tool.areaFacade.tooltip", + labelKey: "measurement.tool.areaVertical.label", + tooltipKey: "measurement.tool.areaVertical.tooltip", }, }, { diff --git a/libraries/mapping/annotations/core/src/lib/types/annotationCandidate.ts b/libraries/mapping/annotations/core/src/lib/types/annotationCandidate.ts new file mode 100644 index 0000000000..5d6430eb49 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/types/annotationCandidate.ts @@ -0,0 +1,25 @@ +export const ANNOTATION_CANDIDATE_KIND_NONE = "none"; +export const ANNOTATION_CANDIDATE_KIND_POINT = "point"; +export const ANNOTATION_CANDIDATE_KIND_DISTANCE = "distance"; +export const ANNOTATION_CANDIDATE_KIND_POLYLINE = "polyline"; +export const ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND = "polygon-ground"; +export const ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR = "polygon-planar"; +export const ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL = "polygon-vertical"; + +export type AnnotationCandidateKind = + | typeof ANNOTATION_CANDIDATE_KIND_NONE + | typeof ANNOTATION_CANDIDATE_KIND_POINT + | typeof ANNOTATION_CANDIDATE_KIND_DISTANCE + | typeof ANNOTATION_CANDIDATE_KIND_POLYLINE + | typeof ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND + | typeof ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR + | typeof ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL; + +export type AnnotationCandidateDescriptor = { + kind: AnnotationCandidateKind; + verticalOffsetMeters: number; + verticalPolygonContext?: { + groupId: string; + firstNodeId: string; + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/types/annotationCesiumTypes.ts b/libraries/mapping/annotations/core/src/lib/types/annotationCesiumTypes.ts index 19c2f8d579..4ecd24d401 100644 --- a/libraries/mapping/annotations/core/src/lib/types/annotationCesiumTypes.ts +++ b/libraries/mapping/annotations/core/src/lib/types/annotationCesiumTypes.ts @@ -9,7 +9,6 @@ import { } from "./annotationTypes"; import type { BaseAnnotationEntry } from "./annotationEntry"; import type { AnnotationPersistenceEnvelopeV2Base } from "./annotationPersistenceTypes"; -import type { PointReferenceLineAnnotation } from "./distanceRelation"; export type AnnotationMode = | typeof SELECT_TOOL_TYPE @@ -30,23 +29,45 @@ export type AnnotationEntry = BaseAnnotationEntry & { >; }; -export type PointAnnotationEntry = AnnotationEntry & { - type: typeof ANNOTATION_TYPE_DISTANCE; +export type AnnotationPointEntry = AnnotationEntry & { + type: typeof ANNOTATION_TYPE_POINT | typeof ANNOTATION_TYPE_DISTANCE; geometryECEF: Cartesian3; geometryWGS84: LatLngAlt.deg & { altitude: Altitude.EllipsoidalWGS84Meters; }; radius?: number; - isFacadeAutoCorner?: boolean; - referenceLineAnnotation?: PointReferenceLineAnnotation; verticalOffsetAnchorECEF?: Cartesian3Json; - distanceAdhocNode?: boolean; - distanceRelationId?: string; }; +export type PointMeasurementEntry = AnnotationPointEntry & { + type: typeof ANNOTATION_TYPE_POINT; +}; + +export type DistancePointEntry = AnnotationPointEntry & { + type: typeof ANNOTATION_TYPE_DISTANCE; +}; + +export type PointAnnotationEntry = AnnotationPointEntry; + export function isPointAnnotationEntry( entry: AnnotationEntry -): entry is PointAnnotationEntry { +): entry is AnnotationPointEntry { + return ( + entry && + (entry.type === ANNOTATION_TYPE_POINT || + entry.type === ANNOTATION_TYPE_DISTANCE) + ); +} + +export function isPointMeasurementEntry( + entry: AnnotationEntry +): entry is PointMeasurementEntry { + return entry && entry.type === ANNOTATION_TYPE_POINT; +} + +export function isDistancePointEntry( + entry: AnnotationEntry +): entry is DistancePointEntry { return entry && entry.type === ANNOTATION_TYPE_DISTANCE; } diff --git a/libraries/mapping/annotations/core/src/lib/types/annotationEntry.ts b/libraries/mapping/annotations/core/src/lib/types/annotationEntry.ts index b1a4e5ef67..e3046fff83 100644 --- a/libraries/mapping/annotations/core/src/lib/types/annotationEntry.ts +++ b/libraries/mapping/annotations/core/src/lib/types/annotationEntry.ts @@ -8,7 +8,7 @@ export type BaseAnnotationEntry = { id: string; type: TMode; timestamp: number; - isLivePreview?: boolean; + isCandidate?: boolean; index?: number; name?: string; hidden?: boolean; diff --git a/libraries/mapping/annotations/core/src/lib/types/annotationPersistenceTypes.ts b/libraries/mapping/annotations/core/src/lib/types/annotationPersistenceTypes.ts index cb8da4574e..914ff733e7 100644 --- a/libraries/mapping/annotations/core/src/lib/types/annotationPersistenceTypes.ts +++ b/libraries/mapping/annotations/core/src/lib/types/annotationPersistenceTypes.ts @@ -6,7 +6,7 @@ import type { PointLabelMetricMode, } from "./annotationLabel"; import type { PointDistanceRelation } from "./distanceRelation"; -import type { PlanarPolygonGroup } from "./planarTypes"; +import type { NodeChainAnnotation } from "./annotationTypes"; export type AnnotationGeometryPoint = { id: string; @@ -29,7 +29,7 @@ export type AnnotationGeometryEdge = { pointBId: string; }; -export type PlanarPolygonGroupVertex = { +export type PolygonAnnotationVertex = { id: string; groupId: string; pointId: string; @@ -45,7 +45,7 @@ export type AnnotationPersistenceEnvelopeV2Base = { tables: { annotations: TMeasurementEntry[]; distanceRelations: PointDistanceRelation[]; - planarPolygonGroups: PlanarPolygonGroup[]; - planarPolygonGroupVertices: PlanarPolygonGroupVertex[]; + nodeChainAnnotations: NodeChainAnnotation[]; + planarPolygonGroupVertices: PolygonAnnotationVertex[]; }; }; diff --git a/libraries/mapping/annotations/core/src/lib/types/annotationTypes.ts b/libraries/mapping/annotations/core/src/lib/types/annotationTypes.ts index ac41edfa58..80076b5979 100644 --- a/libraries/mapping/annotations/core/src/lib/types/annotationTypes.ts +++ b/libraries/mapping/annotations/core/src/lib/types/annotationTypes.ts @@ -1,3 +1,7 @@ +import type { Cartesian3Json } from "@carma/cesium"; + +import type { LinearSegmentLineMode } from "./linearSegment"; + // Tool and annotation identifiers export const SELECT_TOOL_TYPE = "select" as const; export const ANNOTATION_TYPE_POINT = "point" as const; @@ -21,5 +25,62 @@ const ANNOTATION_TYPES = [ export type AnnotationType = (typeof ANNOTATION_TYPES)[number]; export type AnnotationShortLabelKind = AnnotationType; -const ANNOTATION_TOOL_TYPES = [SELECT_TOOL_TYPE, ...ANNOTATION_TYPES] as const; -export type AnnotationToolType = (typeof ANNOTATION_TOOL_TYPES)[number]; +export type AnnotationToolType = typeof SELECT_TOOL_TYPE | AnnotationType; + +export const isAreaToolType = ( + toolType: AnnotationToolType +): toolType is + | typeof ANNOTATION_TYPE_AREA_GROUND + | typeof ANNOTATION_TYPE_AREA_VERTICAL + | typeof ANNOTATION_TYPE_AREA_PLANAR => + toolType === ANNOTATION_TYPE_AREA_GROUND || + toolType === ANNOTATION_TYPE_AREA_VERTICAL || + toolType === ANNOTATION_TYPE_AREA_PLANAR; + +export type PlanarPolygonType = + | typeof ANNOTATION_TYPE_AREA_PLANAR + | typeof ANNOTATION_TYPE_AREA_VERTICAL; + +export type GroundPolygonType = typeof ANNOTATION_TYPE_AREA_GROUND; + +export type PolygonType = GroundPolygonType | PlanarPolygonType; +export type PolygonAreaType = PolygonType; + +export type NodeChainAnnotationType = + | typeof ANNOTATION_TYPE_POLYLINE + | PolygonType; + +export type PlanarPolygonPlane = { + anchorECEF: Cartesian3Json; + normalECEF: Cartesian3Json; +}; + +export type PlanarPolygonLocalFrame = { + originECEF: Cartesian3Json; + eastECEF: Cartesian3Json; + northECEF: Cartesian3Json; + upECEF: Cartesian3Json; +}; + +type NodeChainAnnotationBase = { + id: string; + name?: string; + hidden?: boolean; + segmentLineMode?: LinearSegmentLineMode; + verticalOffsetMeters?: number; + nodeIds: string[]; + edgeRelationIds: string[]; + distanceMeasurementStartPointId?: string; + closed: boolean; + planeLocked: boolean; + plane?: PlanarPolygonPlane; + planarPolygonLocalFrame?: PlanarPolygonLocalFrame; + perimeterMeters?: number; + areaSquareMeters?: number; + verticalityDeg?: number; + bearingDeg?: number; +}; + +export type NodeChainAnnotation = NodeChainAnnotationBase & { + type: NodeChainAnnotationType; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/types/derivedPolylinePath.ts b/libraries/mapping/annotations/core/src/lib/types/derivedPolylinePath.ts similarity index 80% rename from libraries/mapping/annotations/provider/src/lib/context/types/derivedPolylinePath.ts rename to libraries/mapping/annotations/core/src/lib/types/derivedPolylinePath.ts index 699f871ed1..bb3f4b2242 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/types/derivedPolylinePath.ts +++ b/libraries/mapping/annotations/core/src/lib/types/derivedPolylinePath.ts @@ -1,10 +1,10 @@ export type DerivedPolylinePath = { id: string; name?: string; - vertexPointIds: string[]; + nodeIds: string[]; edgeRelationIds: string[]; distanceMeasurementStartPointId: string | null; - vertexHeightsMeters: number[]; + nodeHeightsMeters: number[]; segmentLengthsMeters: number[]; segmentLengthsCumulativeMeters: number[]; totalLengthMeters: number; diff --git a/libraries/mapping/annotations/core/src/lib/types/distanceRelation.ts b/libraries/mapping/annotations/core/src/lib/types/distanceRelation.ts index fabb042dd2..02cfc05b81 100644 --- a/libraries/mapping/annotations/core/src/lib/types/distanceRelation.ts +++ b/libraries/mapping/annotations/core/src/lib/types/distanceRelation.ts @@ -1,7 +1,7 @@ import type { DirectLineLabelMode, DistanceRelationLabelVisibilityByKind, -} from "../visualizers/distance/distanceRelationLabel.types"; +} from "../visualization/distance/distanceRelationLabel.types"; export type PointDistanceRelation = { id: string; @@ -17,8 +17,3 @@ export type PointDistanceRelation = { labelVisibilityByKind?: DistanceRelationLabelVisibilityByKind; directLabelMode?: DirectLineLabelMode; }; - -export type PointReferenceLineAnnotation = { - showDirectLine?: boolean; - showComponentLines?: boolean; -}; diff --git a/libraries/mapping/annotations/core/src/lib/types/distanceRelationRenderContext.ts b/libraries/mapping/annotations/core/src/lib/types/distanceRelationRenderContext.ts index d7bfb0588c..7baa5f00bb 100644 --- a/libraries/mapping/annotations/core/src/lib/types/distanceRelationRenderContext.ts +++ b/libraries/mapping/annotations/core/src/lib/types/distanceRelationRenderContext.ts @@ -8,5 +8,5 @@ export type DistanceRelationRenderContext = { midpointTickRelationIds: ReadonlySet; focusedRelationIds: ReadonlySet; selectedOrActiveOpenPolylineRelationIds: ReadonlySet; - duplicateFacadeOpposingRelationIds: ReadonlySet; + duplicateVerticalOpposingRelationIds: ReadonlySet; }; diff --git a/libraries/mapping/annotations/core/src/lib/types/lineType.ts b/libraries/mapping/annotations/core/src/lib/types/lineType.ts new file mode 100644 index 0000000000..d9bf4be1c4 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/types/lineType.ts @@ -0,0 +1,6 @@ +export const LINE_TYPE_CARTESIAN = "cartesian" as const; +export const LINE_TYPE_GEOGRAPHIC = "geographic" as const; + +export const LINE_TYPES = [LINE_TYPE_CARTESIAN, LINE_TYPE_GEOGRAPHIC] as const; + +export type LineType = (typeof LINE_TYPES)[number]; diff --git a/libraries/mapping/annotations/core/src/lib/types/planarTypes.ts b/libraries/mapping/annotations/core/src/lib/types/planarTypes.ts deleted file mode 100644 index a5043bd799..0000000000 --- a/libraries/mapping/annotations/core/src/lib/types/planarTypes.ts +++ /dev/null @@ -1,52 +0,0 @@ -import type { Cartesian3Json } from "@carma/cesium"; - -import { - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, - ANNOTATION_TYPE_POLYLINE, -} from "./annotationTypes"; -import type { LinearSegmentLineMode } from "./linearSegment"; - -export const PLANAR_POLYGON_SOURCE_KINDS = [ - ANNOTATION_TYPE_POLYLINE, - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, -] as const; - -export type PlanarPolygonSourceKind = - (typeof PLANAR_POLYGON_SOURCE_KINDS)[number]; - -export type PlanarPolygonPlane = { - anchorECEF: Cartesian3Json; - normalECEF: Cartesian3Json; -}; - -export type PlanarPolygonLocalFrame = { - originECEF: Cartesian3Json; - eastECEF: Cartesian3Json; - northECEF: Cartesian3Json; - upECEF: Cartesian3Json; -}; - -export type PlanarPolygonGroup = { - id: string; - name?: string; - hidden?: boolean; - measurementKind: PlanarPolygonSourceKind; - segmentLineMode?: LinearSegmentLineMode; - verticalOffsetMeters?: number; - vertexPointIds: string[]; - edgeRelationIds: string[]; - distanceMeasurementStartPointId?: string; - closed: boolean; - planeLocked: boolean; - plane?: PlanarPolygonPlane; - planarPolygonLocalFrame?: PlanarPolygonLocalFrame; - perimeterMeters?: number; - areaSquareMeters?: number; - verticalityDeg?: number; - bearingDeg?: number; - surfaceType?: "roof" | "facade" | "terrain" | "footprint"; -}; diff --git a/libraries/mapping/annotations/core/src/lib/utils/annotationCollection.ts b/libraries/mapping/annotations/core/src/lib/utils/annotationCollection.ts new file mode 100644 index 0000000000..fc72488106 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/annotationCollection.ts @@ -0,0 +1,98 @@ +import { Cartesian3 } from "@carma/cesium"; + +import { + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationEntry, + type PointAnnotationEntry, +} from "../types/annotationCesiumTypes"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; +import { getCustomPointAnnotationName } from "./annotationNaming"; + +export const getPointById = ( + annotations: AnnotationCollection, + pointId: string +): PointAnnotationEntry | null => { + const point = annotations.find((measurement) => measurement.id === pointId); + return point && isPointAnnotationEntry(point) ? point : null; +}; + +export const getPointPositionMap = ( + annotations: AnnotationCollection, + overrides?: Readonly> +) => { + const map = new Map(); + + annotations.forEach((measurement) => { + if (!isPointAnnotationEntry(measurement)) { + return; + } + map.set(measurement.id, measurement.geometryECEF); + }); + + if (overrides) { + Object.entries(overrides).forEach(([id, position]) => { + map.set(id, position); + }); + } + + return map; +}; + +export const getMeasurementEntryFlyToPoints = ( + measurement: AnnotationEntry +): Cartesian3[] => { + if (isPointAnnotationEntry(measurement)) { + return [measurement.geometryECEF]; + } + + if (Array.isArray(measurement.geometryECEF)) { + return measurement.geometryECEF; + } + + return []; +}; + +export const getAnnotationFlyToPointsById = ( + id: string, + annotations: AnnotationCollection, + nodeChainAnnotations: readonly NodeChainAnnotation[] +): Cartesian3[] => { + if (!id) { + return []; + } + + const pointById = getPointPositionMap(annotations); + const multiNodeAnnotation = + nodeChainAnnotations.find((entry) => entry.id === id) ?? null; + if (multiNodeAnnotation) { + return multiNodeAnnotation.nodeIds + .map((pointId) => pointById.get(pointId) ?? null) + .filter((point): point is Cartesian3 => Boolean(point)); + } + + const annotation = annotations.find((entry) => entry.id === id); + if (!annotation) { + return []; + } + + return getMeasurementEntryFlyToPoints(annotation); +}; + +export const getLastCustomPointAnnotationName = ( + annotations: AnnotationCollection +): string | undefined => { + for (let index = annotations.length - 1; index >= 0; index -= 1) { + const annotation = annotations[index]; + if (!annotation || !isPointAnnotationEntry(annotation)) { + continue; + } + + const customName = getCustomPointAnnotationName(annotation.name); + if (customName) { + return customName; + } + } + + return undefined; +}; diff --git a/libraries/mapping/annotations/core/src/lib/utils/annotationLabel.ts b/libraries/mapping/annotations/core/src/lib/utils/annotationLabel.ts index a9398d2ca2..3a59a1ee9f 100644 --- a/libraries/mapping/annotations/core/src/lib/utils/annotationLabel.ts +++ b/libraries/mapping/annotations/core/src/lib/utils/annotationLabel.ts @@ -1,6 +1,11 @@ import type { AnnotationLabelAnchor, AnnotationLabelAppearance, + PointLabelMetricMode, +} from "../types/annotationLabel"; +import { + DEFAULT_POINT_LABEL_METRIC_MODE, + POINT_LABEL_METRIC_MODES, } from "../types/annotationLabel"; const normalizeCompactLabelContent = ( @@ -67,6 +72,14 @@ export const normalizeLabelAppearance = ( }; }; +export const getNextPointLabelMetricMode = ( + currentMode: PointLabelMetricMode = DEFAULT_POINT_LABEL_METRIC_MODE +): PointLabelMetricMode => { + const currentIndex = POINT_LABEL_METRIC_MODES.indexOf(currentMode); + const nextIndex = (currentIndex + 1) % POINT_LABEL_METRIC_MODES.length; + return POINT_LABEL_METRIC_MODES[nextIndex]; +}; + const areLabelAnchorsEqual = ( left?: AnnotationLabelAnchor, right?: AnnotationLabelAnchor @@ -86,7 +99,6 @@ const areLabelAnchorsEqual = ( type PointLabelMeasurementLike = { id: string; labelAnchor?: AnnotationLabelAnchor; - distanceAdhocNode?: boolean; }; type PointMeasurementWithAltitudeLike = { @@ -104,7 +116,7 @@ type DistanceRelationLike = { type PolylineLabelLike = { id: string; - vertexPointIds: ReadonlyArray; + nodeIds: ReadonlyArray; totalLengthMeters: number; }; @@ -248,7 +260,7 @@ type BuildDesiredPointLabelAnchorByIdParams< > = { pointMeasurements: ReadonlyArray; polylines: ReadonlyArray; - focusedPlanarPolygonGroupId: string | null; + focusedNodeChainAnnotationId: string | null; pointMarkerBadgeByPointId: Readonly>; standaloneDistanceHighestPointIds: ReadonlySet; unfocusedStandaloneDistanceNonHighestPointIds: ReadonlySet; @@ -262,7 +274,7 @@ export const buildDesiredPointLabelAnchorById = < >({ pointMeasurements, polylines, - focusedPlanarPolygonGroupId, + focusedNodeChainAnnotationId, pointMarkerBadgeByPointId, standaloneDistanceHighestPointIds, unfocusedStandaloneDistanceNonHighestPointIds, @@ -274,10 +286,6 @@ export const buildDesiredPointLabelAnchorById = < >): Readonly> => { const byPointId: Record = {}; pointMeasurements.forEach((measurement) => { - if (measurement.distanceAdhocNode) { - byPointId[measurement.id] = undefined; - return; - } byPointId[measurement.id] = { anchorPointId: measurement.id, collapseToCompact: false, @@ -310,13 +318,12 @@ export const buildDesiredPointLabelAnchorById = < }); polylines.forEach((polyline) => { - if (polyline.id === focusedPlanarPolygonGroupId) return; - polyline.vertexPointIds.forEach((pointId) => { + if (polyline.id === focusedNodeChainAnnotationId) return; + polyline.nodeIds.forEach((pointId) => { if (!pointId) return; byPointId[pointId] = undefined; }); - const lastPointId = - polyline.vertexPointIds[polyline.vertexPointIds.length - 1] ?? null; + const lastPointId = polyline.nodeIds[polyline.nodeIds.length - 1] ?? null; if (!lastPointId) return; byPointId[lastPointId] = { anchorPointId: lastPointId, diff --git a/libraries/mapping/annotations/core/src/lib/utils/annotationPersistence.ts b/libraries/mapping/annotations/core/src/lib/utils/annotationPersistence.ts index b21235f78c..6e1dd7822a 100644 --- a/libraries/mapping/annotations/core/src/lib/utils/annotationPersistence.ts +++ b/libraries/mapping/annotations/core/src/lib/utils/annotationPersistence.ts @@ -1,6 +1,6 @@ import type { BaseAnnotationEntry } from "../types/annotationEntry"; import type { AnnotationPersistenceEnvelopeV2Base } from "../types/annotationPersistenceTypes"; -import type { PointDistanceRelation } from "../types/distanceRelation"; +import { withDistanceRelationEdgeId } from "./measurementRelations"; type PersistedAnnotationEntry = BaseAnnotationEntry; type PersistedAnnotationEnvelopeV2 = @@ -10,21 +10,6 @@ const DEFAULT_STORAGE_KEY = "cesium-annotations"; const LEGACY_DEFAULT_STORAGE_KEY = "cesium-measurements"; const LEGACY_PERSISTENCE_STORAGE_SUFFIX = ":normalized-v2"; -const getMeasurementEdgeId = (pointAId: string, pointBId: string) => { - const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); - return `edge:${left}:${right}`; -}; - -const normalizeDistanceRelation = ( - relation: PointDistanceRelation -): PointDistanceRelation => ({ - ...relation, - edgeId: - relation.edgeId && relation.edgeId.length > 0 - ? relation.edgeId - : getMeasurementEdgeId(relation.pointAId, relation.pointBId), -}); - const getStorageKey = (storageKey: string | undefined) => storageKey ?? DEFAULT_STORAGE_KEY; @@ -73,7 +58,7 @@ export const loadAnnotationPersistenceState = < (parsed.tables as { measurements?: unknown }).measurements; if (!Array.isArray(rawAnnotations)) continue; if (!Array.isArray(parsed.tables.distanceRelations)) continue; - if (!Array.isArray(parsed.tables.planarPolygonGroups)) continue; + if (!Array.isArray(parsed.tables.nodeChainAnnotations)) continue; if (!Array.isArray(parsed.tables.planarPolygonGroupVertices)) continue; return { @@ -82,7 +67,7 @@ export const loadAnnotationPersistenceState = < ...parsed.tables, annotations: rawAnnotations as TEntry[], distanceRelations: parsed.tables.distanceRelations.map( - normalizeDistanceRelation + withDistanceRelationEdgeId ), }, }; diff --git a/libraries/mapping/annotations/core/src/lib/utils/annotationStateEquality.ts b/libraries/mapping/annotations/core/src/lib/utils/annotationStateEquality.ts new file mode 100644 index 0000000000..239c91864a --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/annotationStateEquality.ts @@ -0,0 +1,178 @@ +import type { PointDistanceRelation } from "../types/distanceRelation"; +import type { + NodeChainAnnotation, + PlanarPolygonLocalFrame, + PlanarPolygonPlane, +} from "../types/annotationTypes"; +import type { ReferenceLineLabelKind } from "../visualization/distance/distanceRelationLabel.types"; + +const DEFAULT_NUMERIC_EPSILON = 1e-9; + +const areStringArraysEqual = ( + left: readonly string[], + right: readonly string[] +) => { + if (left === right) return true; + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +}; + +const areOptionalNumbersEqual = ( + left: number | undefined, + right: number | undefined, + epsilon: number = DEFAULT_NUMERIC_EPSILON +) => { + if (left === right) return true; + if (left === undefined || right === undefined) return false; + return Math.abs(left - right) <= epsilon; +}; + +const areCartesian3JsonEqual = ( + left: + | { + x: number; + y: number; + z: number; + } + | undefined, + right: + | { + x: number; + y: number; + z: number; + } + | undefined, + epsilon: number = DEFAULT_NUMERIC_EPSILON +) => { + if (left === right) return true; + if (!left || !right) return false; + return ( + Math.abs(left.x - right.x) <= epsilon && + Math.abs(left.y - right.y) <= epsilon && + Math.abs(left.z - right.z) <= epsilon + ); +}; + +const arePlanarPolygonPlanesEqual = ( + left: PlanarPolygonPlane | undefined, + right: PlanarPolygonPlane | undefined, + epsilon: number = DEFAULT_NUMERIC_EPSILON +) => { + if (left === right) return true; + if (!left || !right) return false; + return ( + areCartesian3JsonEqual(left.anchorECEF, right.anchorECEF, epsilon) && + areCartesian3JsonEqual(left.normalECEF, right.normalECEF, epsilon) + ); +}; + +const arePlanarPolygonLocalFramesEqual = ( + left: PlanarPolygonLocalFrame | undefined, + right: PlanarPolygonLocalFrame | undefined, + epsilon: number = DEFAULT_NUMERIC_EPSILON +) => { + if (left === right) return true; + if (!left || !right) return false; + return ( + areCartesian3JsonEqual(left.originECEF, right.originECEF, epsilon) && + areCartesian3JsonEqual(left.eastECEF, right.eastECEF, epsilon) && + areCartesian3JsonEqual(left.northECEF, right.northECEF, epsilon) && + areCartesian3JsonEqual(left.upECEF, right.upECEF, epsilon) + ); +}; + +export const arePolygonAnnotationsEquivalent = ( + left: NodeChainAnnotation, + right: NodeChainAnnotation, + epsilon: number = DEFAULT_NUMERIC_EPSILON +) => + left === right || + (left.id === right.id && + left.name === right.name && + left.hidden === right.hidden && + left.type === right.type && + left.segmentLineMode === right.segmentLineMode && + areOptionalNumbersEqual( + left.verticalOffsetMeters, + right.verticalOffsetMeters, + epsilon + ) && + areStringArraysEqual(left.nodeIds, right.nodeIds) && + areStringArraysEqual(left.edgeRelationIds, right.edgeRelationIds) && + left.distanceMeasurementStartPointId === + right.distanceMeasurementStartPointId && + left.closed === right.closed && + left.planeLocked === right.planeLocked && + arePlanarPolygonPlanesEqual(left.plane, right.plane, epsilon) && + arePlanarPolygonLocalFramesEqual( + left.planarPolygonLocalFrame, + right.planarPolygonLocalFrame, + epsilon + ) && + areOptionalNumbersEqual( + left.perimeterMeters, + right.perimeterMeters, + epsilon + ) && + areOptionalNumbersEqual( + left.areaSquareMeters, + right.areaSquareMeters, + epsilon + ) && + areOptionalNumbersEqual( + left.verticalityDeg, + right.verticalityDeg, + epsilon + ) && + areOptionalNumbersEqual(left.bearingDeg, right.bearingDeg, epsilon)); + +const areDistanceLabelVisibilityEquivalent = ( + left: PointDistanceRelation["labelVisibilityByKind"], + right: PointDistanceRelation["labelVisibilityByKind"], + defaults: Readonly> +) => + (left?.direct ?? defaults.direct) === (right?.direct ?? defaults.direct) && + (left?.vertical ?? defaults.vertical) === + (right?.vertical ?? defaults.vertical) && + (left?.horizontal ?? defaults.horizontal) === + (right?.horizontal ?? defaults.horizontal); + +export const areDistanceRelationsEquivalent = ( + left: readonly PointDistanceRelation[], + right: readonly PointDistanceRelation[], + defaults: Readonly> +) => { + if (left === right) return true; + if (left.length !== right.length) return false; + + for (let index = 0; index < left.length; index += 1) { + const previous = left[index]; + const next = right[index]; + if (!previous || !next) return false; + if ( + previous.id !== next.id || + previous.edgeId !== next.edgeId || + previous.pointAId !== next.pointAId || + previous.pointBId !== next.pointBId || + previous.anchorPointId !== next.anchorPointId || + previous.polygonGroupId !== next.polygonGroupId || + previous.showDirectLine !== next.showDirectLine || + previous.showVerticalLine !== next.showVerticalLine || + previous.showHorizontalLine !== next.showHorizontalLine || + previous.showComponentLines !== next.showComponentLines || + previous.directLabelMode !== next.directLabelMode || + !areDistanceLabelVisibilityEquivalent( + previous.labelVisibilityByKind, + next.labelVisibilityByKind, + defaults + ) + ) { + return false; + } + } + + return true; +}; diff --git a/libraries/mapping/annotations/core/src/lib/utils/candidateCapabilities.ts b/libraries/mapping/annotations/core/src/lib/utils/candidateCapabilities.ts new file mode 100644 index 0000000000..844375a121 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/candidateCapabilities.ts @@ -0,0 +1,58 @@ +import { + ANNOTATION_CANDIDATE_KIND_DISTANCE, + ANNOTATION_CANDIDATE_KIND_NONE, + ANNOTATION_CANDIDATE_KIND_POINT, + ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND, + ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR, + ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + ANNOTATION_CANDIDATE_KIND_POLYLINE, + type AnnotationCandidateKind, +} from "../types/annotationCandidate"; + +export type AnnotationCandidateCapabilities = { + isPolylineCandidateMode: boolean; + hasCandidateNode: boolean; + candidateSupportsEdgeLine: boolean; + candidateUsesPolylineEdgeRules: boolean; + candidateForcesDirectEdgeLine: boolean; + isVerticalPolygonCandidate: boolean; +}; + +export const resolveCandidateCapabilities = ( + kind: AnnotationCandidateKind +): AnnotationCandidateCapabilities => { + const isPolylineCandidateMode = kind === ANNOTATION_CANDIDATE_KIND_POLYLINE; + const isVerticalPolygonCandidate = + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL; + const hasCandidateNode = kind !== ANNOTATION_CANDIDATE_KIND_NONE; + const candidateSupportsEdgeLine = + kind === ANNOTATION_CANDIDATE_KIND_DISTANCE || + kind === ANNOTATION_CANDIDATE_KIND_POLYLINE || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL; + const candidateUsesPolylineEdgeRules = + kind === ANNOTATION_CANDIDATE_KIND_POLYLINE || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR; + const candidateForcesDirectEdgeLine = + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR || + kind === ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL; + + return { + isPolylineCandidateMode, + hasCandidateNode, + candidateSupportsEdgeLine, + candidateUsesPolylineEdgeRules, + candidateForcesDirectEdgeLine, + isVerticalPolygonCandidate, + }; +}; + +export const hasPointCandidateOffsetStem = ( + kind: AnnotationCandidateKind, + verticalOffsetMeters: number +): boolean => + kind === ANNOTATION_CANDIDATE_KIND_POINT && + Math.abs(verticalOffsetMeters) > 1e-9; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/livePreviewDiscNormalSmoothing.ts b/libraries/mapping/annotations/core/src/lib/utils/candidateRingNormalSmoothing.ts similarity index 93% rename from libraries/mapping/annotations/cesium/src/lib/hooks/utils/livePreviewDiscNormalSmoothing.ts rename to libraries/mapping/annotations/core/src/lib/utils/candidateRingNormalSmoothing.ts index 3a3699a0be..3287ce0cc8 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/utils/livePreviewDiscNormalSmoothing.ts +++ b/libraries/mapping/annotations/core/src/lib/utils/candidateRingNormalSmoothing.ts @@ -1,6 +1,6 @@ import { Cartesian3 } from "@carma/cesium"; -export type LivePreviewDiscSample = { +export type CandidateRingSample = { normalX: number; normalY: number; normalZ: number; @@ -20,13 +20,13 @@ const orientNormalTowardReference = ( return normal; }; -export const pushLivePreviewDiscSample = ({ +export const pushCandidateRingSample = ({ samples, normal, maxSampleCount, timestampMs = performance.now(), }: { - samples: LivePreviewDiscSample[]; + samples: CandidateRingSample[]; normal: Cartesian3; maxSampleCount: number; timestampMs?: number; @@ -55,7 +55,7 @@ export const pushLivePreviewDiscSample = ({ } }; -export const getAveragedLivePreviewDiscNormal = ({ +export const getAveragedCandidateRingNormal = ({ samples, fallbackNormal, result, @@ -63,7 +63,7 @@ export const getAveragedLivePreviewDiscNormal = ({ maxSampleAgeMs, nowMs = performance.now(), }: { - samples: LivePreviewDiscSample[]; + samples: CandidateRingSample[]; fallbackNormal: Cartesian3; result: Cartesian3; epsilonSquared: number; diff --git a/libraries/mapping/annotations/core/src/lib/utils/derivedPolylinePaths.ts b/libraries/mapping/annotations/core/src/lib/utils/derivedPolylinePaths.ts new file mode 100644 index 0000000000..3383e3037d --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/derivedPolylinePaths.ts @@ -0,0 +1,148 @@ +import { + Cartesian3, + getDegreesFromCartesian, + getPositionWithVerticalOffsetFromAnchor, +} from "@carma/cesium"; + +import { getDistanceRelationId } from "./measurementRelations"; +import { ANNOTATION_TYPE_POLYLINE } from "../types/annotationTypes"; +import { + isPointAnnotationEntry, + type AnnotationCollection, +} from "../types/annotationCesiumTypes"; +import type { DerivedPolylinePath } from "../types/derivedPolylinePath"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; + +const getPolylineComputationPointPositionMap = ( + annotations: AnnotationCollection, + useOffsetAnchors: boolean +) => { + const map = new Map(); + + annotations.forEach((measurement) => { + if (!isPointAnnotationEntry(measurement)) { + return; + } + + if (useOffsetAnchors && measurement.verticalOffsetAnchorECEF) { + map.set( + measurement.id, + new Cartesian3( + measurement.verticalOffsetAnchorECEF.x, + measurement.verticalOffsetAnchorECEF.y, + measurement.verticalOffsetAnchorECEF.z + ) + ); + return; + } + + map.set(measurement.id, measurement.geometryECEF); + }); + + return map; +}; + +export const buildDerivedPolylinePath = ( + group: NodeChainAnnotation, + pointById: ReadonlyMap, + verticalOffsetMeters: number = 0 +): DerivedPolylinePath | null => { + if (group.closed || group.nodeIds.length < 2) { + return null; + } + + const applyGroupVerticalOffset = (position: Cartesian3) => + Math.abs(verticalOffsetMeters) > 1e-9 + ? getPositionWithVerticalOffsetFromAnchor(position, verticalOffsetMeters) + : position; + + const segmentLengthsMeters: number[] = []; + const segmentLengthsCumulativeMeters: number[] = [0]; + const nodeHeightsMeters = group.nodeIds.map((pointId) => { + const point = pointById.get(pointId); + if (!point) { + return 0; + } + + const pointWGS84 = getDegreesFromCartesian(applyGroupVerticalOffset(point)); + return pointWGS84.altitude ?? 0; + }); + let totalLengthMeters = 0; + const edgeRelationIds: string[] = []; + + for (let index = 0; index < group.nodeIds.length - 1; index += 1) { + const startId = group.nodeIds[index]; + const endId = group.nodeIds[index + 1]; + if (!startId || !endId) { + continue; + } + + const start = pointById.get(startId); + const end = pointById.get(endId); + if (!start || !end) { + continue; + } + + const segmentLength = Cartesian3.distance( + applyGroupVerticalOffset(start), + applyGroupVerticalOffset(end) + ); + segmentLengthsMeters.push(segmentLength); + totalLengthMeters += segmentLength; + segmentLengthsCumulativeMeters.push(totalLengthMeters); + edgeRelationIds.push(getDistanceRelationId(startId, endId)); + } + + if (segmentLengthsMeters.length === 0) { + return null; + } + + const hasStartPoint = + !!group.distanceMeasurementStartPointId && + group.nodeIds.includes(group.distanceMeasurementStartPointId); + + return { + id: group.id, + name: group.name, + nodeIds: [...group.nodeIds], + edgeRelationIds, + distanceMeasurementStartPointId: hasStartPoint + ? group.distanceMeasurementStartPointId ?? null + : group.nodeIds[0] ?? null, + nodeHeightsMeters, + segmentLengthsMeters, + segmentLengthsCumulativeMeters, + totalLengthMeters, + }; +}; + +export const buildDerivedPolylinePaths = ({ + annotations, + nodeChainAnnotations, + defaultVerticalOffsetMeters, + useOffsetAnchors, +}: { + annotations: AnnotationCollection; + nodeChainAnnotations: readonly NodeChainAnnotation[]; + defaultVerticalOffsetMeters: number; + useOffsetAnchors: boolean; +}): DerivedPolylinePath[] => { + const pointById = getPolylineComputationPointPositionMap( + annotations, + useOffsetAnchors + ); + + return nodeChainAnnotations + .filter( + (group): group is NodeChainAnnotation => + group.type === ANNOTATION_TYPE_POLYLINE + ) + .map((group) => + buildDerivedPolylinePath( + group, + pointById, + group.verticalOffsetMeters ?? defaultVerticalOffsetMeters + ) + ) + .filter((polyline): polyline is DerivedPolylinePath => Boolean(polyline)); +}; diff --git a/libraries/mapping/annotations/core/src/lib/utils/distanceRelationDisplay.ts b/libraries/mapping/annotations/core/src/lib/utils/distanceRelationDisplay.ts new file mode 100644 index 0000000000..7290a8077e --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/distanceRelationDisplay.ts @@ -0,0 +1,11 @@ +import type { PointDistanceRelation } from "../types/distanceRelation"; + +export const hasAnyVisibleDistanceRelationLine = ( + relation: PointDistanceRelation +) => + Boolean( + relation.showDirectLine || + relation.showVerticalLine || + relation.showHorizontalLine || + relation.showComponentLines + ); diff --git a/libraries/mapping/annotations/core/src/lib/utils/measurementRelations.ts b/libraries/mapping/annotations/core/src/lib/utils/measurementRelations.ts new file mode 100644 index 0000000000..f6f3c48f4b --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/measurementRelations.ts @@ -0,0 +1,94 @@ +import type { + AnnotationGeometryEdge, + PolygonAnnotationVertex, +} from "../types/annotationPersistenceTypes"; +import type { PointDistanceRelation } from "../types/distanceRelation"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; + +export const getMeasurementEdgeId = (pointAId: string, pointBId: string) => { + const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); + return `edge:${left}:${right}`; +}; + +export const getDistanceRelationId = (pointAId: string, pointBId: string) => { + const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); + return `distance-relation:${left}:${right}`; +}; + +export const withDistanceRelationEdgeId = ( + relation: PointDistanceRelation +): PointDistanceRelation => ({ + ...relation, + edgeId: + relation.edgeId && relation.edgeId.length > 0 + ? relation.edgeId + : getMeasurementEdgeId(relation.pointAId, relation.pointBId), +}); + +export const isSameDistanceRelationPair = ( + relation: PointDistanceRelation, + pointAId: string, + pointBId: string +) => + (relation.pointAId === pointAId && relation.pointBId === pointBId) || + (relation.pointAId === pointBId && relation.pointBId === pointAId); + +export const buildPolygonGroupVertexTable = ( + groups: readonly NodeChainAnnotation[] +): PolygonAnnotationVertex[] => + groups.flatMap((group) => + group.nodeIds.map((pointId, order) => ({ + id: `${group.id}:${order}`, + groupId: group.id, + pointId, + order, + })) + ); + +export const buildGeometryEdgeTable = ( + relations: readonly PointDistanceRelation[], + groups: readonly NodeChainAnnotation[] +): AnnotationGeometryEdge[] => { + const byEdgeId = new Map(); + + relations.forEach((relation) => { + const edgeId = + relation.edgeId && relation.edgeId.length > 0 + ? relation.edgeId + : getMeasurementEdgeId(relation.pointAId, relation.pointBId); + if (!byEdgeId.has(edgeId)) { + byEdgeId.set(edgeId, { + id: edgeId, + pointAId: relation.pointAId, + pointBId: relation.pointBId, + }); + } + }); + + groups.forEach((group) => { + const nodeIds = group.nodeIds; + if (nodeIds.length < 2) return; + + for (let index = 0; index < nodeIds.length - 1; index += 1) { + const pointAId = nodeIds[index]; + const pointBId = nodeIds[index + 1]; + if (!pointAId || !pointBId) continue; + const edgeId = getMeasurementEdgeId(pointAId, pointBId); + if (!byEdgeId.has(edgeId)) { + byEdgeId.set(edgeId, { id: edgeId, pointAId, pointBId }); + } + } + + if (group.closed && nodeIds.length >= 3) { + const pointAId = nodeIds[nodeIds.length - 1]; + const pointBId = nodeIds[0]; + if (!pointAId || !pointBId) return; + const edgeId = getMeasurementEdgeId(pointAId, pointBId); + if (!byEdgeId.has(edgeId)) { + byEdgeId.set(edgeId, { id: edgeId, pointAId, pointBId }); + } + } + }); + + return Array.from(byEdgeId.values()); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/utils/planarPolygon.ts b/libraries/mapping/annotations/core/src/lib/utils/planarGeometry.ts similarity index 93% rename from libraries/mapping/annotations/provider/src/lib/context/utils/planarPolygon.ts rename to libraries/mapping/annotations/core/src/lib/utils/planarGeometry.ts index cf988f311b..fe3aaa6a50 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/utils/planarPolygon.ts +++ b/libraries/mapping/annotations/core/src/lib/utils/planarGeometry.ts @@ -9,12 +9,15 @@ import { normalizeDirection, } from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, +} from "../types/annotationTypes"; import type { - PlanarPolygonGroup, + NodeChainAnnotation, PlanarPolygonLocalFrame, PlanarPolygonPlane, -} from "@carma-mapping/annotations/core"; -import type { PolygonSurfacePreset } from "@carma-mapping/annotations/core"; +} from "../types/annotationTypes"; const EPSILON = 1e-8; @@ -281,26 +284,29 @@ export const computeVerticalityDeg = (plane: PlanarPolygonPlane): number => { return (Math.acos(dot) * 180) / Math.PI; }; -export const classifySurfaceType = ( +export const classifyPlanarPolygonType = ( verticalityDeg: number -): PolygonSurfacePreset => (verticalityDeg > 85 ? "facade" : "roof"); +): typeof ANNOTATION_TYPE_AREA_PLANAR | typeof ANNOTATION_TYPE_AREA_VERTICAL => + verticalityDeg > 85 + ? ANNOTATION_TYPE_AREA_VERTICAL + : ANNOTATION_TYPE_AREA_PLANAR; export const buildEdgeRelationIdsForPolygon = ( - vertexPointIds: string[], + nodeIds: string[], closed: boolean, getDistanceRelationId: (left: string, right: string) => string ): string[] => { - if (vertexPointIds.length < 2) return []; + if (nodeIds.length < 2) return []; const edgeIds: string[] = []; - for (let index = 0; index < vertexPointIds.length - 1; index += 1) { - const start = vertexPointIds[index]; - const end = vertexPointIds[index + 1]; + for (let index = 0; index < nodeIds.length - 1; index += 1) { + const start = nodeIds[index]; + const end = nodeIds[index + 1]; if (!start || !end) continue; edgeIds.push(getDistanceRelationId(start, end)); } - if (closed && vertexPointIds.length >= 3) { - const first = vertexPointIds[0]; - const last = vertexPointIds[vertexPointIds.length - 1]; + if (closed && nodeIds.length >= 3) { + const first = nodeIds[0]; + const last = nodeIds[nodeIds.length - 1]; if (first && last) { edgeIds.push(getDistanceRelationId(last, first)); } @@ -432,12 +438,12 @@ const deriveVerticalPolygonLocalFrame = ( }; export const computePolygonGroupDerivedData = ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, pointById: Map, options?: { preferredFacingPositionECEF?: Cartesian3 | null; } -): PlanarPolygonGroup => { +): NodeChainAnnotation => { const preferredFacingPositionECEF = options?.preferredFacingPositionECEF ?? null; const computePerimeterMeters = () => { @@ -459,7 +465,7 @@ export const computePolygonGroupDerivedData = ( return perimeterMeters; }; - const vertices = group.vertexPointIds + const vertices = group.nodeIds .map((id) => pointById.get(id)) .filter((value): value is Cartesian3 => Boolean(value)); const perimeterMeters = computePerimeterMeters(); @@ -469,7 +475,6 @@ export const computePolygonGroupDerivedData = ( perimeterMeters, areaSquareMeters: 0, verticalityDeg: group.verticalityDeg ?? 0, - surfaceType: group.surfaceType ?? "roof", }; } @@ -483,7 +488,6 @@ export const computePolygonGroupDerivedData = ( perimeterMeters, areaSquareMeters: 0, verticalityDeg: group.verticalityDeg ?? 0, - surfaceType: group.surfaceType ?? "roof", }; } @@ -494,9 +498,8 @@ export const computePolygonGroupDerivedData = ( : 0; const verticalityDeg = computeVerticalityDeg(plane); const bearingDeg = computeBearingDegFromPlaneNormal(plane); - const surfaceType = group.surfaceType ?? classifySurfaceType(verticalityDeg); const planarPolygonLocalFrame = - surfaceType === "facade" + group.type === ANNOTATION_TYPE_AREA_VERTICAL ? deriveVerticalPolygonLocalFrame( vertices, plane, @@ -512,6 +515,5 @@ export const computePolygonGroupDerivedData = ( areaSquareMeters, verticalityDeg, bearingDeg, - surfaceType, }; }; diff --git a/libraries/mapping/annotations/core/src/lib/utils/planarMeasurementGroups.ts b/libraries/mapping/annotations/core/src/lib/utils/planarMeasurementGroups.ts new file mode 100644 index 0000000000..8108f2a8f8 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/planarMeasurementGroups.ts @@ -0,0 +1,58 @@ +import { ANNOTATION_TYPE_POLYLINE } from "../types/annotationTypes"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; + +export const getConnectedOpenPolylineGroupIds = ( + groups: readonly NodeChainAnnotation[], + startGroupId: string +) => { + const openGroups = groups.filter( + (group): group is NodeChainAnnotation => + !group.closed && group.type === ANNOTATION_TYPE_POLYLINE + ); + const startGroup = openGroups.find((group) => group.id === startGroupId); + if (!startGroup) { + return new Set(); + } + + const groupById = new Map(openGroups.map((group) => [group.id, group])); + const nodeIdsByGroupId = new Map( + openGroups.map((group) => [group.id, new Set(group.nodeIds)]) + ); + + const connectedIds = new Set(); + const queue: string[] = [startGroupId]; + + while (queue.length > 0) { + const groupId = queue.shift(); + if (!groupId || connectedIds.has(groupId)) { + continue; + } + + const currentNodes = nodeIdsByGroupId.get(groupId); + if (!currentNodes) { + continue; + } + + connectedIds.add(groupId); + + groupById.forEach((candidateGroup, candidateId) => { + if (connectedIds.has(candidateId)) { + return; + } + + const candidateNodes = nodeIdsByGroupId.get(candidateId); + if (!candidateNodes) { + return; + } + + const sharesNode = Array.from(currentNodes).some((nodeId) => + candidateNodes.has(nodeId) + ); + if (sharesNode) { + queue.push(candidateGroup.id); + } + }); + } + + return connectedIds; +}; diff --git a/libraries/mapping/annotations/core/src/lib/utils/screenViewport.ts b/libraries/mapping/annotations/core/src/lib/utils/screenViewport.ts new file mode 100644 index 0000000000..3c3243d429 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/utils/screenViewport.ts @@ -0,0 +1,28 @@ +import type { CssPixelPosition } from "@carma/units/types"; + +const isFiniteCssPixelPosition = (position: CssPixelPosition): boolean => + Number.isFinite(position.x) && Number.isFinite(position.y); + +export const isPointInViewport = ( + screenPosition: CssPixelPosition, + viewportWidth: number, + viewportHeight: number, + paddingHorizontal: number = 0, + paddingVertical?: number +): boolean => { + if ( + !isFiniteCssPixelPosition(screenPosition) || + !Number.isFinite(viewportWidth) || + !Number.isFinite(viewportHeight) + ) { + return false; + } + + const verticalPadding = paddingVertical ?? paddingHorizontal; + return ( + screenPosition.x >= -paddingHorizontal && + screenPosition.x <= viewportWidth + paddingHorizontal && + screenPosition.y >= -verticalPadding && + screenPosition.y <= viewportHeight + verticalPadding + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/utils/selectionGroupMove.ts b/libraries/mapping/annotations/core/src/lib/utils/selectionGroupMove.ts similarity index 89% rename from libraries/mapping/annotations/provider/src/lib/context/utils/selectionGroupMove.ts rename to libraries/mapping/annotations/core/src/lib/utils/selectionGroupMove.ts index 15346894c6..f51c1f19a9 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/utils/selectionGroupMove.ts +++ b/libraries/mapping/annotations/core/src/lib/utils/selectionGroupMove.ts @@ -4,19 +4,18 @@ import { getEllipsoidalAltitudeOrZero, } from "@carma/cesium"; -import { - isPointAnnotationEntry, - type AnnotationCollection, - type PointAnnotationEntry, -} from "@carma-mapping/annotations/core"; +import { isPointAnnotationEntry } from "../types/annotationCesiumTypes"; +import type { + AnnotationCollection, + PointAnnotationEntry, +} from "../types/annotationCesiumTypes"; const MOVE_DELTA_EPSILON = 1e-12; export const getSelectedPointIds = ( - selectedMeasurementIds: string[], - pointMeasurementIds: Set -): string[] => - selectedMeasurementIds.filter((id) => pointMeasurementIds.has(id)); + selectedAnnotationIds: string[], + pointIds: ReadonlySet +): string[] => selectedAnnotationIds.filter((id) => pointIds.has(id)); export const shouldMoveSelectionAsGroup = ( pointId: string, diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelTextBuilders.ts b/libraries/mapping/annotations/core/src/lib/visualization/area-labels/areaLabelTextBuilders.ts similarity index 83% rename from libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelTextBuilders.ts rename to libraries/mapping/annotations/core/src/lib/visualization/area-labels/areaLabelTextBuilders.ts index 00747d97e4..704285daf4 100644 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelTextBuilders.ts +++ b/libraries/mapping/annotations/core/src/lib/visualization/area-labels/areaLabelTextBuilders.ts @@ -1,7 +1,12 @@ -import { type PlanarPolygonGroup } from "../../types/planarTypes"; +import { type NodeChainAnnotation } from "../../types/annotationTypes"; import type { Cartesian3Json } from "@carma/cesium"; +import { ANNOTATION_TYPE_AREA_VERTICAL } from "../../types/annotationTypes"; import { formatAreaAdaptive } from "../../utils/displayFormatting"; -import { type AreaLabelText } from "./areaLabelVisualizer.types"; + +export type AreaLabelText = { + primaryText: string; + secondaryText?: string | null; +}; const computePolygonAreaFromVertices = ( vertices: ReadonlyArray @@ -43,7 +48,7 @@ const computePolygonAreaFromVertices = ( }; const resolveDisplayedAreaSquareMeters = ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, previewAreaSquareMeters: number ) => { if (!group.closed) { @@ -57,7 +62,7 @@ const resolveDisplayedAreaSquareMeters = ( }; const buildAreaLabelText = ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, vertices: Cartesian3Json[] ): AreaLabelText => { const previewAreaSquareMeters = computePolygonAreaFromVertices(vertices); @@ -65,9 +70,9 @@ const buildAreaLabelText = ( group, previewAreaSquareMeters ); - const isFacadeSurface = (group.surfaceType ?? "roof") === "facade"; + const isVerticalSurface = group.type === ANNOTATION_TYPE_AREA_VERTICAL; const showPreviewAreaSecondary = - !isFacadeSurface && + !isVerticalSurface && planarArea > 0 && previewAreaSquareMeters < planarArea * 0.99; @@ -80,17 +85,17 @@ const buildAreaLabelText = ( }; export const buildGroundAreaLabelText = ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, vertices: Cartesian3Json[] ): AreaLabelText => buildAreaLabelText(group, vertices); export const buildPlanarAreaLabelText = ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, vertices: Cartesian3Json[] ): AreaLabelText => buildAreaLabelText(group, vertices); export const buildVerticalAreaLabelText = ( - group: PlanarPolygonGroup + group: NodeChainAnnotation ): AreaLabelText => ({ primaryText: formatAreaAdaptive(Math.max(0, group.areaSquareMeters ?? 0)), secondaryText: null, diff --git a/libraries/mapping/annotations/core/src/lib/visualization/area-labels/index.ts b/libraries/mapping/annotations/core/src/lib/visualization/area-labels/index.ts new file mode 100644 index 0000000000..751b98fd5f --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/area-labels/index.ts @@ -0,0 +1 @@ +export * from "./areaLabelTextBuilders"; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/distance/distanceRelationLabel.types.ts b/libraries/mapping/annotations/core/src/lib/visualization/distance/distanceRelationLabel.types.ts similarity index 100% rename from libraries/mapping/annotations/core/src/lib/visualizers/distance/distanceRelationLabel.types.ts rename to libraries/mapping/annotations/core/src/lib/visualization/distance/distanceRelationLabel.types.ts diff --git a/libraries/mapping/annotations/core/src/lib/visualization/distance/distanceRelationLabelDisplay.ts b/libraries/mapping/annotations/core/src/lib/visualization/distance/distanceRelationLabelDisplay.ts new file mode 100644 index 0000000000..a89e38288a --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/distance/distanceRelationLabelDisplay.ts @@ -0,0 +1,211 @@ +import type { PointDistanceRelation } from "../../types/distanceRelation"; +import { formatNumber } from "../../utils/displayFormatting"; +import { REFERENCE_LINE_EPSILON_METERS } from "../../utils/distanceVisualization"; +import type { + DirectLineLabelMode, + ReferenceLineLabelKind, +} from "./distanceRelationLabel.types"; + +export type DistanceRelationLabelDisplay = { + directLabelMode: DirectLineLabelMode; + directLabelDistanceMeters: number; + showDirectLabel: boolean; + showVerticalLabel: boolean; + showHorizontalLabel: boolean; + directLabelMinLineLengthPx: number; + componentLabelMinLineLengthPx: number; +}; + +export type DistanceRelationEdgeLabelOverlay = { + labelText?: string; + labelColor: string; + labelStroke: string; + labelFontSize: number; + labelFontFamily: string; + labelFontWeight: string; + labelMinLineLengthPx: number; + labelRotationMode?: "clockwise"; + labelOffsetPx?: number; + labelDominantBaseline?: "alphabetic"; +}; + +export type DistanceRelationEdgeLabelStyleOverrides = Partial< + Record< + ReferenceLineLabelKind, + Partial< + Omit< + DistanceRelationEdgeLabelOverlay, + "labelText" | "labelMinLineLengthPx" + > + > + > +>; + +const BASE_EDGE_LABEL_OVERLAY = { + labelColor: "#000000", + labelStroke: "rgba(255, 255, 255, 0.95)", + labelFontSize: 12, + labelFontFamily: "Arial, sans-serif", + labelFontWeight: "400", +} satisfies Omit< + DistanceRelationEdgeLabelOverlay, + "labelText" | "labelMinLineLengthPx" +>; + +const VERTICAL_EDGE_LABEL_OVERLAY = { + ...BASE_EDGE_LABEL_OVERLAY, + labelRotationMode: "clockwise", + labelOffsetPx: 8, + labelDominantBaseline: "alphabetic", +} satisfies Omit< + DistanceRelationEdgeLabelOverlay, + "labelText" | "labelMinLineLengthPx" +>; + +export const getNextDirectLineLabelMode = ( + currentMode: DirectLineLabelMode +): DirectLineLabelMode => { + if (currentMode === "segment") { + return "none"; + } + + return "segment"; +}; + +export const resolveDistanceRelationLabelDisplay = ({ + relation, + segmentDistanceMeters, + cumulativeDistanceMeters, + verticalDistanceMeters, + horizontalDistanceMeters, + lineLabelMinDistancePx, + isPolygonEdgeRelation, + isSelectedOrActiveEdgeRelation, + isSharedPlanarPolygonEdge, + isDuplicateVerticalOpposingEdgeRelation, +}: { + relation: PointDistanceRelation; + segmentDistanceMeters: number; + cumulativeDistanceMeters: number; + verticalDistanceMeters: number; + horizontalDistanceMeters: number; + lineLabelMinDistancePx: number; + isPolygonEdgeRelation: boolean; + isSelectedOrActiveEdgeRelation: boolean; + isSharedPlanarPolygonEdge: boolean; + isDuplicateVerticalOpposingEdgeRelation: boolean; +}): DistanceRelationLabelDisplay => { + const forceComponentLabelsForSelectedOrActivePolylineEdges = + isPolygonEdgeRelation && isSelectedOrActiveEdgeRelation; + const showVerticalLabel = + (forceComponentLabelsForSelectedOrActivePolylineEdges || + (relation.labelVisibilityByKind?.vertical ?? true)) && + verticalDistanceMeters > REFERENCE_LINE_EPSILON_METERS; + const showHorizontalLabel = + (forceComponentLabelsForSelectedOrActivePolylineEdges || + (relation.labelVisibilityByKind?.horizontal ?? true)) && + horizontalDistanceMeters > REFERENCE_LINE_EPSILON_METERS; + + const directLabelMode = forceComponentLabelsForSelectedOrActivePolylineEdges + ? "segment" + : relation.directLabelMode ?? "segment"; + const directLabelVisibilityEnabled = + forceComponentLabelsForSelectedOrActivePolylineEdges + ? true + : relation.labelVisibilityByKind?.direct ?? true; + const shouldShowPolygonEdgeLengthLabel = + !isPolygonEdgeRelation || + forceComponentLabelsForSelectedOrActivePolylineEdges || + isSelectedOrActiveEdgeRelation; + const showDirectLabel = + directLabelVisibilityEnabled && + directLabelMode !== "none" && + !isSharedPlanarPolygonEdge && + shouldShowPolygonEdgeLengthLabel && + !isDuplicateVerticalOpposingEdgeRelation; + + return { + directLabelMode, + directLabelDistanceMeters: + directLabelMode === "cumulative" + ? cumulativeDistanceMeters + : segmentDistanceMeters, + showDirectLabel, + showVerticalLabel, + showHorizontalLabel, + directLabelMinLineLengthPx: + forceComponentLabelsForSelectedOrActivePolylineEdges + ? 0 + : lineLabelMinDistancePx, + componentLabelMinLineLengthPx: + forceComponentLabelsForSelectedOrActivePolylineEdges + ? 0 + : lineLabelMinDistancePx, + }; +}; + +export const buildDistanceRelationEdgeLabelOverlays = ({ + relation, + segmentDistanceMeters, + cumulativeDistanceMeters, + verticalDistanceMeters, + horizontalDistanceMeters, + lineLabelMinDistancePx, + isPolygonEdgeRelation, + isSelectedOrActiveEdgeRelation, + isSharedPlanarPolygonEdge, + isDuplicateVerticalOpposingEdgeRelation, + styleOverridesByKind, +}: { + relation: PointDistanceRelation; + segmentDistanceMeters: number; + cumulativeDistanceMeters: number; + verticalDistanceMeters: number; + horizontalDistanceMeters: number; + lineLabelMinDistancePx: number; + isPolygonEdgeRelation: boolean; + isSelectedOrActiveEdgeRelation: boolean; + isSharedPlanarPolygonEdge: boolean; + isDuplicateVerticalOpposingEdgeRelation: boolean; + styleOverridesByKind?: DistanceRelationEdgeLabelStyleOverrides; +}): Record => { + const labelDisplay = resolveDistanceRelationLabelDisplay({ + relation, + segmentDistanceMeters, + cumulativeDistanceMeters, + verticalDistanceMeters, + horizontalDistanceMeters, + lineLabelMinDistancePx, + isPolygonEdgeRelation, + isSelectedOrActiveEdgeRelation, + isSharedPlanarPolygonEdge, + isDuplicateVerticalOpposingEdgeRelation, + }); + + return { + direct: { + ...BASE_EDGE_LABEL_OVERLAY, + ...(styleOverridesByKind?.direct ?? {}), + labelText: labelDisplay.showDirectLabel + ? `${formatNumber(labelDisplay.directLabelDistanceMeters)} m` + : undefined, + labelMinLineLengthPx: labelDisplay.directLabelMinLineLengthPx, + }, + vertical: { + ...VERTICAL_EDGE_LABEL_OVERLAY, + ...(styleOverridesByKind?.vertical ?? {}), + labelText: labelDisplay.showVerticalLabel + ? `${formatNumber(verticalDistanceMeters)} m` + : undefined, + labelMinLineLengthPx: labelDisplay.componentLabelMinLineLengthPx, + }, + horizontal: { + ...BASE_EDGE_LABEL_OVERLAY, + ...(styleOverridesByKind?.horizontal ?? {}), + labelText: labelDisplay.showHorizontalLabel + ? `${formatNumber(horizontalDistanceMeters)} m` + : undefined, + labelMinLineLengthPx: labelDisplay.componentLabelMinLineLengthPx, + }, + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/visualization/index.ts b/libraries/mapping/annotations/core/src/lib/visualization/index.ts new file mode 100644 index 0000000000..86f11364ea --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/index.ts @@ -0,0 +1,7 @@ +export * from "./previewGeometry.types"; +export * from "./verticalRectangleGeometry"; +export * from "./polygonPreviewGroups"; +export * from "./polylinePreviewGeometry"; +export * from "./area-labels"; +export * from "./distance/distanceRelationLabel.types"; +export * from "./distance/distanceRelationLabelDisplay"; diff --git a/libraries/mapping/annotations/core/src/lib/visualization/polygonPreviewGroups.ts b/libraries/mapping/annotations/core/src/lib/visualization/polygonPreviewGroups.ts new file mode 100644 index 0000000000..83c27469da --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/polygonPreviewGroups.ts @@ -0,0 +1,202 @@ +import { Cartesian3 } from "@carma/cesium"; + +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_POLYLINE, +} from "../types/annotationTypes"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; +import { buildVerticalRectangleCornerFromDiagonal } from "./verticalRectangleGeometry"; +import type { + VerticalPreviewCornerMarker, + VerticalPreviewEdgeSegment, + PolygonPreviewBuildParams, + PolygonPreviewGroup, + PolygonPreviewGroupsBySurface, +} from "./previewGeometry.types"; + +export const buildPolygonPreviewGroups = ({ + nodeChainAnnotations, + pointsById, + verticalRectanglePreviewOppositeByGroupId, + activeNodeChainAnnotationId, + candidateConnection, +}: PolygonPreviewBuildParams): PolygonPreviewGroup[] => + nodeChainAnnotations + .map((group) => { + const type = group.type; + if (type === ANNOTATION_TYPE_POLYLINE) { + return null; + } + + if (group.closed && group.nodeIds.length >= 3) { + const vertexPoints = group.nodeIds + .map((pointId) => pointsById.get(pointId)?.geometryECEF) + .filter((point): point is Cartesian3 => Boolean(point)); + return { + group, + vertexPoints, + }; + } + + if ( + !group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + group.nodeIds.length === 1 + ) { + const firstNodeId = group.nodeIds[0] ?? null; + const firstNodePosition = firstNodeId + ? pointsById.get(firstNodeId)?.geometryECEF + : null; + const previewOppositeCorner = + verticalRectanglePreviewOppositeByGroupId?.[group.id]; + if (!firstNodePosition || !previewOppositeCorner) { + return null; + } + + const verticalCorners = buildVerticalRectangleCornerFromDiagonal( + firstNodePosition, + previewOppositeCorner + ); + if (!verticalCorners) { + return null; + } + + return { + group, + vertexPoints: [ + firstNodePosition, + verticalCorners.adjacentHorizontalCorner, + previewOppositeCorner, + verticalCorners.adjacentVerticalCorner, + ], + }; + } + + if ( + !group.closed && + group.id === activeNodeChainAnnotationId && + (group.type === ANNOTATION_TYPE_AREA_GROUND || + group.type === ANNOTATION_TYPE_AREA_PLANAR) && + group.nodeIds.length >= 2 + ) { + const baseVertexPoints = group.nodeIds + .map((pointId) => pointsById.get(pointId)?.geometryECEF) + .filter((point): point is Cartesian3 => Boolean(point)); + if (baseVertexPoints.length < 2) { + return null; + } + + const previewTargetPoint = candidateConnection?.showDirectLine + ? candidateConnection.targetPointECEF + : null; + const lastBaseVertex = baseVertexPoints[baseVertexPoints.length - 1]; + const previewIncludesHoveredPoint = Boolean( + previewTargetPoint && + lastBaseVertex && + Cartesian3.distanceSquared(lastBaseVertex, previewTargetPoint) > + 1e-6 + ); + const vertexPoints = previewIncludesHoveredPoint + ? [...baseVertexPoints, Cartesian3.clone(previewTargetPoint)] + : baseVertexPoints; + + return vertexPoints.length >= 3 + ? { + group, + vertexPoints, + } + : null; + } + + return null; + }) + .filter( + ( + previewGroup + ): previewGroup is { + group: NodeChainAnnotation; + vertexPoints: Cartesian3[]; + } => Boolean(previewGroup && previewGroup.vertexPoints.length >= 3) + ); + +export const buildGroundPolygonPreviewGroups = ( + params: PolygonPreviewBuildParams +): PolygonPreviewGroup[] => + buildPolygonPreviewGroups(params).filter( + (previewGroup) => previewGroup.group.type === ANNOTATION_TYPE_AREA_GROUND + ); + +export const buildVerticalPolygonPreviewGroups = ( + params: PolygonPreviewBuildParams +): PolygonPreviewGroup[] => + buildPolygonPreviewGroups(params).filter( + (previewGroup) => previewGroup.group.type === ANNOTATION_TYPE_AREA_VERTICAL + ); + +export const buildPlanarPolygonPreviewGroups = ( + params: PolygonPreviewBuildParams +): PolygonPreviewGroup[] => + buildPolygonPreviewGroups(params).filter( + (previewGroup) => previewGroup.group.type === ANNOTATION_TYPE_AREA_PLANAR + ); + +export const buildPolygonPreviewGroupsBySurface = ( + params: PolygonPreviewBuildParams +): PolygonPreviewGroupsBySurface => ({ + groundPolygonPreviewGroups: buildGroundPolygonPreviewGroups(params), + verticalPolygonPreviewGroups: buildVerticalPolygonPreviewGroups(params), + planarPolygonPreviewGroups: buildPlanarPolygonPreviewGroups(params), +}); + +export const buildVerticalPreviewEdgeSegments = ( + polygonPreviewGroups: PolygonPreviewGroup[] +): VerticalPreviewEdgeSegment[] => + polygonPreviewGroups + .filter( + ({ group, vertexPoints }) => + !group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + vertexPoints.length === 4 + ) + .flatMap(({ group, vertexPoints }) => { + const segments: VerticalPreviewEdgeSegment[] = []; + for (let index = 0; index < vertexPoints.length; index += 1) { + const start = vertexPoints[index]; + const end = vertexPoints[(index + 1) % vertexPoints.length]; + if (!start || !end) continue; + segments.push({ + id: `${group.id}:${index}`, + start, + end, + }); + } + return segments; + }); + +export const buildVerticalPreviewCornerMarkers = ( + polygonPreviewGroups: PolygonPreviewGroup[] +): VerticalPreviewCornerMarker[] => + polygonPreviewGroups + .filter( + ({ group, vertexPoints }) => + !group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + vertexPoints.length === 4 + ) + .flatMap(({ group, vertexPoints }) => { + const horizontalCorner = vertexPoints[1]; + const verticalCorner = vertexPoints[3]; + if (!horizontalCorner || !verticalCorner) return []; + return [ + { + id: `${group.id}:horizontal`, + position: horizontalCorner, + }, + { + id: `${group.id}:vertical`, + position: verticalCorner, + }, + ]; + }); diff --git a/libraries/mapping/annotations/core/src/lib/visualization/polylinePreviewGeometry.ts b/libraries/mapping/annotations/core/src/lib/visualization/polylinePreviewGeometry.ts new file mode 100644 index 0000000000..cc83f0f349 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/polylinePreviewGeometry.ts @@ -0,0 +1,97 @@ +import { Cartesian3 } from "@carma/cesium"; + +import { ANNOTATION_TYPE_AREA_VERTICAL } from "../types/annotationTypes"; +import type { NodeChainAnnotation } from "../types/annotationTypes"; +import { buildVerticalRectangleCornerFromDiagonal } from "./verticalRectangleGeometry"; +import type { + VerticalPreviewCornerMarker, + VerticalPreviewEdgeSegment, + PointWithGeometryECEF, + PolylinePreviewMeasurement, +} from "./previewGeometry.types"; + +export const buildPolylinePreviewMeasurements = ({ + nodeChainAnnotations, + pointsById, + verticalRectanglePreviewOppositeByGroupId, +}: { + nodeChainAnnotations: NodeChainAnnotation[]; + pointsById: ReadonlyMap; + verticalRectanglePreviewOppositeByGroupId?: Readonly< + Record + >; +}): PolylinePreviewMeasurement[] => + nodeChainAnnotations + .map((group) => { + if (group.closed) return null; + if (group.type !== ANNOTATION_TYPE_AREA_VERTICAL) return null; + if (group.nodeIds.length !== 1) return null; + + const firstNodeId = group.nodeIds[0] ?? null; + const firstNodePosition = firstNodeId + ? pointsById.get(firstNodeId)?.geometryECEF + : null; + const previewOppositeCorner = + verticalRectanglePreviewOppositeByGroupId?.[group.id]; + if (!firstNodePosition || !previewOppositeCorner) { + return null; + } + + const verticalCorners = buildVerticalRectangleCornerFromDiagonal( + firstNodePosition, + previewOppositeCorner + ); + if (!verticalCorners) { + return null; + } + + return { + id: group.id, + vertexPoints: [ + firstNodePosition, + verticalCorners.adjacentHorizontalCorner, + previewOppositeCorner, + verticalCorners.adjacentVerticalCorner, + ], + }; + }) + .filter((measurement): measurement is PolylinePreviewMeasurement => + Boolean(measurement && measurement.vertexPoints.length === 4) + ); + +export const buildPolylinePreviewEdgeSegments = ( + polylineMeasurements: PolylinePreviewMeasurement[] +): VerticalPreviewEdgeSegment[] => + polylineMeasurements.flatMap(({ id, vertexPoints }) => { + const segments: VerticalPreviewEdgeSegment[] = []; + for (let index = 0; index < vertexPoints.length; index += 1) { + const start = vertexPoints[index]; + const end = vertexPoints[(index + 1) % vertexPoints.length]; + if (!start || !end) continue; + segments.push({ + id: `${id}:${index}`, + start, + end, + }); + } + return segments; + }); + +export const buildPolylinePreviewCornerMarkers = ( + polylineMeasurements: PolylinePreviewMeasurement[] +): VerticalPreviewCornerMarker[] => + polylineMeasurements.flatMap(({ id, vertexPoints }) => { + const horizontalCorner = vertexPoints[1]; + const verticalCorner = vertexPoints[3]; + if (!horizontalCorner || !verticalCorner) return []; + return [ + { + id: `${id}:horizontal`, + position: horizontalCorner, + }, + { + id: `${id}:vertical`, + position: verticalCorner, + }, + ]; + }); diff --git a/libraries/mapping/annotations/core/src/lib/visualization/previewGeometry.types.ts b/libraries/mapping/annotations/core/src/lib/visualization/previewGeometry.types.ts new file mode 100644 index 0000000000..b11e158340 --- /dev/null +++ b/libraries/mapping/annotations/core/src/lib/visualization/previewGeometry.types.ts @@ -0,0 +1,55 @@ +import { Cartesian3 } from "@carma/cesium"; + +import type { NodeChainAnnotation } from "../types/annotationTypes"; + +export const POLYGON_PREVIEW_STROKE = "rgba(255, 255, 255, 0.65)"; +export const POLYGON_PREVIEW_STROKE_WIDTH_PX = 1; + +export type PointWithGeometryECEF = { + geometryECEF: Cartesian3; +}; + +export type CandidateConnectionPreview = { + anchorPointECEF: Cartesian3; + targetPointECEF: Cartesian3; + showDirectLine: boolean; + showVerticalLine: boolean; + showHorizontalLine: boolean; +}; + +export type PolygonPreviewGroup = { + group: NodeChainAnnotation; + vertexPoints: Cartesian3[]; +}; + +export type VerticalPreviewEdgeSegment = { + id: string; + start: Cartesian3; + end: Cartesian3; +}; + +export type VerticalPreviewCornerMarker = { + id: string; + position: Cartesian3; +}; + +export type PolylinePreviewMeasurement = { + id: string; + vertexPoints: Cartesian3[]; +}; + +export type PolygonPreviewGroupsBySurface = { + groundPolygonPreviewGroups: PolygonPreviewGroup[]; + verticalPolygonPreviewGroups: PolygonPreviewGroup[]; + planarPolygonPreviewGroups: PolygonPreviewGroup[]; +}; + +export type PolygonPreviewBuildParams = { + nodeChainAnnotations: NodeChainAnnotation[]; + pointsById: ReadonlyMap; + verticalRectanglePreviewOppositeByGroupId?: Readonly< + Record + >; + activeNodeChainAnnotationId?: string | null; + candidateConnection?: CandidateConnectionPreview | null; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/utils/cartesianGeometry.ts b/libraries/mapping/annotations/core/src/lib/visualization/verticalRectangleGeometry.ts similarity index 52% rename from libraries/mapping/annotations/provider/src/lib/context/utils/cartesianGeometry.ts rename to libraries/mapping/annotations/core/src/lib/visualization/verticalRectangleGeometry.ts index f9e65e1f7c..271c487b0c 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/utils/cartesianGeometry.ts +++ b/libraries/mapping/annotations/core/src/lib/visualization/verticalRectangleGeometry.ts @@ -1,40 +1,57 @@ import { Cartesian3 } from "@carma/cesium"; -import { - createPlaneFromThreePoints, - projectPointOntoPlane, -} from "./planarPolygon"; +const VERTICAL_RECTANGLE_COMPONENT_EPSILON_METERS = 0.05; +const PLANE_NORMAL_EPSILON = 1e-8; -const FACADE_RECTANGLE_COMPONENT_EPSILON_METERS = 0.05; +type PreviewPlane = { + anchorECEF: Cartesian3; + normalECEF: Cartesian3; +}; -export type FacadeAutoCorner = { - id: string; - position: Cartesian3; +const createPlaneFromThreePoints = ( + a: Cartesian3, + b: Cartesian3, + c: Cartesian3 +): PreviewPlane | null => { + const ab = Cartesian3.subtract(b, a, new Cartesian3()); + const ac = Cartesian3.subtract(c, a, new Cartesian3()); + const normal = Cartesian3.cross(ab, ac, new Cartesian3()); + if (Cartesian3.magnitudeSquared(normal) <= PLANE_NORMAL_EPSILON) return null; + + return { + anchorECEF: Cartesian3.clone(a), + normalECEF: Cartesian3.normalize(normal, new Cartesian3()), + }; }; -export type FacadeAutoCloseRectangle = { - autoCorners: FacadeAutoCorner[]; - closedVertexPointIds: string[]; +const projectPointOntoPlane = ( + point: Cartesian3, + plane: PreviewPlane +): Cartesian3 => { + const delta = Cartesian3.subtract(point, plane.anchorECEF, new Cartesian3()); + const distanceAlongNormal = Cartesian3.dot(delta, plane.normalECEF); + return Cartesian3.subtract( + point, + Cartesian3.multiplyByScalar( + plane.normalECEF, + distanceAlongNormal, + new Cartesian3() + ), + new Cartesian3() + ); }; -export const getVerticalPolygonAxisRotationSuffix = ( - eastRotationDegVsEnuEast: number | null -): string => { - if (eastRotationDegVsEnuEast === null) { - return ""; - } - const roundedRotationDeg = Math.round(eastRotationDegVsEnuEast * 10) / 10; - const safeRoundedRotationDeg = Object.is(roundedRotationDeg, -0) - ? 0 - : roundedRotationDeg; - const signedRotation = - safeRoundedRotationDeg > 0 - ? `+${safeRoundedRotationDeg}` - : `${safeRoundedRotationDeg}`; - return ` (rot. ${signedRotation}° ggü. ENU-E)`; +export type VerticalAutoCorner = { + id: string; + position: Cartesian3; +}; + +export type VerticalAutoCloseRectangle = { + autoCorners: VerticalAutoCorner[]; + closedNodeIds: string[]; }; -export const buildFacadeRectangleCornerFromDiagonal = ( +export const buildVerticalRectangleCornerFromDiagonal = ( firstCorner: Cartesian3, oppositeCorner: Cartesian3 ) => { @@ -59,8 +76,8 @@ export const buildFacadeRectangleCornerFromDiagonal = ( const verticalAbsoluteMeters = Math.abs(verticalMeters); if ( - horizontalMeters < FACADE_RECTANGLE_COMPONENT_EPSILON_METERS || - verticalAbsoluteMeters < FACADE_RECTANGLE_COMPONENT_EPSILON_METERS + horizontalMeters < VERTICAL_RECTANGLE_COMPONENT_EPSILON_METERS || + verticalAbsoluteMeters < VERTICAL_RECTANGLE_COMPONENT_EPSILON_METERS ) { return null; } @@ -83,72 +100,86 @@ export const buildFacadeRectangleCornerFromDiagonal = ( adjacentHorizontalCorner ); - const enforcedAdjacentHorizontalCorner = verticalPlane - ? projectPointOntoPlane(adjacentHorizontalCorner, verticalPlane) - : adjacentHorizontalCorner; - const enforcedAdjacentVerticalCorner = verticalPlane - ? projectPointOntoPlane(adjacentVerticalCorner, verticalPlane) - : adjacentVerticalCorner; - return { - adjacentHorizontalCorner: enforcedAdjacentHorizontalCorner, - adjacentVerticalCorner: enforcedAdjacentVerticalCorner, + adjacentHorizontalCorner: verticalPlane + ? projectPointOntoPlane(adjacentHorizontalCorner, verticalPlane) + : adjacentHorizontalCorner, + adjacentVerticalCorner: verticalPlane + ? projectPointOntoPlane(adjacentVerticalCorner, verticalPlane) + : adjacentVerticalCorner, }; }; -export const getFacadeRectanglePreviewAreaSquareMeters = ( +export const getVerticalPolygonAxisRotationSuffix = ( + eastRotationDegVsEnuEast: number | null +): string => { + if (eastRotationDegVsEnuEast === null) { + return ""; + } + const roundedRotationDeg = Math.round(eastRotationDegVsEnuEast * 10) / 10; + const safeRoundedRotationDeg = Object.is(roundedRotationDeg, -0) + ? 0 + : roundedRotationDeg; + const signedRotation = + safeRoundedRotationDeg > 0 + ? `+${safeRoundedRotationDeg}` + : `${safeRoundedRotationDeg}`; + return ` (rot. ${signedRotation}° ggü. ENU-E)`; +}; + +export const getVerticalRectanglePreviewAreaSquareMeters = ( firstCorner: Cartesian3, oppositeCorner: Cartesian3 ): number => { - const facadeCorners = buildFacadeRectangleCornerFromDiagonal( + const verticalCorners = buildVerticalRectangleCornerFromDiagonal( firstCorner, oppositeCorner ); - if (!facadeCorners) return 0; + if (!verticalCorners) return 0; const horizontalMeters = Cartesian3.distance( firstCorner, - facadeCorners.adjacentHorizontalCorner + verticalCorners.adjacentHorizontalCorner ); const verticalMeters = Cartesian3.distance( firstCorner, - facadeCorners.adjacentVerticalCorner + verticalCorners.adjacentVerticalCorner ); return horizontalMeters * verticalMeters; }; -export const buildFacadeAutoCloseRectangle = ( +export const buildVerticalAutoCloseRectangle = ( pointById: Map, firstPointId: string | null, secondPointId: string | null -): FacadeAutoCloseRectangle | null => { +): VerticalAutoCloseRectangle | null => { if (!firstPointId || !secondPointId) return null; const firstPoint = pointById.get(firstPointId); const secondPoint = pointById.get(secondPointId); if (!firstPoint || !secondPoint) return null; - const facadeCorners = buildFacadeRectangleCornerFromDiagonal( + const verticalCorners = buildVerticalRectangleCornerFromDiagonal( firstPoint, secondPoint ); - if (!facadeCorners) return null; + if (!verticalCorners) return null; const uniqueSeed = `${Date.now()}-${Math.round(Math.random() * 1_000_000)}`; - const cornerHorizontalId = `point-facade-${uniqueSeed}-h`; - const cornerVerticalId = `point-facade-${uniqueSeed}-v`; + const cornerHorizontalId = `point-vertical-${uniqueSeed}-h`; + const cornerVerticalId = `point-vertical-${uniqueSeed}-v`; return { autoCorners: [ { id: cornerHorizontalId, - position: facadeCorners.adjacentHorizontalCorner, + position: verticalCorners.adjacentHorizontalCorner, }, { id: cornerVerticalId, - position: facadeCorners.adjacentVerticalCorner, + position: verticalCorners.adjacentVerticalCorner, }, ], - closedVertexPointIds: [ + closedNodeIds: [ firstPointId, cornerHorizontalId, secondPointId, diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useGroundAreaLabelVisualizer.ts b/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useGroundAreaLabelVisualizer.ts deleted file mode 100644 index 75401eb5e9..0000000000 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useGroundAreaLabelVisualizer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildGroundAreaLabelText } from "./areaLabelTextBuilders"; -import { type GroundAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; -import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; - -const GROUND_AREA_OVERLAY_PREFIX = "distance-ground-polygon-preview"; - -export const useGroundAreaLabelVisualizer = ({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - groundPolygonPreviewGroups, -}: GroundAreaLabelVisualizerOptions) => { - useAreaLabelVisualizerBase({ - overlayPrefix: GROUND_AREA_OVERLAY_PREFIX, - viewProjector, - polygonPreviewGroups: groundPolygonPreviewGroups, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - resolveAreaLabelText: buildGroundAreaLabelText, - }); -}; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/usePlanarAreaLabelVisualizer.ts b/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/usePlanarAreaLabelVisualizer.ts deleted file mode 100644 index 70541ece0d..0000000000 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/usePlanarAreaLabelVisualizer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildPlanarAreaLabelText } from "./areaLabelTextBuilders"; -import { type PlanarAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; -import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; - -const PLANAR_AREA_OVERLAY_PREFIX = "distance-planar-polygon-preview"; - -export const usePlanarAreaLabelVisualizer = ({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - planarPolygonPreviewGroups, -}: PlanarAreaLabelVisualizerOptions) => { - useAreaLabelVisualizerBase({ - overlayPrefix: PLANAR_AREA_OVERLAY_PREFIX, - viewProjector, - polygonPreviewGroups: planarPolygonPreviewGroups, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - resolveAreaLabelText: buildPlanarAreaLabelText, - }); -}; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useVerticalAreaLabelVisualizer.ts b/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useVerticalAreaLabelVisualizer.ts deleted file mode 100644 index fb807d6d0a..0000000000 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useVerticalAreaLabelVisualizer.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { buildVerticalAreaLabelText } from "./areaLabelTextBuilders"; -import { type VerticalAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; -import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; - -const VERTICAL_AREA_OVERLAY_PREFIX = "distance-vertical-polygon-preview"; - -export const useVerticalAreaLabelVisualizer = ({ - viewProjector, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - verticalPolygonPreviewGroups, -}: VerticalAreaLabelVisualizerOptions) => { - useAreaLabelVisualizerBase({ - overlayPrefix: VERTICAL_AREA_OVERLAY_PREFIX, - viewProjector, - polygonPreviewGroups: verticalPolygonPreviewGroups, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - resolveAreaLabelText: buildVerticalAreaLabelText, - }); -}; diff --git a/libraries/mapping/annotations/provider/src/index.ts b/libraries/mapping/annotations/provider/src/index.ts index fdfc52f569..639e69ebd7 100644 --- a/libraries/mapping/annotations/provider/src/index.ts +++ b/libraries/mapping/annotations/provider/src/index.ts @@ -2,14 +2,22 @@ export { AnnotationToolbar3D } from "./lib/components/AnnotationToolbar3D"; export { AnnotationModeToolbar } from "./lib/components/AnnotationModeToolbar"; export { AnnotationInfoBox } from "./lib/components/annotation-info-box/AnnotationInfoBox"; export { - AnnotationsAdapterProvider, - useAnnotationsAdapter, - type AnnotationsAdapterContextType, - type AnnotationsAdapterOptions, -} from "./lib/context/AnnotationsAdapterProvider"; + AnnotationsProvider, + useAnnotationCollection, + useAnnotationEditingState, + useAnnotationSelectionState, + useAnnotationSettings, + useAnnotationTools, + type AnnotationsContextType, + type AnnotationCollectionContextType, + type AnnotationEditingContextType, + type AnnotationSelectionContextType, + type AnnotationSettingsContextType, + type AnnotationToolsContextType, + type AnnotationsOptions, +} from "./lib/context/AnnotationsProvider"; export type { AnnotationToolType, AnnotationModeToolbarProps, } from "./lib/components/AnnotationModeToolbar"; -export { useAnnotationToolMode } from "./lib/components/hooks/useAnnotationToolMode"; export { useLocalAnnotationPersistence } from "./lib/components/hooks/useLocalAnnotationPersistence"; diff --git a/libraries/mapping/annotations/provider/src/lib/components/AnnotationModeToolbar.tsx b/libraries/mapping/annotations/provider/src/lib/components/AnnotationModeToolbar.tsx index 94ed81c28e..d785a1c8f0 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/AnnotationModeToolbar.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/AnnotationModeToolbar.tsx @@ -29,6 +29,7 @@ import { ANNOTATION_TYPE_POINT, ANNOTATION_TYPE_POLYLINE, ANNOTATION_TYPE_AREA_VERTICAL, + isAreaToolType, annotationToolManager as defaultAnnotationToolManager, resolveAnnotationToolText, type AnnotationToolManager, @@ -58,6 +59,8 @@ export interface AnnotationModeToolbarProps { onSelectRectangleModeChange?: (enabled: boolean) => void; selectedMeasurementCount?: number; selectedLabelCount?: number; + onClearAllMeasurements?: () => void; + hasAnyMeasurements?: boolean; onDeleteSelectedPoints?: () => void; onToggleSelectedVisibility?: () => void; onToggleSelectedLock?: () => void; @@ -390,9 +393,6 @@ const SecondaryToolbarSection = ({ ); }; -const isAreaToolType = (type: AnnotationToolType): type is AreaToolType => - AREA_TOOL_TYPES.includes(type as AreaToolType); - const renderHelpContent = (lines: string[]) => (
{lines.map((line) => ( @@ -412,6 +412,8 @@ export function AnnotationModeToolbar({ onSelectRectangleModeChange, selectedMeasurementCount = 0, selectedLabelCount = 0, + onClearAllMeasurements, + hasAnyMeasurements = false, onDeleteSelectedPoints, onToggleSelectedVisibility, onToggleSelectedLock, @@ -901,6 +903,31 @@ export function AnnotationModeToolbar({ )} + + +
)} diff --git a/libraries/mapping/annotations/provider/src/lib/components/AnnotationToolbar3D.tsx b/libraries/mapping/annotations/provider/src/lib/components/AnnotationToolbar3D.tsx index 6d825614cc..ce7622e382 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/AnnotationToolbar3D.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/AnnotationToolbar3D.tsx @@ -3,22 +3,16 @@ import { Modal } from "antd"; import { isKeyboardTargetEditable } from "@carma-commons/utils"; import { isPointAnnotationEntry, - ANNOTATION_TYPE_DISTANCE, - SELECT_TOOL_TYPE, - ANNOTATION_TYPE_POINT, - ANNOTATION_TYPE_POLYLINE, - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, - PLANAR_TOOL_CREATION_MODE_POLYGON, - PLANAR_TOOL_CREATION_MODE_POLYLINE, - useAnnotations, - useAnnotationSelection, - useAnnotationModeOptions, - type AnnotationEntry, - type AnnotationMode, + isPointMeasurementEntry, type AnnotationToolManager, } from "@carma-mapping/annotations/core"; +import { + useAnnotationCollection, + useNodeChainAnnotations, + useAnnotationSelectionState, + useAnnotationSettings, + useAnnotationTools, +} from "../context/AnnotationsProvider"; import { AnnotationModeToolbar } from "./AnnotationModeToolbar"; import { useAnnotationToolMode } from "./hooks/useAnnotationToolMode"; @@ -42,70 +36,38 @@ export function AnnotationToolbar3D({ secondaryToolbarCollapsedByDefault?: boolean; secondaryToolbarDirection?: "down" | "right"; }) { - const { - annotationMode, - setAnnotationMode, - annotations, - setAnnotations, - clearAnnotationsByIds, - deleteSelectedPointAnnotations, - temporaryMode, - setTemporaryMode, - pointVerticalOffsetMeters, - setPointVerticalOffsetMeters, - pointLabelOnCreate, - setPointLabelOnCreate, - } = useAnnotations(); - - const { - selectedMeasurementIds, - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - } = useAnnotationSelection(); - - const { - planarPolygonGroups, - distanceModeStickyToFirstPoint, - setDistanceModeStickyToFirstPoint, - distanceCreationLineVisibility, - setDistanceCreationLineVisibilityByKind, - polylineVerticalOffsetMeters, - setPolylineVerticalOffsetMeters, - polylineSegmentLineMode, - setPolylineSegmentLineMode, - planarToolCreationMode, - setPlanarToolCreationMode, - setPolygonSurfaceTypePreset, - polygonSurfaceTypePreset, - } = useAnnotationModeOptions(); + const tools = useAnnotationTools(); + const selection = useAnnotationSelectionState(); + const annotations = useAnnotationCollection(); + const settings = useAnnotationSettings(); + const nodeChainAnnotations = useNodeChainAnnotations(); const measurementById = useMemo( - () => new Map(annotations.map((m) => [m.id, m])), - [annotations] + () => + new Map( + annotations.items.map((measurement) => [measurement.id, measurement]) + ), + [annotations.items] ); const deletableSelectedPointIds = useMemo( () => - selectedMeasurementIds.filter((id) => { + selection.ids.filter((id) => { const m = measurementById.get(id); return Boolean(m && isPointAnnotationEntry(m) && !m.locked); }), - [measurementById, selectedMeasurementIds] + [measurementById, selection.ids] ); const selectedPointIds = useMemo( () => - selectedMeasurementIds + selection.ids .map((id) => { const m = measurementById.get(id); return m && isPointAnnotationEntry(m) ? id : null; }) .filter((id): id is string => typeof id === "string"), - [measurementById, selectedMeasurementIds] + [measurementById, selection.ids] ); const selectedMeasurementCount = useMemo( @@ -113,7 +75,7 @@ export function AnnotationToolbar3D({ selectedPointIds.filter((id) => { const m = measurementById.get(id); return Boolean( - m && isPointAnnotationEntry(m) && !m.auxiliaryLabelAnchor + m && isPointMeasurementEntry(m) && !m.auxiliaryLabelAnchor ); }).length, [measurementById, selectedPointIds] @@ -124,7 +86,7 @@ export function AnnotationToolbar3D({ selectedPointIds.filter((id) => { const m = measurementById.get(id); return Boolean( - m && isPointAnnotationEntry(m) && m.auxiliaryLabelAnchor + m && isPointMeasurementEntry(m) && m.auxiliaryLabelAnchor ); }).length, [measurementById, selectedPointIds] @@ -140,55 +102,41 @@ export function AnnotationToolbar3D({ const deletableSelectedPointCount = deletableSelectedPointIds.length; const hasDeletableSelection = deletableSelectedPointCount > 0; + const hasAnyMeasurements = + annotations.items.length > 0 || nodeChainAnnotations.length > 0; const toggleSelectedVisibility = useCallback(() => { if (selectedPointIds.length === 0) return; - const selectedIdSet = new Set(selectedPointIds); - const shouldHide = !selectedPointIds.every((id) => - Boolean(measurementById.get(id)?.hidden) - ); - setAnnotations((prev) => - prev.map((m) => - selectedIdSet.has(m.id) ? { ...m, hidden: shouldHide } : m - ) - ); - }, [measurementById, selectedPointIds, setAnnotations]); + annotations.toggleVisibilityByIds(selectedPointIds); + }, [annotations, selectedPointIds]); const toggleSelectedLock = useCallback(() => { if (selectedPointIds.length === 0) return; - const selectedIdSet = new Set(selectedPointIds); - const shouldLock = !selectedPointIds.every((id) => - Boolean(measurementById.get(id)?.locked) - ); - setAnnotations((prev) => - prev.map((m) => - selectedIdSet.has(m.id) ? { ...m, locked: shouldLock } : m - ) - ); - }, [measurementById, selectedPointIds, setAnnotations]); + annotations.toggleLockByIds(selectedPointIds); + }, [annotations, selectedPointIds]); const requestDeleteSelectedPoints = useCallback(() => { const selectedPointIdSet = new Set(deletableSelectedPointIds); - const protectedPolygonCandidate = planarPolygonGroups.find((group) => { - if (!group.closed || group.vertexPointIds.length > 3) { + const protectedPolygonCandidate = nodeChainAnnotations.find((group) => { + if (!group.closed || group.nodeIds.length > 3) { return false; } - const vertexIds = group.vertexPointIds.filter( - (vertexId): vertexId is string => Boolean(vertexId) + const nodeIds = group.nodeIds.filter((nodeId): nodeId is string => + Boolean(nodeId) ); - if (vertexIds.length === 0) { + if (nodeIds.length === 0) { return false; } - const includesAnyVertex = vertexIds.some((vertexId) => - selectedPointIdSet.has(vertexId) + const includesAnyNode = nodeIds.some((nodeId) => + selectedPointIdSet.has(nodeId) ); - if (!includesAnyVertex) { + if (!includesAnyNode) { return false; } - const includesAllVertices = vertexIds.every((vertexId) => - selectedPointIdSet.has(vertexId) + const includesAllNodes = nodeIds.every((nodeId) => + selectedPointIdSet.has(nodeId) ); - return !includesAllVertices; + return !includesAllNodes; }); if (protectedPolygonCandidate) { @@ -201,7 +149,7 @@ export function AnnotationToolbar3D({ cancelText: "Abbrechen", okButtonProps: { danger: true }, onOk: () => { - clearAnnotationsByIds(protectedPolygonCandidate.vertexPointIds); + annotations.removeByIds(protectedPolygonCandidate.nodeIds); }, }); return; @@ -216,20 +164,36 @@ export function AnnotationToolbar3D({ cancelText: "Abbrechen", okButtonProps: { danger: true }, onOk: () => { - deleteSelectedPointAnnotations(); + annotations.removeSelection(); }, }); return; } - deleteSelectedPointAnnotations(); + annotations.removeSelection(); }, [ - clearAnnotationsByIds, - deleteSelectedPointAnnotations, + annotations, deletableSelectedPointCount, deletableSelectedPointIds, - planarPolygonGroups, + nodeChainAnnotations, ]); + const requestClearAllMeasurements = useCallback(() => { + if (!hasAnyMeasurements) return; + + Modal.confirm({ + centered: true, + title: "Alle Messungen löschen", + content: + "Alle vorhandenen Messungen wirklich löschen? Dieser Schritt kann nicht rueckgaengig gemacht werden.", + okText: "Alle löschen", + cancelText: "Abbrechen", + okButtonProps: { danger: true }, + onOk: () => { + annotations.removeAll(); + }, + }); + }, [annotations, hasAnyMeasurements]); + useEffect(() => { if (!enableMultiDeleteHotkey) return; const handleMultiDeleteKey = (event: KeyboardEvent) => { @@ -252,86 +216,14 @@ export function AnnotationToolbar3D({ requestDeleteSelectedPoints, ]); - const isAreaMode = - annotationMode === ANNOTATION_TYPE_POLYLINE && - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON; - const polygonSurfaceMeasurementType = - polygonSurfaceTypePreset === "facade" - ? ANNOTATION_TYPE_AREA_VERTICAL - : polygonSurfaceTypePreset === "roof" - ? ANNOTATION_TYPE_AREA_PLANAR - : ANNOTATION_TYPE_AREA_GROUND; - - const { activeToolType, handleToolTypeChange } = useAnnotationToolMode({ - isSelectionMode: selectionModeActive, - isLabelMode: pointLabelOnCreate, - isDistanceMode: annotationMode === ANNOTATION_TYPE_DISTANCE, - isAreaMode: - isAreaMode && - polygonSurfaceMeasurementType === ANNOTATION_TYPE_AREA_GROUND, - isVerticalMode: - isAreaMode && - polygonSurfaceMeasurementType === ANNOTATION_TYPE_AREA_VERTICAL, - isPlanarMode: - isAreaMode && - polygonSurfaceMeasurementType === ANNOTATION_TYPE_AREA_PLANAR, - isPolylineMode: - annotationMode === ANNOTATION_TYPE_POLYLINE && - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYLINE, - onSelectMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(SELECT_TOOL_TYPE); - setSelectionModeActive(true); - }, - onLabelMode: () => { - setPointLabelOnCreate(true); - setAnnotationMode(ANNOTATION_TYPE_POINT); - setSelectionModeActive(false); - }, - onPointMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_POINT); - setSelectionModeActive(false); - }, - onDistanceMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_DISTANCE); - setSelectionModeActive(false); - }, - onAreaMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_POLYLINE); - setPlanarToolCreationMode(PLANAR_TOOL_CREATION_MODE_POLYGON); - setPolygonSurfaceTypePreset("footprint"); - setSelectionModeActive(false); - }, - onVerticalMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_POLYLINE); - setPlanarToolCreationMode(PLANAR_TOOL_CREATION_MODE_POLYGON); - setPolygonSurfaceTypePreset("facade"); - setSelectionModeActive(false); - }, - onPlanarMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_POLYLINE); - setPlanarToolCreationMode(PLANAR_TOOL_CREATION_MODE_POLYGON); - setPolygonSurfaceTypePreset("roof"); - setSelectionModeActive(false); - }, - onPolylineMode: () => { - setPointLabelOnCreate(false); - setAnnotationMode(ANNOTATION_TYPE_POLYLINE); - setPlanarToolCreationMode(PLANAR_TOOL_CREATION_MODE_POLYLINE); - setSelectionModeActive(false); - }, - }); + const { activeToolType: toolbarToolType, handleToolTypeChange } = + useAnnotationToolMode(tools.activeToolType, tools.requestModeChange); const handleDistanceLineVisibilityChange = useCallback( (kind: "direct" | "vertical" | "horizontal", visible: boolean) => { - setDistanceCreationLineVisibilityByKind(kind, visible); + settings.distance.setCreationLineVisibilityByKind(kind, visible); }, - [setDistanceCreationLineVisibilityByKind] + [settings.distance] ); return ( @@ -340,34 +232,40 @@ export function AnnotationToolbar3D({ style={{ backgroundColor: "transparent", pointerEvents: "auto" }} > - hasPreviewAnchor +export const getDistanceInstructionText = ( + hasCandidateAnchor: boolean +): string => + hasCandidateAnchor ? DISTANCE_SECOND_POINT_INSTRUCTION : DISTANCE_FIRST_POINT_INSTRUCTION; @@ -80,7 +82,7 @@ const renderAnnotationActions = ( name="search-location" onClick={(event: ReactMouseEvent) => { event.stopPropagation(); - actions.flyToMeasurementById(measurement.id); + actions.flyToById(measurement.id); }} className="cursor-pointer text-[16px] text-[#808080] hover:text-[#a0a0a0]" data-test-id="carma-flyto-measurement-btn" @@ -91,9 +93,7 @@ const renderAnnotationActions = ( icon={measurement.hidden ? faEyeSlash : faEye} onClick={(event) => { event.stopPropagation(); - actions.updateAnnotationById(measurement.id, { - hidden: !measurement.hidden, - }); + actions.toggleVisibilityByIds([measurement.id]); }} dataTestId="carma-toggle-measurement-visibility-btn" /> @@ -102,7 +102,7 @@ const renderAnnotationActions = ( icon={measurement.locked ? faLock : faLockOpen} onClick={(event) => { event.stopPropagation(); - actions.toggleAnnotationLockById(measurement.id); + actions.toggleLockByIds([measurement.id]); }} dataTestId="carma-toggle-measurement-lock-btn" /> @@ -112,7 +112,7 @@ const renderAnnotationActions = ( icon={faArrowsDownToLine} onClick={(event) => { event.stopPropagation(); - actions.setReferencePoint(measurement.geometryECEF); + actions.setReferencePointId(measurement.id); }} dataTestId="carma-set-reference-btn" /> @@ -122,7 +122,7 @@ const renderAnnotationActions = ( icon={faTrashCan} onClick={(event) => { event.stopPropagation(); - actions.deleteAnnotationById(measurement.id); + actions.removeByIds([measurement.id]); }} dataTestId="carma-delete-measurement-btn" /> @@ -171,11 +171,11 @@ export const renderEditableAnnotationSubtitle = ({ autoFocusTrigger={autoFocusTrigger} onChange={(nextTitle) => { if (!measurement) return; - actions.updateAnnotationNameById(measurement.id, nextTitle); + actions.updateNameById(measurement.id, nextTitle); }} onCommit={(nextTitle) => { if (!measurement) return; - actions.updateAnnotationNameById(measurement.id, nextTitle); + actions.updateNameById(measurement.id, nextTitle); onTitleCommit?.(nextTitle); }} /> @@ -214,10 +214,10 @@ export const renderRelativeElevationContent = ( export const renderDistanceTableContent = ( rows: DistanceTableRow[], - isLivePreview: boolean, - hasPreviewAnchor: boolean + isCandidate: boolean, + hasCandidateAnchor: boolean ): ReactNode => { - if (isLivePreview && !hasPreviewAnchor) { + if (isCandidate && !hasCandidateAnchor) { return null; } diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/annotationInfoBoxSlots.types.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/annotationInfoBoxSlots.types.ts index 66bb73ecce..5d65f661e0 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/annotationInfoBoxSlots.types.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/annotationInfoBoxSlots.types.ts @@ -13,6 +13,7 @@ import { type AnnotationType, type LinearSegmentLineMode, } from "@carma-mapping/annotations/core"; +import type { AnnotationVisualizerOptionsPatch } from "../../context/annotationsContext.types"; import type { AnnotationDisplayPoint } from "./utils/pointAnnotationDisplay"; export type AnnotationSlotKind = AnnotationType | "unsupported"; @@ -29,28 +30,21 @@ export type DistanceTableRow = { }; export type AnnotationSlotActions = { - updateAnnotationNameById: (id: string, name: string) => void; - updateAnnotationById: (id: string, patch: Partial) => void; - deleteAnnotationById: (id: string) => void; - toggleAnnotationLockById: (id: string) => void; - flyToMeasurementById: (id: string) => void; - flyToPlanarPolygonGroupById: (id: string) => void; - togglePlanarPolygonGroupVisibilityById: (id: string) => void; - togglePlanarPolygonGroupLockById: (id: string) => void; - setReferencePoint: ( - nextReference: PointAnnotationEntry["geometryECEF"] | null - ) => void; - confirmPointLabelInputById: (id: string) => void; + updateNameById: (id: string, name: string) => void; + removeByIds: (ids: string[]) => void; + toggleLockByIds: (ids: string[]) => void; + toggleVisibilityByIds: (ids: string[]) => void; + flyToById: (id: string) => void; + setReferencePointId: (id: string | null) => void; + confirmLabelPlacementById: (id: string) => void; updatePointLabelAppearanceById: ( id: string, appearance: AnnotationEntry["labelAppearance"] | undefined ) => void; - updatePlanarPolygonNameById: (id: string, name: string) => void; - updatePlanarPolygonSegmentLineModeById: ( + updateVisualizerOptionsById: ( id: string, - nextMode: LinearSegmentLineMode + patch: AnnotationVisualizerOptionsPatch ) => void; - deletePlanarPolygonGroupById: (id: string) => void; }; export type PolylineSummary = { @@ -74,7 +68,7 @@ export type PointAnnotationSlotsInput = BaseAnnotationSlotsInput & { kind: typeof ANNOTATION_TYPE_POINT; currentOrder: number | null; nextOrder: number; - isLivePreview: boolean; + isCandidate: boolean; }; export type DistanceAnnotationSlotsInput = BaseAnnotationSlotsInput & { @@ -82,15 +76,15 @@ export type DistanceAnnotationSlotsInput = BaseAnnotationSlotsInput & { currentOrder: number | null; currentOrderToken: string | null; nextOrder: number; - isLivePreview: boolean; - hasPreviewAnchor: boolean; + isCandidate: boolean; + hasCandidateAnchor: boolean; subtitleDirectDistanceMeters: number | null; distanceTableRows: DistanceTableRow[]; }; export type LabelAnnotationSlotsInput = BaseAnnotationSlotsInput & { kind: typeof ANNOTATION_TYPE_LABEL; - isLivePreview: boolean; + isCandidate: boolean; autoFocusTitleTrigger?: number | string; pureLabelAppearance: { fontSizePx: number; @@ -112,7 +106,7 @@ export type PolygonPolylineAnnotationSlotsInput = { | typeof ANNOTATION_TYPE_AREA_GROUND | typeof ANNOTATION_TYPE_AREA_PLANAR | typeof ANNOTATION_TYPE_AREA_VERTICAL; - groupId: string; + measurementId: string; name?: string; order: number; totalLengthMeters: number; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getAnnotationInfoBoxSlots.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getAnnotationInfoBoxSlots.tsx index a12cd0a10d..328d6f8a4d 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getAnnotationInfoBoxSlots.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getAnnotationInfoBoxSlots.tsx @@ -9,7 +9,7 @@ import { } from "@carma-mapping/annotations/core"; import { getDistanceAnnotationInfoBoxSlots } from "./getDistanceAnnotationInfoBoxSlots"; import { getLabelAnnotationInfoBoxSlots } from "./getLabelAnnotationInfoBoxSlots"; -import { getPlanarAnnotationInfoBoxSlots } from "./getPlanarAnnotationInfoBoxSlots"; +import { getNodeChainAnnotationInfoBoxSlots } from "./getNodeChainAnnotationInfoBoxSlots"; import { getPointAnnotationInfoBoxSlots } from "./getPointAnnotationInfoBoxSlots"; import { getUnsupportedAnnotationInfoBoxSlots } from "./getUnsupportedAnnotationInfoBoxSlots"; import type { @@ -46,7 +46,7 @@ export const getAnnotationInfoBoxSlots = ( case ANNOTATION_TYPE_AREA_GROUND: case ANNOTATION_TYPE_AREA_PLANAR: case ANNOTATION_TYPE_AREA_VERTICAL: - return getPlanarAnnotationInfoBoxSlots(input); + return getNodeChainAnnotationInfoBoxSlots(input); default: return getUnsupportedAnnotationInfoBoxSlots(input); } diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationInfoBoxSlots.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationInfoBoxSlots.tsx index 3cf1b328fc..6e2194aba4 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationInfoBoxSlots.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationInfoBoxSlots.tsx @@ -15,7 +15,7 @@ export const getDistanceAnnotationInfoBoxSlots = ( input: DistanceAnnotationSlotsInput ): AnnotationSlots => ({ headingTitle: - input.measurement || !input.isLivePreview + input.measurement || !input.isCandidate ? DISTANCE_TITLE : `${DISTANCE_TITLE} (Neu)`, subtitle: renderEditableAnnotationSubtitle({ @@ -32,11 +32,11 @@ export const getDistanceAnnotationInfoBoxSlots = ( }), content: renderDistanceTableContent( input.distanceTableRows, - input.isLivePreview, - input.hasPreviewAnchor + input.isCandidate, + input.hasCandidateAnchor ), - collapsible: Boolean(input.measurement || input.isLivePreview), - instructionText: input.isLivePreview - ? getDistanceInstructionText(input.hasPreviewAnchor) + collapsible: Boolean(input.measurement || input.isCandidate), + instructionText: input.isCandidate + ? getDistanceInstructionText(input.hasCandidateAnchor) : null, }); diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationSlotsInput.ts index 017f8ceec0..9722b518b5 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getDistanceAnnotationSlotsInput.ts @@ -1,10 +1,11 @@ import { Cartesian3, CarmaTransforms } from "@carma/cesium"; import { ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POINT, AnnotationMode, + type AnnotationToolType, PointDistanceRelation, PointAnnotationEntry, - type AnnotationListType, getCustomPointAnnotationName, } from "@carma-mapping/annotations/core"; @@ -21,28 +22,26 @@ import { } from "./utils/pointAnnotationDisplay"; type GetDistanceMeasurementSlotsInputParams = { - annotationMode: AnnotationMode; + activeToolType: AnnotationToolType; measurement: PointAnnotationEntry | null; activeMeasurementId: string | null; - pointMeasurements: ReadonlyArray; + pointEntries: ReadonlyArray; referencePoint: PointAnnotationEntry["geometryECEF"] | null; hasDistancePreviewAnchor: boolean; distanceRelations: ReadonlyArray; pointMarkerBadgeByPointId: Readonly>; getAnnotationOrderByType: ( - type: AnnotationListType, + type: AnnotationMode, id: string | null | undefined ) => number | null; - getNextAnnotationOrderByType: ( - type: AnnotationListType - ) => number; + getNextAnnotationOrderByType: (type: AnnotationMode) => number; actions: AnnotationSlotActions; }; export type DistanceMeasurementSlotsInputResult = { slotsInput: DistanceAnnotationSlotsInput; isDistanceMeasurement: boolean; - isDistanceLivePreview: boolean; + isDistanceCandidate: boolean; }; const isDistanceMeasurementEntry = ({ @@ -68,7 +67,7 @@ const resolvePointLabel = ({ }: { point: PointAnnotationEntry; getAnnotationOrderByType: ( - type: AnnotationListType, + type: AnnotationMode, id: string | null | undefined ) => number | null; fallbackPointOrderById: ReadonlyMap; @@ -78,7 +77,7 @@ const resolvePointLabel = ({ if (customName) { return customName; } - const order = getAnnotationOrderByType("pointMeasure", point.id); + const order = getAnnotationOrderByType(ANNOTATION_TYPE_POINT, point.id); if (order !== null) { return `${order}`; } @@ -128,10 +127,10 @@ const buildDistanceRow = ({ }; export const getDistanceAnnotationSlotsInput = ({ - annotationMode, + activeToolType, measurement, activeMeasurementId, - pointMeasurements, + pointEntries, referencePoint, hasDistancePreviewAnchor, distanceRelations, @@ -145,18 +144,15 @@ export const getDistanceAnnotationSlotsInput = ({ measurement, distanceRelations, }); - const isDistanceLivePreview = annotationMode === ANNOTATION_TYPE_DISTANCE; + const isDistanceCandidate = activeToolType === ANNOTATION_TYPE_DISTANCE; const currentOrderToken = measurement ? pointMarkerBadgeByPointId[measurement.id]?.text ?? null : null; - const pointMeasurementById = new Map( - pointMeasurements.map((pointMeasurement) => [ - pointMeasurement.id, - pointMeasurement, - ]) + const pointEntryById = new Map( + pointEntries.map((pointEntry) => [pointEntry.id, pointEntry]) ); const fallbackPointOrderById = new Map( - [...pointMeasurements] + [...pointEntries] .sort((left, right) => { const indexDelta = (left.index ?? 0) - (right.index ?? 0); if (indexDelta !== 0) return indexDelta; @@ -164,9 +160,7 @@ export const getDistanceAnnotationSlotsInput = ({ if (timeDelta !== 0) return timeDelta; return left.id.localeCompare(right.id); }) - .map( - (pointMeasurement, index) => [pointMeasurement.id, index + 1] as const - ) + .map((pointEntry, index) => [pointEntry.id, index + 1] as const) ); const distanceTableRows = (() => { @@ -174,13 +168,13 @@ export const getDistanceAnnotationSlotsInput = ({ return [] as DistanceTableRow[]; } - if (isDistanceLivePreview) { + if (isDistanceCandidate) { if (!hasDistancePreviewAnchor) { return [] as DistanceTableRow[]; } const activeAnchorPoint = activeMeasurementId !== null - ? pointMeasurementById.get(activeMeasurementId) ?? null + ? pointEntryById.get(activeMeasurementId) ?? null : null; if (!activeAnchorPoint || activeAnchorPoint.id === measurement.id) { return [] as DistanceTableRow[]; @@ -207,7 +201,7 @@ export const getDistanceAnnotationSlotsInput = ({ relation.pointAId === measurement.id ? relation.pointBId : relation.pointAId; - const relatedPoint = pointMeasurementById.get(relatedPointId); + const relatedPoint = pointEntryById.get(relatedPointId); if (!relatedPoint) { return null; } @@ -228,7 +222,7 @@ export const getDistanceAnnotationSlotsInput = ({ .filter((row): row is DistanceTableRow => row !== null); const referencePointMeasurement = findReferencePointMeasurement({ - pointMeasurements, + pointEntries, referencePoint, }); if ( @@ -273,18 +267,18 @@ export const getDistanceAnnotationSlotsInput = ({ ), isReference: isPointReferenceMeasurement(measurement, referencePoint), currentOrder: getAnnotationOrderByType( - "distanceMeasure", + ANNOTATION_TYPE_DISTANCE, measurement?.id ), currentOrderToken, - nextOrder: getNextAnnotationOrderByType("distanceMeasure"), - isLivePreview: isDistanceLivePreview, - hasPreviewAnchor: hasDistancePreviewAnchor, + nextOrder: getNextAnnotationOrderByType(ANNOTATION_TYPE_DISTANCE), + isCandidate: isDistanceCandidate, + hasCandidateAnchor: hasDistancePreviewAnchor, subtitleDirectDistanceMeters, distanceTableRows, actions, }, isDistanceMeasurement, - isDistanceLivePreview, + isDistanceCandidate, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationInfoBoxSlots.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationInfoBoxSlots.tsx index 0d37e42c95..20e6158a8c 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationInfoBoxSlots.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationInfoBoxSlots.tsx @@ -54,7 +54,7 @@ const normalizeColorToHex = ( const renderPureLabelContent = ( input: LabelAnnotationSlotsInput ): ReactNode => { - if (input.isLivePreview) { + if (input.isCandidate) { return (
@@ -154,7 +154,7 @@ export const getLabelAnnotationInfoBoxSlots = ( input: LabelAnnotationSlotsInput ): AnnotationSlots => ({ headingTitle: - input.measurement || !input.isLivePreview + input.measurement || !input.isCandidate ? LABEL_TITLE : `${LABEL_TITLE} (Neu)`, subtitle: renderEditableAnnotationSubtitle({ @@ -168,7 +168,7 @@ export const getLabelAnnotationInfoBoxSlots = ( onTitleCommit: (title) => { if (!input.measurement) return; if (!title.trim()) return; - input.actions.confirmPointLabelInputById(input.measurement.id); + input.actions.confirmLabelPlacementById(input.measurement.id); }, }), content: renderPureLabelContent(input), diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationSlotsInput.ts index 584bf41c17..5142cd990a 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getLabelAnnotationSlotsInput.ts @@ -35,7 +35,7 @@ type GetLabelMeasurementSlotsInputParams = { export type LabelMeasurementSlotsInputResult = { slotsInput: LabelAnnotationSlotsInput; isLabelMeasurement: boolean; - isLabelLivePreview: boolean; + isLabelCandidate: boolean; }; export const getLabelAnnotationSlotsInput = ({ @@ -98,7 +98,7 @@ export const getLabelAnnotationSlotsInput = ({ displayPoint: resolvePointAnnotationDisplayPoint(displayMeasurement), relativeElevation: null, isReference: false, - isLivePreview: false, + isCandidate: false, autoFocusTitleTrigger: displayMeasurement && displayMeasurement.id === labelInputPromptPointId ? displayMeasurement.id @@ -114,6 +114,6 @@ export const getLabelAnnotationSlotsInput = ({ actions, }, isLabelMeasurement, - isLabelLivePreview: false, + isLabelCandidate: false, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationInfoBoxSlots.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationInfoBoxSlots.tsx similarity index 85% rename from libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationInfoBoxSlots.tsx rename to libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationInfoBoxSlots.tsx index 00d4c895cc..84a953aa2f 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationInfoBoxSlots.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationInfoBoxSlots.tsx @@ -27,7 +27,7 @@ import type { PolygonPolylineAnnotationSlotsInput, } from "./annotationInfoBoxSlots.types"; -const PLANAR_TYPE_TITLE_BY_KIND: Record< +const NODE_CHAIN_TYPE_TITLE_BY_KIND: Record< PolygonPolylineAnnotationSlotsInput["kind"], string > = { @@ -37,7 +37,9 @@ const PLANAR_TYPE_TITLE_BY_KIND: Record< [ANNOTATION_TYPE_AREA_VERTICAL]: "Fassade", }; -const getPlanarMetricContent = (input: PolygonPolylineAnnotationSlotsInput) => { +const getNodeChainMetricContent = ( + input: PolygonPolylineAnnotationSlotsInput +) => { const cardinalHeading = formatBearingToGermanSectorLabel(input.bearingDeg, { useFullLabel: true, includeDegree: true, @@ -65,12 +67,11 @@ const getPlanarMetricContent = (input: PolygonPolylineAnnotationSlotsInput) => { size="small" checked={isComponentsMode} onChange={(checked) => - input.actions.updatePlanarPolygonSegmentLineModeById( - input.groupId, - checked + input.actions.updateVisualizerOptionsById(input.measurementId, { + segmentLineMode: checked ? LINEAR_SEGMENT_LINE_MODE_COMPONENTS - : LINEAR_SEGMENT_LINE_MODE_DIRECT - ) + : LINEAR_SEGMENT_LINE_MODE_DIRECT, + }) } aria-label="Polygonzug-Segmentdarstellung umschalten" data-test-id="infobox-polyline-line-mode-toggle" @@ -168,7 +169,7 @@ const stopHeadingActionPropagation = ( event.stopPropagation(); }; -const renderPlanarHeadingActions = ( +const renderNodeChainHeadingActions = ( input: PolygonPolylineAnnotationSlotsInput ) => (
) => { stopHeadingActionPropagation(event); - input.actions.flyToPlanarPolygonGroupById(input.groupId); + input.actions.flyToById(input.measurementId); }} className="cursor-pointer text-[15px] text-white/85 hover:text-white" - data-test-id="carma-flyto-planar-group-btn" + data-test-id="carma-flyto-node-chain-annotation-btn" /> { stopHeadingActionPropagation(event); - input.actions.togglePlanarPolygonGroupVisibilityById(input.groupId); + input.actions.toggleVisibilityByIds([input.measurementId]); }} className="cursor-pointer text-[14px] text-white/85 hover:text-white" - dataTestId="carma-toggle-planar-group-visibility-btn" + dataTestId="carma-toggle-node-chain-annotation-visibility-btn" /> { stopHeadingActionPropagation(event); - input.actions.togglePlanarPolygonGroupLockById(input.groupId); + input.actions.toggleLockByIds([input.measurementId]); }} className="cursor-pointer text-[14px] text-white/85 hover:text-white" - dataTestId="carma-toggle-planar-group-lock-btn" + dataTestId="carma-toggle-node-chain-annotation-lock-btn" /> { stopHeadingActionPropagation(event); - input.actions.deletePlanarPolygonGroupById(input.groupId); + input.actions.removeByIds([input.measurementId]); }} className="cursor-pointer text-[14px] text-white/85 hover:text-white" - dataTestId="carma-delete-planar-group-btn" + dataTestId="carma-delete-node-chain-annotation-btn" />
); -export const getPlanarAnnotationInfoBoxSlots = ( +export const getNodeChainAnnotationInfoBoxSlots = ( input: PolygonPolylineAnnotationSlotsInput ): AnnotationSlots => ({ - headingTitle: PLANAR_TYPE_TITLE_BY_KIND[input.kind], - headingActions: renderPlanarHeadingActions(input), + headingTitle: NODE_CHAIN_TYPE_TITLE_BY_KIND[input.kind], + headingActions: renderNodeChainHeadingActions(input), subtitle: (
- input.actions.updatePlanarPolygonNameById(input.groupId, nextTitle) + input.actions.updateNameById(input.measurementId, nextTitle) } onCommit={(nextTitle) => - input.actions.updatePlanarPolygonNameById(input.groupId, nextTitle) + input.actions.updateNameById(input.measurementId, nextTitle) } />
), - content: getPlanarMetricContent(input), + content: getNodeChainMetricContent(input), collapsible: input.kind === ANNOTATION_TYPE_POLYLINE, instructionText: null, }); diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationSlotsInput.ts new file mode 100644 index 0000000000..756d9a39d1 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getNodeChainAnnotationSlotsInput.ts @@ -0,0 +1,178 @@ +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_POLYLINE, + type AnnotationEntry, + type DerivedPolylinePath, + type NodeChainAnnotation, + LINEAR_SEGMENT_LINE_MODE_COMPONENTS, + type LinearSegmentLineMode, +} from "@carma-mapping/annotations/core"; +import type { + AnnotationSlotActions, + PolylineSummary, + PolygonPolylineAnnotationSlotsInput, +} from "./annotationInfoBoxSlots.types"; + +type GetNodeChainAnnotationSlotsInputParams = { + polylineMeasurements: ReadonlyArray; + groundPolygons: ReadonlyArray; + planarPolygons: ReadonlyArray; + verticalPolygons: ReadonlyArray; + polylinePaths: ReadonlyArray; + annotations: ReadonlyArray; + fallbackPolylineSegmentLineMode: LinearSegmentLineMode; + focusedNodeChainAnnotationId: string | null; + activeNodeChainAnnotationId: string | null; + actions: AnnotationSlotActions; +}; + +export type NodeChainAnnotationSlotsInputResult = { + slotsInput: PolygonPolylineAnnotationSlotsInput | null; + nodeChainAnnotation: NodeChainAnnotation | null; +}; + +const getNodeChainKind = ( + group: NodeChainAnnotation +): PolygonPolylineAnnotationSlotsInput["kind"] => group.type; + +const hasDisplayableActiveMetrics = ( + group: NodeChainAnnotation | null +): boolean => { + if (!group) return false; + const requiredVertexCount = group.type === ANNOTATION_TYPE_POLYLINE ? 2 : 3; + return group.nodeIds.length >= requiredVertexCount; +}; + +const getPolylineSummary = ( + polyline: DerivedPolylinePath | null +): PolylineSummary | null => { + if (!polyline || polyline.segmentLengthsMeters.length === 0) { + return null; + } + + const segmentCount = polyline.segmentLengthsMeters.length; + const meanSegmentLengthMeters = polyline.totalLengthMeters / segmentCount; + const heights = polyline.nodeHeightsMeters; + + let ascentMeters = 0; + let descentMeters = 0; + for (let index = 1; index < heights.length; index += 1) { + const delta = heights[index] - heights[index - 1]; + if (!Number.isFinite(delta) || Math.abs(delta) <= 1e-9) continue; + if (delta > 0) { + ascentMeters += delta; + } else { + descentMeters += Math.abs(delta); + } + } + const startEndElevationDeltaMeters = + heights.length >= 2 ? heights[heights.length - 1] - heights[0] : 0; + + return { + segmentCount, + meanSegmentLengthMeters, + totalAbsoluteElevationChangeMeters: ascentMeters + descentMeters, + startEndElevationDeltaMeters, + ascentMeters, + descentMeters, + }; +}; + +export const getNodeChainAnnotationSlotsInput = ({ + polylineMeasurements, + groundPolygons, + planarPolygons, + verticalPolygons, + polylinePaths, + annotations, + fallbackPolylineSegmentLineMode, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + actions, +}: GetNodeChainAnnotationSlotsInputParams): NodeChainAnnotationSlotsInputResult => { + const nodeChainAnnotations = [ + ...polylineMeasurements, + ...groundPolygons, + ...planarPolygons, + ...verticalPolygons, + ]; + const activeNodeChainAnnotation = + (activeNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId + ) + : null) ?? null; + const selectedNodeChainAnnotation = + (focusedNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === focusedNodeChainAnnotationId + ) + : null) ?? null; + const nodeChainAnnotation = hasDisplayableActiveMetrics( + activeNodeChainAnnotation + ) + ? activeNodeChainAnnotation + : selectedNodeChainAnnotation; + + if (!nodeChainAnnotation) { + return { + slotsInput: null, + nodeChainAnnotation: null, + }; + } + + const sameKindGroups = + getNodeChainKind(nodeChainAnnotation) === ANNOTATION_TYPE_POLYLINE + ? polylineMeasurements + : getNodeChainKind(nodeChainAnnotation) === ANNOTATION_TYPE_AREA_GROUND + ? groundPolygons + : getNodeChainKind(nodeChainAnnotation) === ANNOTATION_TYPE_AREA_PLANAR + ? planarPolygons + : verticalPolygons; + const order = + Math.max( + 0, + sameKindGroups.findIndex((group) => group.id === nodeChainAnnotation.id) + ) + 1; + const annotationKind = getNodeChainKind(nodeChainAnnotation); + const polyline = + annotationKind === ANNOTATION_TYPE_POLYLINE + ? polylinePaths.find((entry) => entry.id === nodeChainAnnotation.id) ?? + null + : null; + const segmentLineMode = + annotationKind === ANNOTATION_TYPE_POLYLINE + ? nodeChainAnnotation.segmentLineMode ?? + fallbackPolylineSegmentLineMode ?? + LINEAR_SEGMENT_LINE_MODE_COMPONENTS + : null; + const annotationLockedById = new Map( + annotations.map((entry) => [entry.id, Boolean(entry.locked)] as const) + ); + const isLocked = + nodeChainAnnotation.nodeIds.length > 0 && + nodeChainAnnotation.nodeIds.every((nodeId) => + Boolean(annotationLockedById.get(nodeId)) + ); + + return { + nodeChainAnnotation, + slotsInput: { + kind: annotationKind, + measurementId: nodeChainAnnotation.id, + name: nodeChainAnnotation.name, + order, + totalLengthMeters: + nodeChainAnnotation.perimeterMeters ?? polyline?.totalLengthMeters ?? 0, + areaSquareMeters: nodeChainAnnotation.areaSquareMeters, + bearingDeg: nodeChainAnnotation.bearingDeg, + verticalityDeg: nodeChainAnnotation.verticalityDeg, + segmentLineMode, + polylineSummary: getPolylineSummary(polyline), + hidden: Boolean(nodeChainAnnotation.hidden), + locked: isLocked, + actions, + }, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationSlotsInput.ts deleted file mode 100644 index 01656b6fa5..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPlanarAnnotationSlotsInput.ts +++ /dev/null @@ -1,158 +0,0 @@ -import { - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_POLYLINE, - type AnnotationEntry, - type PlanarPolygonGroup, - LINEAR_SEGMENT_LINE_MODE_COMPONENTS, - type LinearSegmentLineMode, -} from "@carma-mapping/annotations/core"; -import type { DerivedPolylinePath } from "../../context/types/derivedPolylinePath"; -import type { - AnnotationSlotActions, - PolylineSummary, - PolygonPolylineAnnotationSlotsInput, -} from "./annotationInfoBoxSlots.types"; - -type GetPlanarMeasurementSlotsInputParams = { - polylineGroups: ReadonlyArray; - areaPolygonGroups: ReadonlyArray; - planarSurfacePolygonGroups: ReadonlyArray; - verticalPolygonGroups: ReadonlyArray; - polylines: ReadonlyArray; - annotations: ReadonlyArray; - fallbackPolylineSegmentLineMode: LinearSegmentLineMode; - selectedPlanarPolygonGroupId: string | null; - activePlanarPolygonGroupId: string | null; - actions: AnnotationSlotActions; -}; - -export type PlanarMeasurementSlotsInputResult = { - slotsInput: PolygonPolylineAnnotationSlotsInput | null; - planarGroup: PlanarPolygonGroup | null; -}; - -const getPlanarKind = ( - group: PlanarPolygonGroup -): PolygonPolylineAnnotationSlotsInput["kind"] => group.measurementKind; - -const getPolylineSummary = ( - polyline: DerivedPolylinePath | null -): PolylineSummary | null => { - if (!polyline || polyline.segmentLengthsMeters.length === 0) { - return null; - } - - const segmentCount = polyline.segmentLengthsMeters.length; - const meanSegmentLengthMeters = polyline.totalLengthMeters / segmentCount; - const heights = polyline.vertexHeightsMeters; - - let ascentMeters = 0; - let descentMeters = 0; - for (let index = 1; index < heights.length; index += 1) { - const delta = heights[index] - heights[index - 1]; - if (!Number.isFinite(delta) || Math.abs(delta) <= 1e-9) continue; - if (delta > 0) { - ascentMeters += delta; - } else { - descentMeters += Math.abs(delta); - } - } - const startEndElevationDeltaMeters = - heights.length >= 2 ? heights[heights.length - 1] - heights[0] : 0; - - return { - segmentCount, - meanSegmentLengthMeters, - totalAbsoluteElevationChangeMeters: ascentMeters + descentMeters, - startEndElevationDeltaMeters, - ascentMeters, - descentMeters, - }; -}; - -export const getPlanarAnnotationSlotsInput = ({ - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - polylines, - annotations, - fallbackPolylineSegmentLineMode, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - actions, -}: GetPlanarMeasurementSlotsInputParams): PlanarMeasurementSlotsInputResult => { - const planarPolygonGroups = [ - ...polylineGroups, - ...areaPolygonGroups, - ...planarSurfacePolygonGroups, - ...verticalPolygonGroups, - ]; - const focusedGroupId = - activePlanarPolygonGroupId ?? selectedPlanarPolygonGroupId; - const planarGroup = - (focusedGroupId - ? planarPolygonGroups.find((group) => group.id === focusedGroupId) - : null) ?? null; - - if (!planarGroup) { - return { - slotsInput: null, - planarGroup: null, - }; - } - - const sameKindGroups = - getPlanarKind(planarGroup) === ANNOTATION_TYPE_POLYLINE - ? polylineGroups - : getPlanarKind(planarGroup) === ANNOTATION_TYPE_AREA_GROUND - ? areaPolygonGroups - : getPlanarKind(planarGroup) === ANNOTATION_TYPE_AREA_PLANAR - ? planarSurfacePolygonGroups - : verticalPolygonGroups; - const order = - Math.max( - 0, - sameKindGroups.findIndex((group) => group.id === planarGroup.id) - ) + 1; - const planarKind = getPlanarKind(planarGroup); - const polyline = - planarKind === ANNOTATION_TYPE_POLYLINE - ? polylines.find((entry) => entry.id === planarGroup.id) ?? null - : null; - const segmentLineMode = - planarKind === ANNOTATION_TYPE_POLYLINE - ? planarGroup.segmentLineMode ?? - fallbackPolylineSegmentLineMode ?? - LINEAR_SEGMENT_LINE_MODE_COMPONENTS - : null; - const annotationLockedById = new Map( - annotations.map((entry) => [entry.id, Boolean(entry.locked)] as const) - ); - const isLocked = - planarGroup.vertexPointIds.length > 0 && - planarGroup.vertexPointIds.every((vertexId) => - Boolean(annotationLockedById.get(vertexId)) - ); - - return { - planarGroup, - slotsInput: { - kind: planarKind, - groupId: planarGroup.id, - name: planarGroup.name, - order, - totalLengthMeters: - planarGroup.perimeterMeters ?? polyline?.totalLengthMeters ?? 0, - areaSquareMeters: planarGroup.areaSquareMeters, - bearingDeg: planarGroup.bearingDeg, - verticalityDeg: planarGroup.verticalityDeg, - segmentLineMode, - polylineSummary: getPolylineSummary(polyline), - hidden: Boolean(planarGroup.hidden), - locked: isLocked, - actions, - }, - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationInfoBoxSlots.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationInfoBoxSlots.tsx index ba54459786..354ed38a90 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationInfoBoxSlots.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationInfoBoxSlots.tsx @@ -13,10 +13,7 @@ import { export const getPointAnnotationInfoBoxSlots = ( input: PointAnnotationSlotsInput ): AnnotationSlots => ({ - headingTitle: - input.measurement || !input.isLivePreview - ? POINT_TITLE - : `${POINT_TITLE} (Neu)`, + headingTitle: POINT_TITLE, subtitle: renderEditableAnnotationSubtitle({ annotationTypeTitle: POINT_TITLE, titleToken: getPointTitleToken(input), @@ -26,6 +23,6 @@ export const getPointAnnotationInfoBoxSlots = ( actions: input.actions, }), content: renderRelativeElevationContent(input.relativeElevation), - collapsible: Boolean(input.measurement || input.isLivePreview), - instructionText: input.isLivePreview ? POINT_MODE_INSTRUCTION : null, + collapsible: Boolean(input.measurement || input.isCandidate), + instructionText: input.isCandidate ? POINT_MODE_INSTRUCTION : null, }); diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationSlotsInput.ts index 30956dee32..34345b4997 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/getPointAnnotationSlotsInput.ts @@ -1,7 +1,7 @@ import { ANNOTATION_TYPE_POINT, type AnnotationMode, - type AnnotationListType, + type AnnotationToolType, type PointAnnotationEntry, } from "@carma-mapping/annotations/core"; import type { @@ -14,28 +14,24 @@ import { resolvePointRelativeElevation, } from "./utils/pointAnnotationDisplay"; type GetPointMeasurementSlotsInputParams = { - annotationMode: AnnotationMode; - pointLabelOnCreate: boolean; + activeToolType: AnnotationToolType; measurement: PointAnnotationEntry | null; referencePoint: PointAnnotationEntry["geometryECEF"] | null; getAnnotationOrderByType: ( - type: AnnotationListType, + type: AnnotationMode, id: string | null | undefined ) => number | null; - getNextAnnotationOrderByType: ( - type: AnnotationListType - ) => number; + getNextAnnotationOrderByType: (type: AnnotationMode) => number; actions: AnnotationSlotActions; }; export type PointMeasurementSlotsInputResult = { slotsInput: PointAnnotationSlotsInput; - isPointLivePreview: boolean; + isPointCandidate: boolean; }; export const getPointAnnotationSlotsInput = ({ - annotationMode, - pointLabelOnCreate, + activeToolType, measurement, referencePoint, getAnnotationOrderByType, @@ -43,8 +39,7 @@ export const getPointAnnotationSlotsInput = ({ actions, }: GetPointMeasurementSlotsInputParams): PointMeasurementSlotsInputResult => { const displayPoint = resolvePointAnnotationDisplayPoint(measurement); - const isPointLivePreview = - annotationMode === ANNOTATION_TYPE_POINT && !pointLabelOnCreate; + const isPointCandidate = activeToolType === ANNOTATION_TYPE_POINT; return { slotsInput: { @@ -56,11 +51,14 @@ export const getPointAnnotationSlotsInput = ({ referencePoint ), isReference: isPointReferenceMeasurement(measurement, referencePoint), - currentOrder: getAnnotationOrderByType("pointMeasure", measurement?.id), - nextOrder: getNextAnnotationOrderByType("pointMeasure"), - isLivePreview: isPointLivePreview, + currentOrder: getAnnotationOrderByType( + ANNOTATION_TYPE_POINT, + measurement?.id + ), + nextOrder: getNextAnnotationOrderByType(ANNOTATION_TYPE_POINT), + isCandidate: isPointCandidate, actions, }, - isPointLivePreview, + isPointCandidate, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxDisplaySelection.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxDisplaySelection.ts index ab1ad72671..339322265a 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxDisplaySelection.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxDisplaySelection.ts @@ -4,89 +4,84 @@ import { isPointAnnotationEntry, ANNOTATION_TYPE_DISTANCE, ANNOTATION_TYPE_POINT, - type AnnotationEntry, - type AnnotationMode, + type AnnotationToolType, type PointAnnotationEntry, - useAnnotations, - useAnnotationSelection, } from "@carma-mapping/annotations/core"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; +import { + useCandidateAnnotation, + useAnnotationCollection, + useAnnotationSelectionState, + useAnnotationTools, +} from "../../context/AnnotationsProvider"; export type AnnotationInfoBoxDisplaySelection = { - annotationMode: AnnotationMode; - pointLabelOnCreate: boolean; - isPointModeLivePreviewActive: boolean; - isDistanceModeLivePreviewActive: boolean; - pointMeasurements: ReadonlyArray; + activeToolType: AnnotationToolType; + isPointCandidateModeActive: boolean; + isDistanceCandidateModeActive: boolean; + pointEntries: ReadonlyArray; currentMeasurement: PointAnnotationEntry | null; displayMeasurement: PointAnnotationEntry | null; }; export const useAnnotationInfoBoxDisplaySelection = (): AnnotationInfoBoxDisplaySelection => { - const { - annotationMode, - annotations, - liveAnnotationCandidate, - pointLabelOnCreate, - } = useAnnotations(); - const { selectedMeasurementId } = useAnnotationSelection(); - const { activeMeasurementId } = useAnnotationsAdapter(); - - const isPointModeLivePreviewActive = - annotationMode === ANNOTATION_TYPE_POINT && !pointLabelOnCreate; - const isDistanceModeLivePreviewActive = - annotationMode === ANNOTATION_TYPE_DISTANCE; - const isLivePreviewMode = - isPointModeLivePreviewActive || isDistanceModeLivePreviewActive; + const tools = useAnnotationTools(); + const annotations = useAnnotationCollection(); + const selection = useAnnotationSelectionState(); + const candidateAnnotation = useCandidateAnnotation(); - const effectiveMeasurementId = isLivePreviewMode - ? activeMeasurementId ?? selectedMeasurementId - : selectedMeasurementId ?? activeMeasurementId; + const isPointCandidateModeActive = + tools.activeToolType === ANNOTATION_TYPE_POINT; + const isDistanceCandidateModeActive = + tools.activeToolType === ANNOTATION_TYPE_DISTANCE; + const primarySelectedAnnotationId = + selection.ids[selection.ids.length - 1] ?? null; + const effectiveMeasurementId = isPointCandidateModeActive + ? primarySelectedAnnotationId ?? selection.activeAnnotationId + : isDistanceCandidateModeActive + ? selection.activeAnnotationId ?? primarySelectedAnnotationId + : primarySelectedAnnotationId ?? selection.activeAnnotationId; - const pointMeasurements = useMemo( - () => annotations.filter(isPointAnnotationEntry), - [annotations] + const pointEntries = useMemo( + () => annotations.items.filter(isPointAnnotationEntry), + [annotations.items] ); - const currentMeasurement = useMemo( () => - pointMeasurements.find( + pointEntries.find( (measurement) => measurement.id === effectiveMeasurementId ) ?? null, - [effectiveMeasurementId, pointMeasurements] + [effectiveMeasurementId, pointEntries] ); - const livePreviewMeasurement = useMemo( + const candidateMeasurement = useMemo( () => - liveAnnotationCandidate && - isPointAnnotationEntry(liveAnnotationCandidate) - ? liveAnnotationCandidate + candidateAnnotation && isPointAnnotationEntry(candidateAnnotation) + ? candidateAnnotation : null, - [liveAnnotationCandidate] + [candidateAnnotation] ); const displayMeasurement = useMemo( () => - livePreviewMeasurement - ? livePreviewMeasurement - : isPointModeLivePreviewActive || isDistanceModeLivePreviewActive - ? null + isPointCandidateModeActive + ? currentMeasurement + : isDistanceCandidateModeActive + ? candidateMeasurement : currentMeasurement, [ currentMeasurement, - isDistanceModeLivePreviewActive, - isPointModeLivePreviewActive, - livePreviewMeasurement, + isDistanceCandidateModeActive, + isPointCandidateModeActive, + candidateMeasurement, ] ); return { - annotationMode, - pointLabelOnCreate, - isPointModeLivePreviewActive, - isDistanceModeLivePreviewActive, - pointMeasurements, + activeToolType: tools.activeToolType, + isPointCandidateModeActive, + isDistanceCandidateModeActive, + pointEntries, currentMeasurement, displayMeasurement, }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxNavigationBindings.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxNavigationBindings.ts index e54feb9531..d6d7795bb1 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxNavigationBindings.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxNavigationBindings.ts @@ -6,22 +6,14 @@ import { ANNOTATION_TYPE_AREA_VERTICAL, ANNOTATION_TYPE_LABEL, ANNOTATION_TYPE_POLYLINE, - useAnnotations, - useAnnotationSelection, -} from "@carma-mapping/annotations/core"; -import type { - AnnotationEntry, - AnnotationMode, - PointAnnotationEntry, + type PointAnnotationEntry, } from "@carma-mapping/annotations/core"; import type { AnnotationSlotKind } from "./getAnnotationInfoBoxSlots"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; - -type UseAnnotationInfoBoxNavigationBindingsParams = { - annotationType: AnnotationSlotKind; - currentMeasurementId: string | null; - labelMeasurements: ReadonlyArray; -}; +import { + useAnnotationCollection, + useNodeChainAnnotationReadModel, + useAnnotationSelectionState, +} from "../../context/AnnotationsProvider"; export type AnnotationInfoBoxNavigationBindings = { navigationMeasurements: ReadonlyArray<{ id: string }>; @@ -31,24 +23,14 @@ export type AnnotationInfoBoxNavigationBindings = { onFlyToAllMeasurements: () => void; }; -export const useAnnotationInfoBoxNavigationBindings = ({ - annotationType, - currentMeasurementId, - labelMeasurements, -}: UseAnnotationInfoBoxNavigationBindingsParams): AnnotationInfoBoxNavigationBindings => { - const { getAnnotationsForNavigation } = useAnnotations< - AnnotationMode, - AnnotationEntry - >(); - const { selectMeasurementById } = useAnnotationSelection(); - const { - planarPolygonGroups, - activePlanarPolygonGroupId, - selectedPlanarPolygonGroupId, - selectPlanarPolygonGroupById, - flyToMeasurementById, - flyToAllMeasurements, - } = useAnnotationsAdapter(); +export const useAnnotationInfoBoxNavigationBindings = ( + annotationType: AnnotationSlotKind, + currentMeasurementId: string | null, + labelMeasurements: ReadonlyArray +): AnnotationInfoBoxNavigationBindings => { + const annotations = useAnnotationCollection(); + const selection = useAnnotationSelectionState(); + const nodeChainReadModel = useNodeChainAnnotationReadModel(); const navigationMeasurements = useMemo(() => { if (annotationType === ANNOTATION_TYPE_LABEL) { @@ -60,52 +42,63 @@ export const useAnnotationInfoBoxNavigationBindings = ({ annotationType === ANNOTATION_TYPE_AREA_PLANAR || annotationType === ANNOTATION_TYPE_AREA_VERTICAL ) { - return planarPolygonGroups.map((group) => ({ id: group.id })); + return nodeChainReadModel.measurements.map((measurement) => ({ + id: measurement.id, + })); } - return getAnnotationsForNavigation().map((entry) => ({ id: entry.id })); + return annotations.getNavigationItems().map((entry) => ({ id: entry.id })); }, [ annotationType, - getAnnotationsForNavigation, + annotations, labelMeasurements, - planarPolygonGroups, + nodeChainReadModel.measurements, ]); - const currentNavigationId = + const isNodeChainAnnotationType = annotationType === ANNOTATION_TYPE_POLYLINE || annotationType === ANNOTATION_TYPE_AREA_GROUND || annotationType === ANNOTATION_TYPE_AREA_PLANAR || - annotationType === ANNOTATION_TYPE_AREA_VERTICAL - ? activePlanarPolygonGroupId ?? selectedPlanarPolygonGroupId - : currentMeasurementId; + annotationType === ANNOTATION_TYPE_AREA_VERTICAL; + + const currentNavigationId = useMemo(() => { + if (!isNodeChainAnnotationType) { + return currentMeasurementId; + } + + const activeNodeChainAnnotation = + nodeChainReadModel.activeMeasurementId !== null + ? nodeChainReadModel.measurements.find( + (measurement) => + measurement.id === nodeChainReadModel.activeMeasurementId + ) ?? null + : null; + const requiredVertexCount = + activeNodeChainAnnotation?.type === ANNOTATION_TYPE_POLYLINE ? 2 : 3; + const canUseActiveNodeChainAnnotation = + activeNodeChainAnnotation !== null && + activeNodeChainAnnotation.nodeIds.length >= requiredVertexCount; + + return canUseActiveNodeChainAnnotation + ? activeNodeChainAnnotation.id + : nodeChainReadModel.focusedMeasurementId; + }, [ + currentMeasurementId, + isNodeChainAnnotationType, + nodeChainReadModel.activeMeasurementId, + nodeChainReadModel.focusedMeasurementId, + nodeChainReadModel.measurements, + ]); const handleNavigationSelection = (id: string | null) => { - if ( - annotationType === ANNOTATION_TYPE_POLYLINE || - annotationType === ANNOTATION_TYPE_AREA_GROUND || - annotationType === ANNOTATION_TYPE_AREA_PLANAR || - annotationType === ANNOTATION_TYPE_AREA_VERTICAL - ) { - selectPlanarPolygonGroupById(id); + if (isNodeChainAnnotationType) { + annotations.focusById(id); return; } - selectMeasurementById(id); + selection.set(id ? [id] : []); }; const handleNavigationFlyTo = (id: string) => { - if ( - annotationType === ANNOTATION_TYPE_POLYLINE || - annotationType === ANNOTATION_TYPE_AREA_GROUND || - annotationType === ANNOTATION_TYPE_AREA_PLANAR || - annotationType === ANNOTATION_TYPE_AREA_VERTICAL - ) { - const group = planarPolygonGroups.find((entry) => entry.id === id); - const firstVertexId = group?.vertexPointIds[0] ?? null; - if (firstVertexId) { - flyToMeasurementById(firstVertexId); - } - return; - } - flyToMeasurementById(id); + annotations.flyToById(id); }; return { @@ -113,6 +106,6 @@ export const useAnnotationInfoBoxNavigationBindings = ({ currentNavigationId, handleNavigationSelection, handleNavigationFlyTo, - onFlyToAllMeasurements: flyToAllMeasurements, + onFlyToAllMeasurements: annotations.flyToAll, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxPayload.tsx b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxPayload.tsx index 95b381748d..e6a38aecba 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxPayload.tsx +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxPayload.tsx @@ -18,20 +18,16 @@ import { useAnnotationInfoBoxNavigationBindings } from "./useAnnotationInfoBoxNa import { useAnnotationInfoNavigationState } from "./useAnnotationInfoNavigationState"; import { useDistanceInfoBoxSlotsInput } from "./useDistanceInfoBoxSlotsInput"; import { useLabelInfoBoxSlotsInput } from "./useLabelInfoBoxSlotsInput"; -import { usePlanarInfoBoxSlotsInput } from "./usePlanarInfoBoxSlotsInput"; +import { useNodeChainInfoBoxSlotsInput } from "./useNodeChainInfoBoxSlotsInput"; import { usePointInfoBoxSlotsInput } from "./usePointInfoBoxSlotsInput"; -type UseAnnotationInfoBoxPayloadParams = { - pixelWidth: number; -}; - -export const useAnnotationInfoBoxPayload = ({ - pixelWidth, -}: UseAnnotationInfoBoxPayloadParams): AnnotationInfoBoxPayload => { +export const useAnnotationInfoBoxPayload = ( + pixelWidth: number +): AnnotationInfoBoxPayload => { const distanceState = useDistanceInfoBoxSlotsInput(); const pointState = usePointInfoBoxSlotsInput(); const labelState = useLabelInfoBoxSlotsInput(); - const planarState = usePlanarInfoBoxSlotsInput(); + const nodeChainState = useNodeChainInfoBoxSlotsInput(); const annotationType: AnnotationSlotKind = distanceState.isDistanceKind ? ANNOTATION_TYPE_DISTANCE @@ -39,7 +35,7 @@ export const useAnnotationInfoBoxPayload = ({ ? ANNOTATION_TYPE_POINT : labelState.isLabelKind ? ANNOTATION_TYPE_LABEL - : planarState.slotsInput?.kind ?? "unsupported"; + : nodeChainState.slotsInput?.kind ?? "unsupported"; const slotsInput: AnnotationSlotsInput = annotationType === ANNOTATION_TYPE_DISTANCE @@ -52,7 +48,7 @@ export const useAnnotationInfoBoxPayload = ({ annotationType === ANNOTATION_TYPE_AREA_GROUND || annotationType === ANNOTATION_TYPE_AREA_PLANAR || annotationType === ANNOTATION_TYPE_AREA_VERTICAL - ? planarState.slotsInput ?? { kind: "unsupported" } + ? nodeChainState.slotsInput ?? { kind: "unsupported" } : { kind: "unsupported" }; const { @@ -61,24 +57,32 @@ export const useAnnotationInfoBoxPayload = ({ handleNavigationSelection, handleNavigationFlyTo, onFlyToAllMeasurements, - } = useAnnotationInfoBoxNavigationBindings({ + } = useAnnotationInfoBoxNavigationBindings( annotationType, - currentMeasurementId: distanceState.currentMeasurementId, - labelMeasurements: labelState.labelMeasurements, - }); + annotationType === ANNOTATION_TYPE_DISTANCE + ? distanceState.currentMeasurementId + : annotationType === ANNOTATION_TYPE_POINT + ? pointState.currentMeasurementId + : annotationType === ANNOTATION_TYPE_LABEL + ? labelState.currentMeasurementId + : distanceState.currentMeasurementId, + labelState.labelMeasurements + ); const { currentIndex, totalEntries, onPreviousMeasurement, onNextMeasurement, - } = useAnnotationInfoNavigationState({ + } = useAnnotationInfoNavigationState( navigationMeasurements, - currentMeasurementId: currentNavigationId, - onSelectMeasurementById: handleNavigationSelection, - onFlyToMeasurementById: handleNavigationFlyTo, - onFlyToAllMeasurements, - }); + currentNavigationId, + { + onSelectMeasurementById: handleNavigationSelection, + onFlyToMeasurementById: handleNavigationFlyTo, + onFlyToAllMeasurements, + } + ); const slots = getAnnotationInfoBoxSlots(slotsInput); diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxSlotActions.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxSlotActions.ts index 6a07e3e56d..33c4d37bcb 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxSlotActions.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoBoxSlotActions.ts @@ -1,121 +1,24 @@ import { useMemo } from "react"; -import { - type AnnotationEntry, - type AnnotationMode, - useAnnotations, -} from "@carma-mapping/annotations/core"; import type { AnnotationSlotActions } from "./getAnnotationInfoBoxSlots"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; +import { useAnnotationCollection } from "../../context/AnnotationsProvider"; export const useAnnotationInfoBoxSlotActions = (): AnnotationSlotActions => { - const { - annotations, - setAnnotations, - updateAnnotationNameById, - updateAnnotationById, - deleteAnnotationById, - toggleAnnotationLockById, - updatePointLabelAppearanceById, - confirmPointLabelInputById, - clearAnnotationsByIds, - } = useAnnotations(); - const { - flyToMeasurementById, - setReferencePoint, - planarPolygonGroups, - setPlanarPolygonGroups, - updatePlanarPolygonNameById, - selectPlanarPolygonGroupById, - } = useAnnotationsAdapter(); + const annotations = useAnnotationCollection(); return useMemo( () => ({ - updateAnnotationNameById, - updateAnnotationById, - deleteAnnotationById, - toggleAnnotationLockById, - flyToMeasurementById, - flyToPlanarPolygonGroupById: (groupId: string) => { - const group = planarPolygonGroups.find((entry) => entry.id === groupId); - const firstVertexId = group?.vertexPointIds[0]; - if (!firstVertexId) return; - flyToMeasurementById(firstVertexId); - }, - togglePlanarPolygonGroupVisibilityById: (groupId: string) => { - setPlanarPolygonGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - hidden: !group.hidden, - } - : group - ) - ); - }, - togglePlanarPolygonGroupLockById: (groupId: string) => { - const group = planarPolygonGroups.find((entry) => entry.id === groupId); - if (!group || group.vertexPointIds.length === 0) return; - const vertexIdSet = new Set(group.vertexPointIds); - const shouldLock = group.vertexPointIds.some((vertexId) => { - const vertex = annotations.find((entry) => entry.id === vertexId); - return !vertex?.locked; - }); - setAnnotations((prev) => - prev.map((entry) => - vertexIdSet.has(entry.id) - ? { - ...entry, - locked: shouldLock, - } - : entry - ) - ); - }, - setReferencePoint, - confirmPointLabelInputById, - updatePointLabelAppearanceById, - updatePlanarPolygonNameById, - updatePlanarPolygonSegmentLineModeById: (groupId, nextMode) => { - setPlanarPolygonGroups((prev) => - prev.map((group) => - group.id === groupId - ? { - ...group, - segmentLineMode: nextMode, - } - : group - ) - ); - }, - deletePlanarPolygonGroupById: (groupId: string) => { - const group = planarPolygonGroups.find((entry) => entry.id === groupId); - if (!group) return; - const vertexIds = group.vertexPointIds.filter( - (vertexId): vertexId is string => Boolean(vertexId) - ); - if (vertexIds.length === 0) return; - clearAnnotationsByIds(vertexIds); - selectPlanarPolygonGroupById(null); - }, + updateNameById: annotations.updateNameById, + removeByIds: annotations.removeByIds, + toggleLockByIds: annotations.toggleLockByIds, + toggleVisibilityByIds: annotations.toggleVisibilityByIds, + flyToById: annotations.flyToById, + setReferencePointId: annotations.setReferencePointId, + confirmLabelPlacementById: annotations.confirmLabelPlacementById, + updatePointLabelAppearanceById: + annotations.updatePointLabelAppearanceById, + updateVisualizerOptionsById: annotations.updateVisualizerOptionsById, }), - [ - annotations, - clearAnnotationsByIds, - confirmPointLabelInputById, - deleteAnnotationById, - flyToMeasurementById, - planarPolygonGroups, - selectPlanarPolygonGroupById, - setAnnotations, - setPlanarPolygonGroups, - setReferencePoint, - toggleAnnotationLockById, - updateAnnotationById, - updateAnnotationNameById, - updatePlanarPolygonNameById, - updatePointLabelAppearanceById, - ] + [annotations] ); }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoNavigationState.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoNavigationState.ts index da9ce23e70..1b8d2fbde5 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoNavigationState.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useAnnotationInfoNavigationState.ts @@ -4,21 +4,21 @@ type NavigationMeasurement = { id: string; }; -type UseAnnotationInfoNavigationStateParams = { - navigationMeasurements: ReadonlyArray; - currentMeasurementId: string | null; +type UseAnnotationInfoNavigationStateOptions = { onSelectMeasurementById: (id: string | null) => void; onFlyToMeasurementById: (id: string) => void; onFlyToAllMeasurements: () => void; }; -export const useAnnotationInfoNavigationState = ({ - navigationMeasurements, - currentMeasurementId, - onSelectMeasurementById, - onFlyToMeasurementById, - onFlyToAllMeasurements, -}: UseAnnotationInfoNavigationStateParams) => { +export const useAnnotationInfoNavigationState = ( + navigationMeasurements: ReadonlyArray, + currentMeasurementId: string | null, + { + onSelectMeasurementById, + onFlyToMeasurementById, + onFlyToAllMeasurements, + }: UseAnnotationInfoNavigationStateOptions +) => { const navigableMeasurements = useMemo( () => navigationMeasurements, [navigationMeasurements] diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useDistanceInfoBoxSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useDistanceInfoBoxSlotsInput.ts index a4ddd9f762..030d841f73 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useDistanceInfoBoxSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useDistanceInfoBoxSlotsInput.ts @@ -1,11 +1,16 @@ import { useMemo } from "react"; -import { useAnnotations } from "@carma-mapping/annotations/core"; -import type { - AnnotationEntry, - AnnotationMode, +import { + isPointMeasurementEntry, + type PointMeasurementEntry, } from "@carma-mapping/annotations/core"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; +import { + useAnnotationCollection, + useDistanceAnnotationReadModel, + useNodeChainAnnotations, + useAnnotationSelectionState, +} from "../../context/AnnotationsProvider"; +import { usePointMarkerBadgeState } from "../../context/render/point/label/usePointMarkerBadgeState"; import type { DistanceAnnotationSlotsInput } from "./getAnnotationInfoBoxSlots"; import { getDistanceAnnotationSlotsInput } from "./getDistanceAnnotationSlotsInput"; import { useAnnotationInfoBoxDisplaySelection } from "./useAnnotationInfoBoxDisplaySelection"; @@ -20,66 +25,73 @@ export type DistanceInfoBoxSlotsInputState = { export const useDistanceInfoBoxSlotsInput = (): DistanceInfoBoxSlotsInputState => { const { - annotationMode, - isDistanceModeLivePreviewActive, - pointMeasurements, + activeToolType, + isDistanceCandidateModeActive, + pointEntries, displayMeasurement, currentMeasurement, } = useAnnotationInfoBoxDisplaySelection(); - const { - activeMeasurementId, - referencePoint, - hasDistancePreviewAnchor, - distanceRelations, - pointMarkerBadgeByPointId, - } = useAnnotationsAdapter(); - const { getAnnotationOrderByType, getNextAnnotationOrderByType } = - useAnnotations(); + const selection = useAnnotationSelectionState(); + const distanceReadModel = useDistanceAnnotationReadModel(); + const nodeChainAnnotations = useNodeChainAnnotations(); + const annotations = useAnnotationCollection(); const actions = useAnnotationInfoBoxSlotActions(); + const pointMeasureEntries = useMemo( + () => + annotations.items.filter( + (annotation): annotation is PointMeasurementEntry => + isPointMeasurementEntry(annotation) + ), + [annotations.items] + ); + const { pointMarkerBadgeByPointId } = usePointMarkerBadgeState( + pointEntries, + pointMeasureEntries, + nodeChainAnnotations, + distanceReadModel.distanceRelations + ); const isDistanceMeasurement = useMemo( () => displayMeasurement !== null && - distanceRelations.some( + distanceReadModel.distanceRelations.some( (relation) => relation.pointAId === displayMeasurement.id || relation.pointBId === displayMeasurement.id ), - [displayMeasurement, distanceRelations] + [displayMeasurement, distanceReadModel.distanceRelations] ); const slotsInput = useMemo( () => getDistanceAnnotationSlotsInput({ - annotationMode, + activeToolType, measurement: displayMeasurement, - activeMeasurementId, - pointMeasurements, - referencePoint, - hasDistancePreviewAnchor, - distanceRelations, + activeMeasurementId: selection.activeAnnotationId, + pointEntries, + referencePoint: distanceReadModel.referencePoint, + hasDistancePreviewAnchor: distanceReadModel.hasPreviewAnchor, + distanceRelations: distanceReadModel.distanceRelations, pointMarkerBadgeByPointId, - getAnnotationOrderByType, - getNextAnnotationOrderByType, + getAnnotationOrderByType: annotations.getOrderByType, + getNextAnnotationOrderByType: annotations.getNextOrderByType, actions, }).slotsInput, [ actions, - activeMeasurementId, + activeToolType, + annotations, + distanceReadModel, displayMeasurement, - distanceRelations, - getAnnotationOrderByType, - getNextAnnotationOrderByType, - hasDistancePreviewAnchor, - annotationMode, pointMarkerBadgeByPointId, - pointMeasurements, - referencePoint, + pointMeasureEntries, + pointEntries, + selection.activeAnnotationId, ] ); return { - isDistanceKind: isDistanceModeLivePreviewActive || isDistanceMeasurement, + isDistanceKind: isDistanceCandidateModeActive || isDistanceMeasurement, slotsInput, currentMeasurementId: currentMeasurement?.id ?? null, }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useLabelInfoBoxSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useLabelInfoBoxSlotsInput.ts index 45d1155366..e027945349 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useLabelInfoBoxSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useLabelInfoBoxSlotsInput.ts @@ -1,50 +1,66 @@ import { useMemo } from "react"; import { - isPointAnnotationEntry, - type AnnotationEntry, - type AnnotationMode, - type PointAnnotationEntry, - useAnnotations, + ANNOTATION_TYPE_POINT, + isPointMeasurementEntry, + type PointMeasurementEntry, } from "@carma-mapping/annotations/core"; import type { LabelAnnotationSlotsInput } from "./getAnnotationInfoBoxSlots"; import { getLabelAnnotationSlotsInput } from "./getLabelAnnotationSlotsInput"; import { useAnnotationInfoBoxDisplaySelection } from "./useAnnotationInfoBoxDisplaySelection"; import { useAnnotationInfoBoxSlotActions } from "./useAnnotationInfoBoxSlotActions"; +import { + useAnnotationCollection, + usePendingLabelPlacementTargetId, +} from "../../context/AnnotationsProvider"; export type LabelInfoBoxSlotsInputState = { isLabelKind: boolean; slotsInput: LabelAnnotationSlotsInput; - labelMeasurements: ReadonlyArray; + labelMeasurements: ReadonlyArray; + currentMeasurementId: string | null; }; export const useLabelInfoBoxSlotsInput = (): LabelInfoBoxSlotsInputState => { const { displayMeasurement } = useAnnotationInfoBoxDisplaySelection(); - const { annotationsByType, labelInputPromptPointId } = useAnnotations< - AnnotationMode, - AnnotationEntry - >(); + const annotations = useAnnotationCollection(); + const pendingLabelPlacementTargetId = usePendingLabelPlacementTargetId(); const actions = useAnnotationInfoBoxSlotActions(); + const displayLabelMeasurement = + displayMeasurement && isPointMeasurementEntry(displayMeasurement) + ? displayMeasurement + : null; + const labelMeasurements = useMemo( - () => annotationsByType("pointLabel").filter(isPointAnnotationEntry), - [annotationsByType] + () => + annotations + .byType(ANNOTATION_TYPE_POINT) + .filter(isPointMeasurementEntry) + .filter((measurement) => Boolean(measurement.auxiliaryLabelAnchor)), + [annotations] ); const labelState = useMemo( () => getLabelAnnotationSlotsInput({ - measurement: displayMeasurement, + measurement: displayLabelMeasurement, labelMeasurements, - labelInputPromptPointId, + labelInputPromptPointId: pendingLabelPlacementTargetId, actions, }), - [actions, displayMeasurement, labelInputPromptPointId, labelMeasurements] + [ + actions, + displayLabelMeasurement, + labelMeasurements, + pendingLabelPlacementTargetId, + ] ); return { - isLabelKind: labelState.isLabelLivePreview || labelState.isLabelMeasurement, + isLabelKind: labelState.isLabelCandidate || labelState.isLabelMeasurement, slotsInput: labelState.slotsInput, labelMeasurements, + currentMeasurementId: displayLabelMeasurement?.id ?? null, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useNodeChainInfoBoxSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useNodeChainInfoBoxSlotsInput.ts new file mode 100644 index 0000000000..77862b3e99 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/useNodeChainInfoBoxSlotsInput.ts @@ -0,0 +1,54 @@ +import { useMemo } from "react"; + +import type { PolygonPolylineAnnotationSlotsInput } from "./getAnnotationInfoBoxSlots"; +import { getNodeChainAnnotationSlotsInput } from "./getNodeChainAnnotationSlotsInput"; +import { useAnnotationInfoBoxSlotActions } from "./useAnnotationInfoBoxSlotActions"; +import { + useAnnotationCollection, + useNodeChainAnnotationReadModel, + useAnnotationSettings, +} from "../../context/AnnotationsProvider"; + +export type NodeChainInfoBoxSlotsInputState = { + slotsInput: PolygonPolylineAnnotationSlotsInput | null; +}; + +export const useNodeChainInfoBoxSlotsInput = + (): NodeChainInfoBoxSlotsInputState => { + const annotations = useAnnotationCollection(); + const settings = useAnnotationSettings(); + const nodeChainReadModel = useNodeChainAnnotationReadModel(); + const actions = useAnnotationInfoBoxSlotActions(); + + const slotsInput = useMemo( + () => + getNodeChainAnnotationSlotsInput({ + polylineMeasurements: nodeChainReadModel.polylineMeasurements, + groundPolygons: nodeChainReadModel.groundPolygons, + planarPolygons: nodeChainReadModel.planarPolygons, + verticalPolygons: nodeChainReadModel.verticalPolygons, + polylinePaths: nodeChainReadModel.polylinePaths, + annotations: annotations.items, + fallbackPolylineSegmentLineMode: settings.polyline.segmentLineMode, + focusedNodeChainAnnotationId: nodeChainReadModel.focusedMeasurementId, + activeNodeChainAnnotationId: nodeChainReadModel.activeMeasurementId, + actions, + }).slotsInput, + [ + actions, + annotations.items, + nodeChainReadModel.activeMeasurementId, + nodeChainReadModel.focusedMeasurementId, + settings.polyline.segmentLineMode, + nodeChainReadModel.groundPolygons, + nodeChainReadModel.planarPolygons, + nodeChainReadModel.polylineMeasurements, + nodeChainReadModel.polylinePaths, + nodeChainReadModel.verticalPolygons, + ] + ); + + return { + slotsInput, + }; + }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePlanarInfoBoxSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePlanarInfoBoxSlotsInput.ts deleted file mode 100644 index f99fa2b898..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePlanarInfoBoxSlotsInput.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { useMemo } from "react"; - -import type { PolygonPolylineAnnotationSlotsInput } from "./getAnnotationInfoBoxSlots"; -import { getPlanarAnnotationSlotsInput } from "./getPlanarAnnotationSlotsInput"; -import { useAnnotationInfoBoxSlotActions } from "./useAnnotationInfoBoxSlotActions"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; - -export type PlanarInfoBoxSlotsInputState = { - slotsInput: PolygonPolylineAnnotationSlotsInput | null; -}; - -export const usePlanarInfoBoxSlotsInput = (): PlanarInfoBoxSlotsInputState => { - const { - annotations, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - polylines, - polylineSegmentLineMode, - } = useAnnotationsAdapter(); - const actions = useAnnotationInfoBoxSlotActions(); - - const slotsInput = useMemo( - () => - getPlanarAnnotationSlotsInput({ - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - polylines, - annotations, - fallbackPolylineSegmentLineMode: polylineSegmentLineMode, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - actions, - }).slotsInput, - [ - actions, - activePlanarPolygonGroupId, - areaPolygonGroups, - annotations, - planarSurfacePolygonGroups, - polylines, - polylineGroups, - polylineSegmentLineMode, - selectedPlanarPolygonGroupId, - verticalPolygonGroups, - ] - ); - - return { - slotsInput, - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePointInfoBoxSlotsInput.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePointInfoBoxSlotsInput.ts index c8cfed0691..6643a9be42 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePointInfoBoxSlotsInput.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/usePointInfoBoxSlotsInput.ts @@ -1,15 +1,14 @@ import { useMemo } from "react"; -import { useAnnotations } from "@carma-mapping/annotations/core"; -import type { - AnnotationEntry, - AnnotationMode, -} from "@carma-mapping/annotations/core"; +import { isPointMeasurementEntry } from "@carma-mapping/annotations/core"; import type { PointAnnotationSlotsInput } from "./getAnnotationInfoBoxSlots"; import { getPointAnnotationSlotsInput } from "./getPointAnnotationSlotsInput"; import { useAnnotationInfoBoxDisplaySelection } from "./useAnnotationInfoBoxDisplaySelection"; import { useAnnotationInfoBoxSlotActions } from "./useAnnotationInfoBoxSlotActions"; -import { useAnnotationsAdapter } from "../../context/AnnotationsAdapterProvider"; +import { + useAnnotationCollection, + useReferencePoint, +} from "../../context/AnnotationsProvider"; export type PointInfoBoxSlotsInputState = { isPointKind: boolean; @@ -19,46 +18,51 @@ export type PointInfoBoxSlotsInputState = { export const usePointInfoBoxSlotsInput = (): PointInfoBoxSlotsInputState => { const { - annotationMode, - pointLabelOnCreate, - isPointModeLivePreviewActive, + activeToolType, + isPointCandidateModeActive, displayMeasurement, currentMeasurement, } = useAnnotationInfoBoxDisplaySelection(); - const { referencePoint } = useAnnotationsAdapter(); - const { getAnnotationOrderByType, getNextAnnotationOrderByType } = - useAnnotations(); + const referencePoint = useReferencePoint(); + const annotations = useAnnotationCollection(); const actions = useAnnotationInfoBoxSlotActions(); + const displayPointMeasurement = + displayMeasurement && isPointMeasurementEntry(displayMeasurement) + ? displayMeasurement + : null; + const currentPointMeasurement = + currentMeasurement && isPointMeasurementEntry(currentMeasurement) + ? currentMeasurement + : null; + const slotsInput = useMemo( () => getPointAnnotationSlotsInput({ - annotationMode, - pointLabelOnCreate, - measurement: displayMeasurement, + activeToolType, + measurement: displayPointMeasurement, referencePoint, - getAnnotationOrderByType, - getNextAnnotationOrderByType, + getAnnotationOrderByType: annotations.getOrderByType, + getNextAnnotationOrderByType: annotations.getNextOrderByType, actions, }).slotsInput, [ actions, - displayMeasurement, - getAnnotationOrderByType, - getNextAnnotationOrderByType, - annotationMode, - pointLabelOnCreate, + activeToolType, + annotations, + displayPointMeasurement, referencePoint, ] ); const isPointKind = - isPointModeLivePreviewActive || - (displayMeasurement !== null && !displayMeasurement.auxiliaryLabelAnchor); + isPointCandidateModeActive || + (displayPointMeasurement !== null && + !displayPointMeasurement.auxiliaryLabelAnchor); return { isPointKind, slotsInput, - currentMeasurementId: currentMeasurement?.id ?? null, + currentMeasurementId: currentPointMeasurement?.id ?? null, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/utils/pointAnnotationDisplay.ts b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/utils/pointAnnotationDisplay.ts index c67877b2ca..5b5b8c9111 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/utils/pointAnnotationDisplay.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/annotation-info-box/utils/pointAnnotationDisplay.ts @@ -61,12 +61,12 @@ export const isPointReferenceMeasurement = ( }; export const findReferencePointMeasurement = ({ - pointMeasurements, + pointEntries, referencePoint, }: { - pointMeasurements: ReadonlyArray; + pointEntries: ReadonlyArray; referencePoint: PointAnnotationEntry["geometryECEF"] | null; }): PointAnnotationEntry | null => - pointMeasurements.find((pointMeasurement) => - isPointReferenceMeasurement(pointMeasurement, referencePoint) + pointEntries.find((pointEntry) => + isPointReferenceMeasurement(pointEntry, referencePoint) ) ?? null; diff --git a/libraries/mapping/annotations/provider/src/lib/components/hooks/useAnnotationToolMode.ts b/libraries/mapping/annotations/provider/src/lib/components/hooks/useAnnotationToolMode.ts index 1ea6ce0fe4..2a028c5c36 100644 --- a/libraries/mapping/annotations/provider/src/lib/components/hooks/useAnnotationToolMode.ts +++ b/libraries/mapping/annotations/provider/src/lib/components/hooks/useAnnotationToolMode.ts @@ -1,13 +1,7 @@ import { useEffect, useState } from "react"; import { SELECT_TOOL_TYPE, - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_DISTANCE, - ANNOTATION_TYPE_LABEL, - ANNOTATION_TYPE_AREA_PLANAR, ANNOTATION_TYPE_POINT, - ANNOTATION_TYPE_POLYLINE, - ANNOTATION_TYPE_AREA_VERTICAL, type AnnotationToolType, } from "@carma-mapping/annotations/core"; @@ -32,145 +26,25 @@ const logToolDebug = (event: string, payload: Record) => { console.debug(`[AnnotationTools] ${event}`, payload); }; -interface UseAnnotationToolModeProps { - isSelectionMode: boolean; - isLabelMode: boolean; - isDistanceMode: boolean; - isAreaMode: boolean; - isVerticalMode: boolean; - isPlanarMode: boolean; - isPolylineMode: boolean; - onSelectMode: () => void; - onLabelMode: () => void; - onPointMode: () => void; - onDistanceMode: () => void; - onAreaMode: () => void; - onVerticalMode: () => void; - onPlanarMode: () => void; - onPolylineMode: () => void; -} - -const resolveToolType = ({ - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, -}: Pick< - UseAnnotationToolModeProps, - | "isSelectionMode" - | "isLabelMode" - | "isDistanceMode" - | "isAreaMode" - | "isVerticalMode" - | "isPlanarMode" - | "isPolylineMode" ->): AnnotationToolType => { - if (isSelectionMode) return SELECT_TOOL_TYPE; - if (isLabelMode) return ANNOTATION_TYPE_LABEL; - if (isDistanceMode) return ANNOTATION_TYPE_DISTANCE; - if (isAreaMode) return ANNOTATION_TYPE_AREA_GROUND; - if (isVerticalMode) return ANNOTATION_TYPE_AREA_VERTICAL; - if (isPlanarMode) return ANNOTATION_TYPE_AREA_PLANAR; - if (isPolylineMode) return ANNOTATION_TYPE_POLYLINE; - return ANNOTATION_TYPE_POINT; -}; - -export const useAnnotationToolMode = ({ - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, - onSelectMode, - onLabelMode, - onPointMode, - onDistanceMode, - onAreaMode, - onVerticalMode, - onPlanarMode, - onPolylineMode, -}: UseAnnotationToolModeProps) => { - const initialToolType = resolveToolType({ - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, - }); - +export const useAnnotationToolMode = ( + activeToolType: AnnotationToolType, + onToolTypeChange: (toolType: AnnotationToolType) => void +) => { const [lastNonSelectionToolType, setLastNonSelectionToolType] = useState( - initialToolType === SELECT_TOOL_TYPE + activeToolType === SELECT_TOOL_TYPE ? ANNOTATION_TYPE_POINT - : initialToolType + : activeToolType ); - const [activeToolType, setActiveToolType] = - useState(initialToolType); - - const triggerToolCallback = (toolType: AnnotationToolType) => { - logToolDebug("trigger-callback", { toolType }); - switch (toolType) { - case SELECT_TOOL_TYPE: - return onSelectMode(); - case ANNOTATION_TYPE_LABEL: - return onLabelMode(); - case ANNOTATION_TYPE_POINT: - return onPointMode(); - case ANNOTATION_TYPE_DISTANCE: - return onDistanceMode(); - case ANNOTATION_TYPE_POLYLINE: - return onPolylineMode(); - case ANNOTATION_TYPE_AREA_GROUND: - return onAreaMode(); - case ANNOTATION_TYPE_AREA_VERTICAL: - return onVerticalMode(); - case ANNOTATION_TYPE_AREA_PLANAR: - return onPlanarMode(); - } - }; useEffect(() => { - const resolved = resolveToolType({ - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, - }); logToolDebug("sync-from-flags", { - resolvedToolType: resolved, - flags: { - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, - }, + activeToolType, }); - setActiveToolType(resolved); - if (resolved !== SELECT_TOOL_TYPE) { - setLastNonSelectionToolType(resolved); + if (activeToolType !== SELECT_TOOL_TYPE) { + setLastNonSelectionToolType(activeToolType); } - }, [ - isSelectionMode, - isLabelMode, - isDistanceMode, - isAreaMode, - isVerticalMode, - isPlanarMode, - isPolylineMode, - ]); + }, [activeToolType]); const handleToolTypeChange = (toolType: AnnotationToolType) => { logToolDebug("tool-change-request", { @@ -180,22 +54,20 @@ export const useAnnotationToolMode = ({ }); if (toolType === activeToolType && toolType !== SELECT_TOOL_TYPE) { setLastNonSelectionToolType(toolType); - setActiveToolType(SELECT_TOOL_TYPE); logToolDebug("tool-change-branch", { branch: "active-non-select-clicked-switch-to-select", nextToolType: SELECT_TOOL_TYPE, }); - triggerToolCallback(SELECT_TOOL_TYPE); + onToolTypeChange(SELECT_TOOL_TYPE); return; } if (toolType === SELECT_TOOL_TYPE && activeToolType === SELECT_TOOL_TYPE) { - setActiveToolType(lastNonSelectionToolType); logToolDebug("tool-change-branch", { branch: "select-clicked-while-select-active-restore-last-non-select", nextToolType: lastNonSelectionToolType, }); - triggerToolCallback(lastNonSelectionToolType); + onToolTypeChange(lastNonSelectionToolType); return; } @@ -203,22 +75,20 @@ export const useAnnotationToolMode = ({ setLastNonSelectionToolType((prev) => activeToolType === SELECT_TOOL_TYPE ? prev : activeToolType ); - setActiveToolType(SELECT_TOOL_TYPE); logToolDebug("tool-change-branch", { branch: "switch-to-select", nextToolType: SELECT_TOOL_TYPE, }); - triggerToolCallback(SELECT_TOOL_TYPE); + onToolTypeChange(SELECT_TOOL_TYPE); return; } setLastNonSelectionToolType(toolType); - setActiveToolType(toolType); logToolDebug("tool-change-branch", { branch: "switch-to-requested-tool", nextToolType: toolType, }); - triggerToolCallback(toolType); + onToolTypeChange(toolType); }; return { diff --git a/libraries/mapping/annotations/provider/src/lib/context/AnnotationsAdapterProvider.tsx b/libraries/mapping/annotations/provider/src/lib/context/AnnotationsAdapterProvider.tsx deleted file mode 100644 index d7c5382a16..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/AnnotationsAdapterProvider.tsx +++ /dev/null @@ -1,6674 +0,0 @@ -/* @refresh reset */ -import React, { - createContext, - useContext, - useState, - useMemo, - useCallback, - useRef, - Dispatch, - SetStateAction, - useEffect, -} from "react"; -import { - Cartesian2, - Cartesian3, - Cartesian4, - BoundingSphere, - type Scene, - Matrix4, - ScreenSpaceEventHandler, - ScreenSpaceEventType, - Transforms, - cartesian3FromJson, - getEllipsoidalAltitudeOrZero, - getDegreesFromCartesian, - getLocalUpDirectionAtAnchor, - getPositionFromLocalFrame, - getPositionInLocalFrame, - getPositionWithVerticalOffsetFromAnchor, - getSignedAngleDegAroundAxis, - normalizeDirection, - projectPointToHorizontalPlaneAtAnchor, - resolveLocalFrameVectors, -} from "@carma/cesium"; -import { useLabelOverlay } from "@carma-providers/label-overlay"; - -import { - isKeyboardTargetEditable, - normalizeOptions, -} from "@carma-commons/utils"; -import { - ANNOTATION_TYPE_AREA_GROUND, - ANNOTATION_TYPE_AREA_PLANAR, - ANNOTATION_TYPE_AREA_VERTICAL, - ANNOTATION_TYPE_DISTANCE, - ANNOTATION_TYPE_POINT, - ANNOTATION_TYPE_POLYLINE, - DEFAULT_LINEAR_SEGMENT_LINE_MODE, - DEFAULT_POINT_LABEL_METRIC_MODE, - LINEAR_SEGMENT_LINE_MODE_COMPONENTS, - LINEAR_SEGMENT_LINE_MODE_DIRECT, - SELECT_TOOL_TYPE, - AnnotationContextsProvider, - PLANAR_TOOL_CREATION_MODE_POLYGON, - PLANAR_TOOL_CREATION_MODE_POLYLINE, - isPointAnnotationEntry, - type AnnotationEditContextType, - type AnnotationModeOptionsContextType, - type AnnotationSelectionContextType, - type AnnotationVisibilityContextType, - type AnnotationsContextType, - type AnnotationCollection, - type AnnotationEntry, - useAnnotationEditState, - useAnnotationEntryMutations, - useAnnotationCoreState, - useAnnotationSelectionMutations, - useAnnotationVisibilityState, - useAnnotationCollectionSelectors, - useAnnotationPointMarkerBadges, - useSelectionToolState, - normalizeLabelAppearance, - buildPointGeometryRows, - buildDesiredPointLabelAnchorById, - buildStandaloneDistancePointSets, - applyDesiredPointLabelAnchors, - collectCollapsedPillPointIds, - collectPointIdsWithoutSelfLabelAnchor, - collectLabelAnchorPointIdsWithForcedVisibility, - applyLabelAppearance, - formatNumber, - getCustomPointAnnotationName, - type AnnotationGeometryEdge, - type AnnotationGeometryPoint, - type AnnotationLabelAnchor, - type AnnotationLabelAppearance, - type AnnotationMode, - type AnnotationPersistenceEnvelopeV2, - type AnnotationCreatePayload, - type PlanarGroupBadgeKind, - type PlanarToolCreationMode, - type PlanarPolygonGroup, - type PlanarPolygonGroupVertex, - type PlanarPolygonPlane, - type PlanarPolygonSourceKind, - type PolygonSurfacePreset, - type PointDistanceRelation, - type PointLabelMetricMode, - type ReferenceLineLabelKind, - type DirectLineLabelMode, - type LinearSegmentLineMode, - type AnnotationListType, -} from "@carma-mapping/annotations/core"; - -import { useCesiumContext } from "@carma-mapping/engines/cesium"; -import { flyToBoundingSphereExtent } from "@carma-mapping/engines/cesium/api"; - -import { - useCesiumOverlaySync, - type CesiumLabelLayoutConfigOverrides, - type PointMarkerBadge, -} from "@carma-mapping/annotations/cesium"; -import { - ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE, - ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - ANNOTATION_LIVE_PREVIEW_TYPE_POINT, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE, - type AnnotationLivePreviewDescriptor, - useAnnotationLivePreviewState, -} from "./hooks/useAnnotationLivePreviewState"; -import { useAnnotationVisualizerAdapter } from "./hooks/useAnnotationVisualizerAdapter"; -import { usePointCreateConfigState } from "./hooks/usePointCreateConfigState"; -import { usePointQueryCreationController } from "./hooks/usePointQueryCreationController"; -import { useAnnotationPointEditingController } from "./hooks/useAnnotationPointEditingController"; -import { - getNextPointLabelMetricMode, - runPointLabelClickInteraction, -} from "./utils/pointLabelInteractions"; -import { - applyDeltaToSelectedPoints, - computeMoveDelta, - getSelectedPointIds, - hasReferencePointInSelection, - shouldMoveSelectionAsGroup, -} from "./utils/selectionGroupMove"; -import type { DerivedPolylinePath } from "./types/derivedPolylinePath"; -import { - buildEdgeRelationIdsForPolygon, - computePolygonGroupDerivedData, - computePolylinePlanarAngleSumDeg, - createPlaneFromThreePoints, - distancePointToPlane, - orientPlaneNormalTowardPosition, - projectPointOntoPlane, -} from "./utils/planarPolygon"; -import { - buildFacadeAutoCloseRectangle, - getFacadeRectanglePreviewAreaSquareMeters, - getVerticalPolygonAxisRotationSuffix, -} from "./utils/cartesianGeometry"; - -type MoveGizmoStartOptions = { - axisDirection?: Cartesian3 | null; - axisTitle?: string | null; - preferredAxisId?: string | null; - axisCandidates?: Array<{ - id: string; - direction: Cartesian3; - color?: string; - title?: string | null; - }> | null; - verticalOffsetEditMode?: - | typeof ANNOTATION_TYPE_POINT - | typeof ANNOTATION_TYPE_POLYLINE - | null; - verticalOffsetPlanarGroupId?: string | null; -}; - -const VERTICAL_POLYGON_AXIS_ALIGNMENT_DOT_EPSILON = 0.999; -const VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS = 0.05; -const VERTICAL_POLYGON_AXIS_ID_ENU_UP = "enu-up"; -const VERTICAL_POLYGON_AXIS_ID_ENU_EAST = "enu-east"; -const VERTICAL_POLYGON_AXIS_ID_ENU_NORTH = "enu-north"; -const ROOF_POLYGON_AXIS_ID_NORMAL = "roof-normal"; -const ROOF_POLYGON_AXIS_ID_IN_PLANE_PRIMARY = "roof-in-plane-primary"; -const ROOF_POLYGON_AXIS_ID_IN_PLANE_SECONDARY = "roof-in-plane-secondary"; - -export interface AnnotationsAdapterContextType { - annotationMode: AnnotationMode; - setAnnotationMode: Dispatch>; - annotations: AnnotationCollection; - setAnnotations: Dispatch>; - selectedMeasurementId: string | null; - activeMeasurementId: string | null; - selectedMeasurementIds: string[]; - selectMeasurementIds: (ids: string[], additive?: boolean) => void; - selectionModeActive: boolean; - setSelectionModeActive: Dispatch>; - selectModeAdditive: boolean; - setSelectModeAdditive: Dispatch>; - selectModeRectangle: boolean; - setSelectModeRectangle: Dispatch>; - selectMeasurementById: (id: string | null) => void; - updateAnnotationNameById: (id: string, name: string) => void; - updatePointLabelAppearanceById: ( - id: string, - appearance: AnnotationLabelAppearance | undefined - ) => void; - toggleAnnotationLockById: (id: string) => void; - selectedPlanarPolygonGroupId: string | null; - activePlanarPolygonGroupId: string | null; - selectPlanarPolygonGroupById: (id: string | null) => void; - updatePlanarPolygonNameById: (id: string, name: string) => void; - moveGizmoPointId: string | null; - isMoveGizmoDragging: boolean; - startMoveGizmoForMeasurementId: ( - id: string, - options?: MoveGizmoStartOptions - ) => void; - stopMoveGizmo: () => void; - setPointAnnotationElevationById: ( - id: string, - elevationMeters: number - ) => void; - setPointAnnotationCoordinatesById: ( - id: string, - latitude: number, - longitude: number, - elevationMeters?: number - ) => void; - // utility functions - clearAllMeasurements: () => void; - clearAnnotationsByIds: (ids: string[]) => void; - deleteSelectedPointAnnotations: () => void; - clearMeasurementsByType: (type: AnnotationMode) => void; - flyToMeasurementById: (id: string) => void; - flyToAllMeasurements: () => void; - // visibility options - showLabels: boolean; - setShowLabels: Dispatch>; - // generic options - temporaryMode: boolean; - setTemporaryMode: Dispatch>; - // per measurement type options - pointRadius: number; - setPointRadius: Dispatch>; - pointVerticalOffsetMeters: number; - setPointVerticalOffsetMeters: Dispatch>; - polylineVerticalOffsetMeters: number; - setPolylineVerticalOffsetMeters: Dispatch>; - polylineVerticalOffsetVisualOnly: boolean; - setPolylineVerticalOffsetVisualOnly: Dispatch>; - polylineSegmentLineMode: LinearSegmentLineMode; - setPolylineSegmentLineMode: Dispatch>; - planarToolCreationMode: PlanarToolCreationMode; - setPlanarToolCreationMode: Dispatch>; - polygonSurfaceTypePreset: PolygonSurfacePreset; - setPolygonSurfaceTypePreset: Dispatch>; - distanceModeStickyToFirstPoint: boolean; - setDistanceModeStickyToFirstPoint: Dispatch>; - distanceCreationLineVisibility: { - direct: boolean; - vertical: boolean; - horizontal: boolean; - }; - setDistanceCreationLineVisibilityByKind: ( - kind: "direct" | "vertical" | "horizontal", - visible: boolean - ) => void; - heightOffset: number; - setHeightOffset: Dispatch>; - referencePoint: Cartesian3 | null; - livePreviewPointECEF: Cartesian3 | null; - hasDistancePreviewAnchor: boolean; - setReferencePoint: Dispatch>; - referenceElevation: number; // derived from referencePoint - geometryNodeTable: Record; - distanceRelations: PointDistanceRelation[]; - setDistanceRelations: Dispatch>; - planarPolygonGroups: PlanarPolygonGroup[]; - polylineGroups: PlanarPolygonGroup[]; - areaPolygonGroups: PlanarPolygonGroup[]; - planarSurfacePolygonGroups: PlanarPolygonGroup[]; - verticalPolygonGroups: PlanarPolygonGroup[]; - setPlanarPolygonGroups: Dispatch>; - polylines: DerivedPolylinePath[]; - setPolylines: Dispatch>; - showSelectedReferenceLine: boolean; - setShowSelectedReferenceLine: Dispatch>; - showSelectedReferenceLineComponents: boolean; - setShowSelectedReferenceLineComponents: Dispatch>; - occlusionChecksEnabled: boolean; - setOcclusionChecksEnabled: Dispatch>; - setPointLabelMetricModeById: (id: string, mode: PointLabelMetricMode) => void; - pointLabelOnCreate: boolean; - setPointLabelOnCreate: Dispatch>; - labelInputPromptPointId: string | null; - confirmPointLabelInputById: (id: string) => void; - pointMarkerBadgeByPointId: Readonly>; - pendingPolylinePromotionRingClosurePointId: string | null; - confirmPolylineRingPromotion: (surfaceType: PolygonSurfacePreset) => void; - cancelPolylineRingPromotion: () => void; -} - -const AnnotationsAdapterContext = createContext< - AnnotationsAdapterContextType | undefined ->(undefined); - -export type AnnotationsAdapterOptions = { - temporary?: boolean; - pointQueries?: { - enabled?: boolean; - radius?: number; - verticalOffsetMeters?: number; - heightOffset?: number; - }; - cartographicCRS?: "string"; - mode?: AnnotationMode; - initialPersistenceState?: AnnotationPersistenceEnvelopeV2 | null; - onPersistenceStateChange?: (state: AnnotationPersistenceEnvelopeV2) => void; - labels?: CesiumLabelLayoutConfigOverrides; - moveGizmo?: { - markerSizeScale?: number; - labelDistanceScale?: number; - snapPlaneDragToGround?: boolean; - showRotationHandle?: boolean; - }; -}; - -const defaultOptions: AnnotationsAdapterOptions = { - temporary: false, - mode: ANNOTATION_TYPE_POINT, -}; - -const defaultPointQueryOptions: AnnotationsAdapterOptions["pointQueries"] = { - enabled: true, - radius: 1, - verticalOffsetMeters: 0, - heightOffset: 1.5, -}; -const defaultMoveGizmoOptions: NonNullable< - AnnotationsAdapterOptions["moveGizmo"] -> = { - markerSizeScale: 1, - labelDistanceScale: 1, - snapPlaneDragToGround: false, - showRotationHandle: true, -}; -const POINT_LABEL_LONG_PRESS_DURATION_MS = 300; -const REFERENCE_POINT_SYNC_EPSILON_METERS = 0.001; -const PERSISTENCE_RESTORE_DELAY_MS = 250; -const PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS = 0.2; -const PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG = 150; -const DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY: Record< - ReferenceLineLabelKind, - boolean -> = { - direct: true, - vertical: true, - horizontal: true, -}; -const DEFAULT_DIRECT_LINE_LABEL_MODE: DirectLineLabelMode = "segment"; -const getNextDirectLineLabelMode = ( - currentMode: DirectLineLabelMode -): DirectLineLabelMode => { - if (currentMode === "segment") return "none"; - return "segment"; -}; - -type PlanarGroupSeedConfig = { - surfaceType: PolygonSurfacePreset; - measurementKind: PlanarPolygonSourceKind; - segmentLineMode: LinearSegmentLineMode; -}; - -const resolvePlanarGroupSeedConfig = ({ - planarToolCreationMode, - polygonSurfaceTypePreset, - defaultPolylineSegmentLineMode, -}: { - planarToolCreationMode: PlanarToolCreationMode; - polygonSurfaceTypePreset: PolygonSurfacePreset; - defaultPolylineSegmentLineMode: LinearSegmentLineMode; -}): PlanarGroupSeedConfig => { - if (planarToolCreationMode !== PLANAR_TOOL_CREATION_MODE_POLYGON) { - return { - surfaceType: "roof", - measurementKind: ANNOTATION_TYPE_POLYLINE, - segmentLineMode: defaultPolylineSegmentLineMode, - }; - } - - const surfaceType = polygonSurfaceTypePreset; - const measurementKind = - surfaceType === "facade" - ? ANNOTATION_TYPE_AREA_VERTICAL - : surfaceType === "roof" - ? ANNOTATION_TYPE_AREA_PLANAR - : ANNOTATION_TYPE_AREA_GROUND; - const segmentLineMode = - measurementKind === ANNOTATION_TYPE_AREA_GROUND && surfaceType === "terrain" - ? defaultPolylineSegmentLineMode - : LINEAR_SEGMENT_LINE_MODE_DIRECT; - - return { - surfaceType, - measurementKind, - segmentLineMode, - }; -}; - -const getConnectedOpenPolylineGroupIds = ( - groups: PlanarPolygonGroup[], - startGroupId: string -) => { - const openGroups = groups.filter((group) => !group.closed); - const startGroup = openGroups.find((group) => group.id === startGroupId); - if (!startGroup) return new Set(); - - const groupById = new Map(openGroups.map((group) => [group.id, group])); - const vertexIdsByGroupId = new Map( - openGroups.map((group) => [group.id, new Set(group.vertexPointIds)]) - ); - - const connectedIds = new Set(); - const queue: string[] = [startGroupId]; - - while (queue.length > 0) { - const groupId = queue.shift(); - if (!groupId || connectedIds.has(groupId)) continue; - const currentVertices = vertexIdsByGroupId.get(groupId); - if (!currentVertices) continue; - connectedIds.add(groupId); - - groupById.forEach((candidateGroup, candidateId) => { - if (connectedIds.has(candidateId)) return; - const candidateVertices = vertexIdsByGroupId.get(candidateId); - if (!candidateVertices) return; - - const sharesVertex = Array.from(currentVertices).some((vertexId) => - candidateVertices.has(vertexId) - ); - if (sharesVertex) { - queue.push(candidateGroup.id); - } - }); - } - - return connectedIds; -}; - -const getMeasurementEdgeId = (pointAId: string, pointBId: string) => { - const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); - return `edge:${left}:${right}`; -}; - -const getDistanceRelationId = (pointAId: string, pointBId: string) => { - const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); - return `distance-relation:${left}:${right}`; -}; - -const withDistanceRelationEdgeId = ( - relation: PointDistanceRelation -): PointDistanceRelation => ({ - ...relation, - edgeId: - relation.edgeId && relation.edgeId.length > 0 - ? relation.edgeId - : getMeasurementEdgeId(relation.pointAId, relation.pointBId), -}); - -const isSameDistanceRelationPair = ( - relation: PointDistanceRelation, - pointAId: string, - pointBId: string -) => - (relation.pointAId === pointAId && relation.pointBId === pointBId) || - (relation.pointAId === pointBId && relation.pointBId === pointAId); - -const hasAnyVisibleDistanceRelationLine = (relation: PointDistanceRelation) => - Boolean( - relation.showDirectLine || - relation.showVerticalLine || - relation.showHorizontalLine || - relation.showComponentLines - ); - -const getPointById = (annotations: AnnotationCollection, id: string) => - annotations.find( - (measurement) => - isPointAnnotationEntry(measurement) && measurement.id === id - ); - -const getPointPositionMap = ( - annotations: AnnotationCollection, - overrides?: Record -) => { - const map = new Map(); - annotations.forEach((measurement) => { - if (!isPointAnnotationEntry(measurement)) return; - map.set(measurement.id, measurement.geometryECEF); - }); - if (overrides) { - Object.entries(overrides).forEach(([id, position]) => { - map.set(id, position); - }); - } - return map; -}; - -const getPolylineComputationPointPositionMap = ( - annotations: AnnotationCollection, - useOffsetAnchors: boolean -) => { - const map = new Map(); - annotations.forEach((measurement) => { - if (!isPointAnnotationEntry(measurement)) return; - if (useOffsetAnchors && measurement.verticalOffsetAnchorECEF) { - map.set( - measurement.id, - new Cartesian3( - measurement.verticalOffsetAnchorECEF.x, - measurement.verticalOffsetAnchorECEF.y, - measurement.verticalOffsetAnchorECEF.z - ) - ); - return; - } - map.set(measurement.id, measurement.geometryECEF); - }); - return map; -}; - -const buildPolygonGroupVertexTable = ( - groups: PlanarPolygonGroup[] -): PlanarPolygonGroupVertex[] => - groups.flatMap((group) => - group.vertexPointIds.map((pointId, order) => ({ - id: `${group.id}:${order}`, - groupId: group.id, - pointId, - order, - })) - ); - -const buildGeometryEdgeTable = ( - relations: PointDistanceRelation[], - groups: PlanarPolygonGroup[] -): AnnotationGeometryEdge[] => { - const byEdgeId = new Map(); - - relations.forEach((relation) => { - const edgeId = - relation.edgeId && relation.edgeId.length > 0 - ? relation.edgeId - : getMeasurementEdgeId(relation.pointAId, relation.pointBId); - if (!byEdgeId.has(edgeId)) { - byEdgeId.set(edgeId, { - id: edgeId, - pointAId: relation.pointAId, - pointBId: relation.pointBId, - }); - } - }); - - groups.forEach((group) => { - const vertexIds = group.vertexPointIds; - if (vertexIds.length < 2) return; - for (let index = 0; index < vertexIds.length - 1; index += 1) { - const pointAId = vertexIds[index]; - const pointBId = vertexIds[index + 1]; - if (!pointAId || !pointBId) continue; - const edgeId = getMeasurementEdgeId(pointAId, pointBId); - if (!byEdgeId.has(edgeId)) { - byEdgeId.set(edgeId, { id: edgeId, pointAId, pointBId }); - } - } - if (group.closed && vertexIds.length >= 3) { - const pointAId = vertexIds[vertexIds.length - 1]; - const pointBId = vertexIds[0]; - if (!pointAId || !pointBId) return; - const edgeId = getMeasurementEdgeId(pointAId, pointBId); - if (!byEdgeId.has(edgeId)) { - byEdgeId.set(edgeId, { id: edgeId, pointAId, pointBId }); - } - } - }); - - return Array.from(byEdgeId.values()); -}; - -const getPlanarGroupMeasurementKind = ( - group: Pick -): PlanarPolygonSourceKind => group.measurementKind; - -const buildDerivedPolylinePath = ( - group: PlanarPolygonGroup, - pointById: Map, - verticalOffsetMeters: number = 0 -): DerivedPolylinePath | null => { - if ( - group.closed || - getPlanarGroupMeasurementKind(group) !== ANNOTATION_TYPE_POLYLINE || - group.vertexPointIds.length < 2 - ) { - return null; - } - - const applyGroupVerticalOffset = (position: Cartesian3) => - Math.abs(verticalOffsetMeters) > 1e-9 - ? getPositionWithVerticalOffsetFromAnchor(position, verticalOffsetMeters) - : position; - - const segmentLengthsMeters: number[] = []; - const segmentLengthsCumulativeMeters: number[] = [0]; - const vertexHeightsMeters = group.vertexPointIds.map((pointId) => { - const point = pointById.get(pointId); - if (!point) return 0; - const pointWGS84 = getDegreesFromCartesian(applyGroupVerticalOffset(point)); - return pointWGS84.altitude ?? 0; - }); - let totalLengthMeters = 0; - const edgeRelationIds: string[] = []; - - for (let index = 0; index < group.vertexPointIds.length - 1; index += 1) { - const startId = group.vertexPointIds[index]; - const endId = group.vertexPointIds[index + 1]; - if (!startId || !endId) continue; - const start = pointById.get(startId); - const end = pointById.get(endId); - if (!start || !end) continue; - const segmentLength = Cartesian3.distance( - applyGroupVerticalOffset(start), - applyGroupVerticalOffset(end) - ); - segmentLengthsMeters.push(segmentLength); - totalLengthMeters += segmentLength; - segmentLengthsCumulativeMeters.push(totalLengthMeters); - edgeRelationIds.push(getDistanceRelationId(startId, endId)); - } - - if (segmentLengthsMeters.length === 0) { - return null; - } - - const hasStartPoint = - !!group.distanceMeasurementStartPointId && - group.vertexPointIds.includes(group.distanceMeasurementStartPointId); - - return { - id: group.id, - name: group.name, - vertexPointIds: [...group.vertexPointIds], - edgeRelationIds, - distanceMeasurementStartPointId: hasStartPoint - ? group.distanceMeasurementStartPointId ?? null - : group.vertexPointIds[0] ?? null, - vertexHeightsMeters, - segmentLengthsMeters, - segmentLengthsCumulativeMeters, - totalLengthMeters, - }; -}; - -interface AnnotationsAdapterProviderProps { - children: React.ReactNode; - options?: AnnotationsAdapterOptions; - enabled?: boolean; -} - -const deleteFromHideMeasurementsOfType = - (type: AnnotationMode) => (prev: Set) => { - // prevent rerenders on non-changes - if (!prev.has(type)) return prev; - const newSet = new Set(prev); - newSet.delete(type); - return newSet; - }; - -const FLY_TO_MIN_RADIUS_METERS = 50; -const FLY_TO_PADDING_FACTOR = 1.1; - -const getMeasurementEntryFlyToPoints = ( - measurement: AnnotationEntry -): Cartesian3[] => { - if (isPointAnnotationEntry(measurement)) { - return [measurement.geometryECEF]; - } - - if (Array.isArray(measurement.geometryECEF)) { - return measurement.geometryECEF; - } - - return []; -}; - -const flyToMeasurementPointGroup = ( - scene: Scene | null | undefined, - points: Cartesian3[] -) => { - if (!scene || scene.isDestroyed() || points.length === 0) { - return; - } - - const sphere = BoundingSphere.fromPoints(points); - sphere.radius = Math.max(sphere.radius, FLY_TO_MIN_RADIUS_METERS); - - flyToBoundingSphereExtent(scene.camera, sphere, { - minRange: FLY_TO_MIN_RADIUS_METERS, - paddingFactor: FLY_TO_PADDING_FACTOR, - }); -}; - -export const AnnotationsAdapterProvider: React.FC< - AnnotationsAdapterProviderProps -> = ({ children, options, enabled = true }) => { - const { getScene } = useCesiumContext(); - const scene = getScene(); - const getPreferredPlaneFacingPosition = useCallback((): Cartesian3 | null => { - if (!scene || scene.isDestroyed()) return null; - return scene.camera.positionWC; - }, [scene]); - const orientPlaneTowardSceneCamera = useCallback( - (plane: PlanarPolygonPlane): PlanarPolygonPlane => - orientPlaneNormalTowardPosition(plane, getPreferredPlaneFacingPosition()), - [getPreferredPlaneFacingPosition] - ); - const computePolygonGroupDerivedDataWithCamera = useCallback( - (group: PlanarPolygonGroup, pointById: Map) => - computePolygonGroupDerivedData(group, pointById, { - preferredFacingPositionECEF: getPreferredPlaneFacingPosition(), - }), - [getPreferredPlaneFacingPosition] - ); - const requestUpdateCallback = useCesiumOverlaySync(); - const overlayContext = useLabelOverlay(); - - useEffect(() => { - if (overlayContext && overlayContext.updatePositions) { - requestUpdateCallback(overlayContext.updatePositions); - } - }, [overlayContext, requestUpdateCallback]); - - const pointQueryOptions = normalizeOptions( - options?.pointQueries, - defaultPointQueryOptions - ); - const pointQueryEnabled = pointQueryOptions.enabled !== false; - - const moveGizmoOptions = normalizeOptions( - options?.moveGizmo, - defaultMoveGizmoOptions - ); - - const normalizedOptions = normalizeOptions(options, defaultOptions); - const { - mode: initialMeasurementMode, - temporary: initialTemporary, - initialPersistenceState, - onPersistenceStateChange, - } = normalizedOptions; - const isInteractionActive = enabled; - - const { - annotationMode, - setAnnotationMode, - annotations, - setAnnotations, - selectedMeasurementId, - setSelectedMeasurementId, - selectedMeasurementIds, - setSelectedMeasurementIds, - selectedMeasurementIdRef, - updateAnnotationNameById, - toggleAnnotationLockById, - showLabels, - setShowLabels, - } = useAnnotationCoreState({ - initialMode: initialMeasurementMode ?? ANNOTATION_TYPE_POINT, - }); - - const previousMeasurementModeRef = useRef(annotationMode); - const hoveredLivePreviewPointIdRef = useRef(null); - - const [pointRadius, setPointRadius] = useState(pointQueryOptions.radius ?? 1); - const [pointVerticalOffsetMeters, setPointVerticalOffsetMeters] = useState( - pointQueryOptions.verticalOffsetMeters ?? 0 - ); - const [ - defaultPolylineVerticalOffsetMeters, - setDefaultPolylineVerticalOffsetMeters, - ] = useState(pointQueryOptions.verticalOffsetMeters ?? 0); - const polylineVerticalOffsetVisualOnly = true; - const setPolylineVerticalOffsetVisualOnly = useCallback< - Dispatch> - >(() => { - // Polyline offset is intentionally always interpreted as visual-only. - }, []); - const [defaultPolylineSegmentLineMode, setDefaultPolylineSegmentLineMode] = - useState(DEFAULT_LINEAR_SEGMENT_LINE_MODE); - const [planarToolCreationMode, setPlanarToolCreationMode] = - useState(PLANAR_TOOL_CREATION_MODE_POLYLINE); - const [polygonSurfaceTypePreset, setPolygonSurfaceTypePreset] = - useState("facade"); - const [distanceModeStickyToFirstPoint, setDistanceModeStickyToFirstPoint] = - useState(false); - const [distanceCreationLineVisibility, setDistanceCreationLineVisibility] = - useState({ - direct: true, - vertical: true, - horizontal: true, - }); - const [heightOffset, setHeightOffset] = useState( - pointQueryOptions.heightOffset ?? 1.5 - ); - const [temporaryMode, setTemporaryMode] = useState( - initialTemporary ?? false - ); - const [pointLabelOnCreate, setPointLabelOnCreate] = useState(false); - const [labelInputPromptPointId, setLabelInputPromptPointId] = useState< - string | null - >(null); - const { - hideMeasurementsOfType, - setHideMeasurementsOfType, - hideLabelsOfType, - setHideLabelsOfType, - } = useAnnotationVisibilityState(); - const { - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - effectiveSelectModeAdditive, - } = useSelectionToolState(); - - useEffect(() => { - if ( - !scene || - scene.isDestroyed() || - !selectionModeActive || - !effectiveSelectModeAdditive - ) { - return; - } - - const plusCursor = document.createElement("div"); - plusCursor.textContent = "+"; - plusCursor.style.position = "fixed"; - plusCursor.style.pointerEvents = "none"; - plusCursor.style.userSelect = "none"; - plusCursor.style.zIndex = "10000"; - plusCursor.style.fontSize = "16px"; - plusCursor.style.fontWeight = "700"; - plusCursor.style.lineHeight = "1"; - plusCursor.style.color = "rgba(255, 255, 255, 0.95)"; - plusCursor.style.textShadow = "0 0 2px rgba(0, 0, 0, 0.85)"; - plusCursor.style.display = "none"; - document.body.appendChild(plusCursor); - - const updatePlusCursorPosition = (event: PointerEvent) => { - const canvasRect = scene.canvas.getBoundingClientRect(); - const insideCanvas = - event.clientX >= canvasRect.left && - event.clientX <= canvasRect.right && - event.clientY >= canvasRect.top && - event.clientY <= canvasRect.bottom; - - if (!insideCanvas) { - plusCursor.style.display = "none"; - return; - } - - plusCursor.style.left = `${event.clientX + 10}px`; - plusCursor.style.top = `${event.clientY + 8}px`; - plusCursor.style.display = "block"; - }; - - const hidePlusCursor = () => { - plusCursor.style.display = "none"; - }; - - window.addEventListener("pointermove", updatePlusCursorPosition, true); - scene.canvas.addEventListener("pointerleave", hidePlusCursor); - window.addEventListener("blur", hidePlusCursor, true); - - return () => { - window.removeEventListener("pointermove", updatePlusCursorPosition, true); - scene.canvas.removeEventListener("pointerleave", hidePlusCursor); - window.removeEventListener("blur", hidePlusCursor, true); - plusCursor.remove(); - }; - }, [scene, selectionModeActive, effectiveSelectModeAdditive]); - - const [moveGizmoPointId, setMoveGizmoPointId] = useState(null); - const [moveGizmoAxisDirection, setMoveGizmoAxisDirection] = - useState(null); - const [moveGizmoAxisTitle, setMoveGizmoAxisTitle] = useState( - null - ); - const [moveGizmoAxisCandidates, setMoveGizmoAxisCandidates] = useState | null>(null); - const [moveGizmoPreferredAxisId, setMoveGizmoPreferredAxisId] = useState< - string | null - >(null); - const [moveGizmoVerticalOffsetEditMode, setMoveGizmoVerticalOffsetEditMode] = - useState< - typeof ANNOTATION_TYPE_POINT | typeof ANNOTATION_TYPE_POLYLINE | null - >(null); - const [ - moveGizmoVerticalOffsetPlanarGroupId, - setMoveGizmoVerticalOffsetPlanarGroupId, - ] = useState(null); - const [isMoveGizmoDragging, setIsMoveGizmoDragging] = - useState(false); - const { - lockedEditMeasurementId, - setLockedEditMeasurementId, - clearLockedEditMeasurementId, - } = useAnnotationEditState(); - - useEffect(() => { - if (moveGizmoPointId) return; - setMoveGizmoPreferredAxisId(null); - setMoveGizmoVerticalOffsetEditMode(null); - setMoveGizmoVerticalOffsetPlanarGroupId(null); - }, [moveGizmoPointId]); - - const isSceneReady = Boolean(scene && !scene.isDestroyed()); - - const [referencePoint, setReferencePoint] = useState(null); - const [occlusionChecksEnabled, setOcclusionChecksEnabled] = - useState(true); - const [distanceRelations, setDistanceRelations] = useState< - PointDistanceRelation[] - >([]); - const [planarPolygonGroups, setPlanarPolygonGroups] = useState< - PlanarPolygonGroup[] - >([]); - const [polylines, setPolylines] = useState([]); - const [activePlanarPolygonGroupId, setActivePlanarPolygonGroupId] = useState< - string | null - >(null); - const [selectedPlanarPolygonGroupId, setSelectedPlanarPolygonGroupId] = - useState(null); - const [previousSelectedMeasurementId, setPreviousSelectedMeasurementId] = - useState(null); - const [doubleClickChainSourcePointId, setDoubleClickChainSourcePointId] = - useState(null); - const [ - pendingPolylinePromotionRingClosurePointId, - setPendingPolylinePromotionRingClosurePointId, - ] = useState(null); - - const geometryPointsTable = useMemo( - () => buildPointGeometryRows(annotations.filter(isPointAnnotationEntry)), - [annotations] - ); - const geometryNodeTable = useMemo( - () => - geometryPointsTable.reduce>( - (table, node) => { - table[node.id] = node; - return table; - }, - {} - ), - [geometryPointsTable] - ); - const geometryEdgesTable = useMemo( - () => buildGeometryEdgeTable(distanceRelations, planarPolygonGroups), - [distanceRelations, planarPolygonGroups] - ); - const planarPolygonGroupVerticesTable = useMemo( - () => buildPolygonGroupVertexTable(planarPolygonGroups), - [planarPolygonGroups] - ); - - const hasAppliedInitialPersistenceStateRef = useRef(false); - const lastSavedPersistenceStateRef = useRef(null); - - useEffect(() => { - if (!isSceneReady || hasAppliedInitialPersistenceStateRef.current) { - return; - } - - if (initialPersistenceState) { - setTimeout(() => { - setAnnotations(initialPersistenceState.tables.annotations); - setDistanceRelations( - initialPersistenceState.tables.distanceRelations.map( - withDistanceRelationEdgeId - ) - ); - setPlanarPolygonGroups( - initialPersistenceState.tables.planarPolygonGroups - ); - }, PERSISTENCE_RESTORE_DELAY_MS); - } - - hasAppliedInitialPersistenceStateRef.current = true; - }, [initialPersistenceState, isSceneReady, setAnnotations]); - - useEffect(() => { - setPlanarPolygonGroups((prev) => { - let hasChanges = false; - const nextGroups = prev.map((group) => { - if (group.segmentLineMode) { - return group; - } - hasChanges = true; - return { - ...group, - segmentLineMode: group.closed - ? LINEAR_SEGMENT_LINE_MODE_DIRECT - : defaultPolylineSegmentLineMode, - }; - }); - return hasChanges ? nextGroups : prev; - }); - }, [defaultPolylineSegmentLineMode]); - - useEffect(() => { - if ( - !onPersistenceStateChange || - !hasAppliedInitialPersistenceStateRef.current - ) { - return; - } - - const persistenceState: AnnotationPersistenceEnvelopeV2 = { - version: 2, - geometry: { - points: geometryPointsTable, - edges: geometryEdgesTable, - }, - tables: { - annotations, - distanceRelations: distanceRelations.map(withDistanceRelationEdgeId), - planarPolygonGroups, - planarPolygonGroupVertices: planarPolygonGroupVerticesTable, - }, - }; - - const serialized = JSON.stringify(persistenceState); - if (serialized === lastSavedPersistenceStateRef.current) { - return; - } - - onPersistenceStateChange(persistenceState); - lastSavedPersistenceStateRef.current = serialized; - }, [ - distanceRelations, - geometryEdgesTable, - geometryPointsTable, - annotations, - onPersistenceStateChange, - planarPolygonGroupVerticesTable, - planarPolygonGroups, - ]); - - const referenceElevation = useMemo(() => { - if (!referencePoint || !scene) return 0; - const cartographic = - scene.globe.ellipsoid.cartesianToCartographic(referencePoint); - return cartographic?.height ?? 0; - }, [referencePoint, scene]); - - const derivedPolylines = useMemo(() => { - const pointById = getPolylineComputationPointPositionMap( - annotations, - polylineVerticalOffsetVisualOnly - ); - return planarPolygonGroups - .map((group) => - buildDerivedPolylinePath( - group, - pointById, - group.verticalOffsetMeters ?? defaultPolylineVerticalOffsetMeters - ) - ) - .filter((collection): collection is DerivedPolylinePath => - Boolean(collection) - ); - }, [ - defaultPolylineVerticalOffsetMeters, - annotations, - planarPolygonGroups, - polylineVerticalOffsetVisualOnly, - ]); - - useEffect(() => { - setPolylines(derivedPolylines); - }, [derivedPolylines]); - - const focusedPlanarPolygonGroupId = - selectedPlanarPolygonGroupId ?? activePlanarPolygonGroupId; - const polylineVerticalOffsetMeters = useMemo(() => { - if (!focusedPlanarPolygonGroupId) { - return defaultPolylineVerticalOffsetMeters; - } - const focusedGroup = planarPolygonGroups.find( - (group) => group.id === focusedPlanarPolygonGroupId - ); - return ( - focusedGroup?.verticalOffsetMeters ?? defaultPolylineVerticalOffsetMeters - ); - }, [ - defaultPolylineVerticalOffsetMeters, - focusedPlanarPolygonGroupId, - planarPolygonGroups, - ]); - const setPolylineVerticalOffsetMeters = useCallback< - Dispatch> - >( - (nextOffsetOrUpdater) => { - const nextOffsetMeters = - typeof nextOffsetOrUpdater === "function" - ? nextOffsetOrUpdater(polylineVerticalOffsetMeters) - : nextOffsetOrUpdater; - - if (!Number.isFinite(nextOffsetMeters)) { - return; - } - - if (Math.abs(nextOffsetMeters - polylineVerticalOffsetMeters) <= 1e-9) { - return; - } - - setDefaultPolylineVerticalOffsetMeters(nextOffsetMeters); - - if (!focusedPlanarPolygonGroupId) { - return; - } - - const focusedGroup = planarPolygonGroups.find( - (group) => group.id === focusedPlanarPolygonGroupId - ); - if (!focusedGroup) { - return; - } - - setPlanarPolygonGroups((prev) => - prev.map((group) => - group.id === focusedPlanarPolygonGroupId - ? { - ...group, - verticalOffsetMeters: nextOffsetMeters, - } - : group - ) - ); - - const focusedVertexIdSet = new Set(focusedGroup.vertexPointIds); - if (focusedVertexIdSet.size === 0) { - return; - } - - setAnnotations((prev) => - prev.map((measurement) => { - if ( - !isPointAnnotationEntry(measurement) || - !focusedVertexIdSet.has(measurement.id) || - !measurement.verticalOffsetAnchorECEF - ) { - return measurement; - } - - const anchorECEF = new Cartesian3( - measurement.verticalOffsetAnchorECEF.x, - measurement.verticalOffsetAnchorECEF.y, - measurement.verticalOffsetAnchorECEF.z - ); - const nextPointPosition = getPositionWithVerticalOffsetFromAnchor( - anchorECEF, - nextOffsetMeters - ); - const nextWGS84 = getDegreesFromCartesian(nextPointPosition); - - return { - ...measurement, - geometryECEF: nextPointPosition, - geometryWGS84: { - longitude: nextWGS84.longitude, - latitude: nextWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(nextWGS84.altitude), - }, - }; - }) - ); - }, - [ - focusedPlanarPolygonGroupId, - planarPolygonGroups, - polylineVerticalOffsetMeters, - setAnnotations, - ] - ); - - const activeLivePreviewDescriptor = - useMemo(() => { - if (annotationMode === ANNOTATION_TYPE_POINT) { - if (pointLabelOnCreate && labelInputPromptPointId) { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - verticalOffsetMeters: 0, - }; - } - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POINT, - verticalOffsetMeters: pointLabelOnCreate - ? 0 - : pointVerticalOffsetMeters, - }; - } - - if (annotationMode === ANNOTATION_TYPE_DISTANCE) { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE, - verticalOffsetMeters: 0, - }; - } - - if (annotationMode !== ANNOTATION_TYPE_POLYLINE) { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - verticalOffsetMeters: 0, - }; - } - - if (planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYLINE) { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE, - verticalOffsetMeters: polylineVerticalOffsetMeters, - }; - } - - const activeOpenPolygonGroup = activePlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId && !group.closed - ) ?? null - : null; - const effectiveSurfaceType = - activeOpenPolygonGroup?.surfaceType ?? polygonSurfaceTypePreset; - - if (effectiveSurfaceType === "facade") { - const firstVertexPointId = - activeOpenPolygonGroup?.vertexPointIds.length === 1 - ? activeOpenPolygonGroup.vertexPointIds[0] - : null; - if (firstVertexPointId && activeOpenPolygonGroup) { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - verticalOffsetMeters: 0, - verticalPolygonContext: { - groupId: activeOpenPolygonGroup.id, - firstVertexPointId, - }, - }; - } - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - verticalOffsetMeters: 0, - }; - } - - if (effectiveSurfaceType === "footprint") { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND, - verticalOffsetMeters: 0, - }; - } - - if (effectiveSurfaceType === "roof") { - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR, - verticalOffsetMeters: 0, - }; - } - - return { - type: ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - verticalOffsetMeters: 0, - }; - }, [ - activePlanarPolygonGroupId, - annotationMode, - labelInputPromptPointId, - planarToolCreationMode, - planarPolygonGroups, - pointLabelOnCreate, - pointVerticalOffsetMeters, - polygonSurfaceTypePreset, - polylineVerticalOffsetMeters, - ]); - - const { - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - handlePointQueryPointerMove, - previewIsPolylineCreateMode, - hasActivePreviewNode, - activePreviewSupportsDistanceLine, - activePreviewUsesPolylineDistanceRules, - activePreviewForceDirectDistanceLine, - isLivePointPreviewModeActive, - } = useAnnotationLivePreviewState({ - scene, - activePreview: activeLivePreviewDescriptor, - pointQueryEnabled, - moveGizmoPointId, - isMoveGizmoDragging, - annotations, - setPlanarPolygonGroups, - getPositionWithVerticalOffsetFromAnchor, - getFacadeRectanglePreviewAreaSquareMeters, - }); - - const handlePointLabelHoverChange = useCallback( - (pointId: string, hovered: boolean) => { - if (!pointQueryEnabled || moveGizmoPointId || isMoveGizmoDragging) return; - if (!hasActivePreviewNode) return; - - if (hovered) { - const hoveredPoint = getPointById(annotations, pointId); - if (!hoveredPoint || !isPointAnnotationEntry(hoveredPoint)) return; - hoveredLivePreviewPointIdRef.current = pointId; - const localUp = getLocalUpDirectionAtAnchor(hoveredPoint.geometryECEF); - handlePointQueryPointerMove( - hoveredPoint.geometryECEF, - undefined, - localUp - ); - return; - } - - if (hoveredLivePreviewPointIdRef.current !== pointId) return; - hoveredLivePreviewPointIdRef.current = null; - handlePointQueryPointerMove(null, undefined, null); - }, - [ - pointQueryEnabled, - moveGizmoPointId, - isMoveGizmoDragging, - hasActivePreviewNode, - annotations, - handlePointQueryPointerMove, - ] - ); - - useEffect(() => { - if (isLivePointPreviewModeActive) return; - hoveredLivePreviewPointIdRef.current = null; - }, [isLivePointPreviewModeActive]); - const polylineSegmentLineMode = useMemo(() => { - if (!activePlanarPolygonGroupId) { - return defaultPolylineSegmentLineMode; - } - const activeGroup = planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId - ); - return activeGroup?.segmentLineMode ?? defaultPolylineSegmentLineMode; - }, [ - activePlanarPolygonGroupId, - defaultPolylineSegmentLineMode, - planarPolygonGroups, - ]); - const setPolylineSegmentLineMode = useCallback< - Dispatch> - >( - (nextModeOrUpdater) => { - const nextMode = - typeof nextModeOrUpdater === "function" - ? nextModeOrUpdater(polylineSegmentLineMode) - : nextModeOrUpdater; - - if (!nextMode || nextMode === polylineSegmentLineMode) { - return; - } - - setDefaultPolylineSegmentLineMode(nextMode); - - if (!activePlanarPolygonGroupId) { - return; - } - - setPlanarPolygonGroups((prev) => - prev.map((group) => - group.id === activePlanarPolygonGroupId - ? { - ...group, - segmentLineMode: nextMode, - } - : group - ) - ); - }, - [activePlanarPolygonGroupId, polylineSegmentLineMode] - ); - const focusedPolyline = useMemo(() => { - if (!focusedPlanarPolygonGroupId) return null; - return ( - polylines.find( - (polyline) => polyline.id === focusedPlanarPolygonGroupId - ) ?? null - ); - }, [focusedPlanarPolygonGroupId, polylines]); - const focusedPolylineStartPointId = - focusedPolyline?.distanceMeasurementStartPointId ?? - focusedPolyline?.vertexPointIds[0] ?? - null; - - const focusedPolylineDistanceToStartByPointId = useMemo(() => { - if (!focusedPolyline) return {}; - const byId: Record = {}; - focusedPolyline.vertexPointIds.forEach((pointId, index) => { - byId[pointId] = - focusedPolyline.segmentLengthsCumulativeMeters[index] ?? 0; - }); - return byId; - }, [focusedPolyline]); - - const cumulativeDistanceByRelationId = useMemo(() => { - const byRelationId: Record = {}; - polylines.forEach((polyline) => { - polyline.edgeRelationIds.forEach((relationId, segmentIndex) => { - byRelationId[relationId] = - polyline.segmentLengthsCumulativeMeters[segmentIndex + 1] ?? - polyline.segmentLengthsCumulativeMeters[segmentIndex] ?? - 0; - }); - }); - return byRelationId; - }, [polylines]); - - const effectiveReferenceElevation = useMemo(() => { - if (!focusedPolylineStartPointId) { - return referenceElevation; - } - if (focusedPolyline) { - const focusedStartPointIndex = focusedPolyline.vertexPointIds.findIndex( - (pointId) => pointId === focusedPolylineStartPointId - ); - if (focusedStartPointIndex >= 0) { - return focusedPolyline.vertexHeightsMeters[focusedStartPointIndex] ?? 0; - } - } - return referenceElevation; - }, [focusedPolyline, focusedPolylineStartPointId, referenceElevation]); - - const distanceToReferenceByPointId = useMemo(() => { - if (!referencePoint) return {}; - - const distances: Record = {}; - annotations.forEach((measurement) => { - if (!isPointAnnotationEntry(measurement)) return; - distances[measurement.id] = Cartesian3.distance( - measurement.geometryECEF, - referencePoint - ); - }); - return distances; - }, [annotations, referencePoint]); - - const effectiveDistanceToReferenceByPointId = useMemo(() => { - if (!focusedPolyline) return distanceToReferenceByPointId; - return { - ...distanceToReferenceByPointId, - ...focusedPolylineDistanceToStartByPointId, - }; - }, [ - distanceToReferenceByPointId, - focusedPolyline, - focusedPolylineDistanceToStartByPointId, - ]); - - const polygonOnlyPointIdSet = useMemo(() => { - const displayReadyPolygonGroupIds = new Set( - planarPolygonGroups - .filter( - (group) => - group.closed || - (group.planeLocked && group.vertexPointIds.length >= 4) - ) - .map((group) => group.id) - ); - - const polygonVertexIds = new Set(); - planarPolygonGroups.forEach((group) => { - if (!displayReadyPolygonGroupIds.has(group.id)) return; - group.vertexPointIds.forEach((id) => polygonVertexIds.add(id)); - }); - - const nonPolygonRelationPointIds = new Set(); - distanceRelations.forEach((relation) => { - if ( - relation.polygonGroupId && - displayReadyPolygonGroupIds.has(relation.polygonGroupId) - ) { - return; - } - nonPolygonRelationPointIds.add(relation.pointAId); - nonPolygonRelationPointIds.add(relation.pointBId); - }); - - const ids = new Set(); - polygonVertexIds.forEach((id) => { - if (!nonPolygonRelationPointIds.has(id)) { - ids.add(id); - } - }); - - // Keep polygon-only labels hidden by default, but always show the actively - // selected point label so node selection provides direct point feedback. - if (selectedMeasurementId) { - ids.delete(selectedMeasurementId); - } - - return ids; - }, [planarPolygonGroups, distanceRelations, selectedMeasurementId]); - - // For unfocused polylines, collect the first node IDs so all non-last nodes can be - // suppressed (last node renders the collapsed pill marker). - const unfocusedPolylineMarkerOnlyPointIds = useMemo(() => { - const ids = new Set(); - polylines.forEach((polyline) => { - if (polyline.id === focusedPlanarPolygonGroupId) return; - const first = polyline.vertexPointIds[0]; - const last = polyline.vertexPointIds[polyline.vertexPointIds.length - 1]; - if (first && first !== last) ids.add(first); - }); - return ids; - }, [polylines, focusedPlanarPolygonGroupId]); - - const pointMeasurements = useMemo( - () => annotations.filter(isPointAnnotationEntry), - [annotations] - ); - const planarVertexPointIdSetForBadges = useMemo(() => { - const ids = new Set(); - planarPolygonGroups.forEach((group) => { - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - ids.add(pointId); - } - }); - }); - return ids; - }, [planarPolygonGroups]); - const pointMeasureOrderById = useMemo( - () => - pointMeasurements - .filter( - (measurement) => - !measurement.auxiliaryLabelAnchor && - !measurement.distanceAdhocNode && - !planarVertexPointIdSetForBadges.has(measurement.id) - ) - .reduce>((orderById, measurement, index) => { - orderById[measurement.id] = index + 1; - return orderById; - }, {}), - [planarVertexPointIdSetForBadges, pointMeasurements] - ); - const resolvePlanarGroupBadgeKind = useCallback( - (group: PlanarPolygonGroup): PlanarGroupBadgeKind => group.measurementKind, - [] - ); - const pointMarkerBadgeByPointId = useAnnotationPointMarkerBadges({ - pointMeasurements, - planarPolygonGroups, - distanceRelations, - pointMeasureOrderById, - resolvePlanarGroupBadgeKind, - isPointAutoCorner: (point) => Boolean(point.isFacadeAutoCorner), - }) as Readonly>; - - const unfocusedPolylineInteriorIds = useMemo(() => { - const ids = new Set(); - polylines.forEach((polyline) => { - if (polyline.id === focusedPlanarPolygonGroupId) return; - polyline.vertexPointIds.forEach((pointId, index) => { - if (index === 0 || index === polyline.vertexPointIds.length - 1) return; - ids.add(pointId); - }); - }); - return ids; - }, [polylines, focusedPlanarPolygonGroupId]); - - const unfocusedPolylineNonLastIds = useMemo(() => { - const ids = new Set(unfocusedPolylineMarkerOnlyPointIds); - unfocusedPolylineInteriorIds.forEach((pointId) => { - ids.add(pointId); - }); - return ids; - }, [unfocusedPolylineMarkerOnlyPointIds, unfocusedPolylineInteriorIds]); - - const { - standaloneDistanceHighestPointIds, - unfocusedStandaloneDistanceNonHighestPointIds, - focusedStandaloneDistanceNonHighestPointIds, - } = useMemo(() => { - const selectedPointIdSet = new Set(selectedMeasurementIds); - if (selectedMeasurementId) { - selectedPointIdSet.add(selectedMeasurementId); - } - - const { - highestPointIds, - unfocusedNonHighestPointIds, - focusedNonHighestPointIds, - } = buildStandaloneDistancePointSets({ - pointMeasurements, - distanceRelations, - selectedPointIds: selectedPointIdSet, - }); - - return { - standaloneDistanceHighestPointIds: highestPointIds, - unfocusedStandaloneDistanceNonHighestPointIds: - unfocusedNonHighestPointIds, - focusedStandaloneDistanceNonHighestPointIds: focusedNonHighestPointIds, - }; - }, [ - distanceRelations, - pointMeasurements, - selectedMeasurementId, - selectedMeasurementIds, - ]); - - const desiredLabelAnchorByPointId = useMemo< - Readonly> - >( - () => - buildDesiredPointLabelAnchorById({ - pointMeasurements, - polylines, - focusedPlanarPolygonGroupId, - pointMarkerBadgeByPointId, - standaloneDistanceHighestPointIds, - unfocusedStandaloneDistanceNonHighestPointIds, - focusedStandaloneDistanceNonHighestPointIds, - formatDistanceLabel: formatNumber, - }), - [ - pointMeasurements, - polylines, - focusedPlanarPolygonGroupId, - pointMarkerBadgeByPointId, - standaloneDistanceHighestPointIds, - unfocusedStandaloneDistanceNonHighestPointIds, - focusedStandaloneDistanceNonHighestPointIds, - ] - ); - - useEffect(() => { - setAnnotations((prev) => { - const { nextMeasurements, hasChanges } = applyDesiredPointLabelAnchors({ - annotations: prev, - desiredLabelAnchorByPointId, - isPointMeasurement: isPointAnnotationEntry, - }); - return hasChanges ? nextMeasurements : prev; - }); - }, [desiredLabelAnchorByPointId]); - - const collapsedPillPointIds = useMemo( - () => collectCollapsedPillPointIds(pointMeasurements), - [pointMeasurements] - ); - - const selectedClosedAreaGroupIdSet = useMemo(() => { - const ids = new Set(); - if (!selectedPlanarPolygonGroupId && !activePlanarPolygonGroupId) { - return ids; - } - planarPolygonGroups.forEach((group) => { - if (!group.closed) return; - if ( - group.id === selectedPlanarPolygonGroupId || - group.id === activePlanarPolygonGroupId - ) { - ids.add(group.id); - } - }); - return ids; - }, [ - activePlanarPolygonGroupId, - planarPolygonGroups, - selectedPlanarPolygonGroupId, - ]); - - const closedAreaVertexPointIdSet = useMemo(() => { - const ids = new Set(); - planarPolygonGroups.forEach((group) => { - if (!group.closed) return; - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - ids.add(pointId); - } - }); - }); - return ids; - }, [planarPolygonGroups]); - - const selectedClosedAreaVertexPointIdSet = useMemo(() => { - const ids = new Set(); - if (selectedClosedAreaGroupIdSet.size === 0) { - return ids; - } - planarPolygonGroups.forEach((group) => { - if (!group.closed || !selectedClosedAreaGroupIdSet.has(group.id)) return; - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - ids.add(pointId); - } - }); - }); - return ids; - }, [planarPolygonGroups, selectedClosedAreaGroupIdSet]); - - const unselectedClosedAreaVertexPointIdSet = useMemo(() => { - const ids = new Set(); - closedAreaVertexPointIdSet.forEach((pointId) => { - if (!selectedClosedAreaVertexPointIdSet.has(pointId)) { - ids.add(pointId); - } - }); - return ids; - }, [closedAreaVertexPointIdSet, selectedClosedAreaVertexPointIdSet]); - - const labelAnchorPointIdsWithForcedVisibility = useMemo( - () => - collectLabelAnchorPointIdsWithForcedVisibility( - pointMeasurements, - unselectedClosedAreaVertexPointIdSet - ), - [pointMeasurements, unselectedClosedAreaVertexPointIdSet] - ); - - const openFacadeSingleVertexPointIdSet = useMemo(() => { - const ids = new Set(); - planarPolygonGroups.forEach((group) => { - if (group.closed) return; - if ((group.surfaceType ?? "roof") !== "facade") return; - if (group.vertexPointIds.length !== 1) return; - const onlyPointId = group.vertexPointIds[0]; - if (onlyPointId) { - ids.add(onlyPointId); - } - }); - return ids; - }, [planarPolygonGroups]); - - const showPoints = !hideMeasurementsOfType.has(ANNOTATION_TYPE_DISTANCE); - const showDistanceAndPolygonVisuals = true; - - const pointMeasurementIds = useMemo(() => { - const ids = new Set(); - annotations.forEach((measurement) => { - if (!isPointAnnotationEntry(measurement)) return; - ids.add(measurement.id); - }); - return ids; - }, [annotations, planarPolygonGroups.length]); - - const showPointLabels = - showPoints && showLabels && !hideLabelsOfType.has(ANNOTATION_TYPE_DISTANCE); - const pointIdsWithoutLabelAnchor = useMemo( - () => collectPointIdsWithoutSelfLabelAnchor(pointMeasurements), - [pointMeasurements] - ); - - const lockedMeasurementIdSet = useMemo(() => { - const ids = new Set(); - annotations.forEach((measurement) => { - if (measurement.locked) { - ids.add(measurement.id); - } - }); - return ids; - }, [annotations]); - - const lastCustomLabelOnCreate = useMemo(() => { - for (let index = annotations.length - 1; index >= 0; index -= 1) { - const measurement = annotations[index]; - if (!measurement || !isPointAnnotationEntry(measurement)) continue; - if (!measurement.auxiliaryLabelAnchor) continue; - const customName = getCustomPointAnnotationName(measurement.name); - if (customName) return customName; - } - return undefined; - }, [annotations]); - - const { selectMeasurementIds, selectMeasurementById } = - useAnnotationSelectionMutations({ - selectableMeasurementIds: pointMeasurementIds, - selectedMeasurementIdRef, - setSelectedMeasurementId, - setSelectedMeasurementIds, - onPrimarySelectionChange: (nextPrimaryId, previousPrimaryId) => { - if ( - nextPrimaryId && - previousPrimaryId && - nextPrimaryId !== previousPrimaryId - ) { - setPreviousSelectedMeasurementId(previousPrimaryId); - } - }, - onSelectionIdsChange: (_nextIds, nextPrimaryId) => { - if (nextPrimaryId !== null) { - setSelectedPlanarPolygonGroupId((prevSelectedGroupId) => - prevSelectedGroupId === null ? prevSelectedGroupId : null - ); - } - }, - }); - - // Creation flows can select an id before it appears in the current selectable-id - // snapshot. This bypass keeps InfoBox/selection in sync with the just-created point. - const selectMeasurementByIdImmediate = useCallback( - (id: string | null) => { - const previousPrimaryId = selectedMeasurementIdRef.current; - selectedMeasurementIdRef.current = id; - setSelectedMeasurementId((prev) => (prev === id ? prev : id)); - setSelectedMeasurementIds((prev) => { - if (id === null) { - return prev.length === 0 ? prev : []; - } - return prev.length === 1 && prev[0] === id ? prev : [id]; - }); - if (id !== null) { - setSelectedPlanarPolygonGroupId((prev) => - prev === null ? prev : null - ); - } - if (previousPrimaryId && id && previousPrimaryId !== id) { - setPreviousSelectedMeasurementId(previousPrimaryId); - } - }, - [ - selectedMeasurementIdRef, - setSelectedMeasurementId, - setSelectedMeasurementIds, - setSelectedPlanarPolygonGroupId, - setPreviousSelectedMeasurementId, - ] - ); - - const selectPlanarPolygonGroupById = useCallback((id: string | null) => { - setSelectedPlanarPolygonGroupId((prev) => (prev === id ? prev : id)); - if (id !== null) { - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setDoubleClickChainSourcePointId(null); - setActivePlanarPolygonGroupId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - } - }, []); - - const selectedDistancePair = useMemo(() => { - if (!selectedMeasurementId || !previousSelectedMeasurementId) { - return null; - } - if (selectedMeasurementId === previousSelectedMeasurementId) { - return null; - } - if ( - !pointMeasurementIds.has(selectedMeasurementId) || - !pointMeasurementIds.has(previousSelectedMeasurementId) - ) { - return null; - } - return { - activePointId: selectedMeasurementId, - previousPointId: previousSelectedMeasurementId, - }; - }, [ - pointMeasurementIds, - previousSelectedMeasurementId, - selectedMeasurementId, - ]); - - // Internal drawing-session signal for an active open polyline/polygon chain. - const isActiveDrawMode = useMemo(() => { - if (!doubleClickChainSourcePointId) return false; - if (!pointMeasurementIds.has(doubleClickChainSourcePointId)) return false; - if (!activePlanarPolygonGroupId) return false; - return planarPolygonGroups.some( - (group) => group.id === activePlanarPolygonGroupId && !group.closed - ); - }, [ - activePlanarPolygonGroupId, - doubleClickChainSourcePointId, - planarPolygonGroups, - pointMeasurementIds, - ]); - - const selectedDistanceRelation = useMemo(() => { - if (!selectedDistancePair) return null; - return ( - distanceRelations.find((relation) => - isSameDistanceRelationPair( - relation, - selectedDistancePair.activePointId, - selectedDistancePair.previousPointId - ) - ) ?? null - ); - }, [distanceRelations, selectedDistancePair]); - - const showSelectedReferenceLine = - selectedDistanceRelation?.showDirectLine ?? false; - const selectedVerticalLineVisible = - selectedDistanceRelation?.showVerticalLine ?? - selectedDistanceRelation?.showComponentLines ?? - false; - const selectedHorizontalLineVisible = - selectedDistanceRelation?.showHorizontalLine ?? - selectedDistanceRelation?.showComponentLines ?? - false; - const showSelectedReferenceLineComponents = - selectedVerticalLineVisible || selectedHorizontalLineVisible; - - const referencePointMeasurementId = useMemo(() => { - if (!referencePoint) return null; - const pointMeasurement = annotations.find( - (measurement) => - isPointAnnotationEntry(measurement) && - Cartesian3.distance(measurement.geometryECEF, referencePoint) <= - REFERENCE_POINT_SYNC_EPSILON_METERS - ); - return pointMeasurement && isPointAnnotationEntry(pointMeasurement) - ? pointMeasurement.id - : null; - }, [annotations, referencePoint]); - - const resolveDistanceRelationSourcePointId = useCallback( - (targetPointId: string) => { - if (distanceModeStickyToFirstPoint && referencePointMeasurementId) { - return referencePointMeasurementId === targetPointId - ? null - : referencePointMeasurementId; - } - const hasChainSource = Boolean( - doubleClickChainSourcePointId && - pointMeasurementIds.has(doubleClickChainSourcePointId) - ); - if (!hasChainSource) return null; - return doubleClickChainSourcePointId === targetPointId - ? null - : doubleClickChainSourcePointId; - }, - [ - distanceModeStickyToFirstPoint, - doubleClickChainSourcePointId, - pointMeasurementIds, - referencePointMeasurementId, - ] - ); - - const setDistanceCreationLineVisibilityByKind = useCallback( - (kind: "direct" | "vertical" | "horizontal", visible: boolean) => { - setDistanceCreationLineVisibility((prev) => - prev[kind] === visible - ? prev - : { - ...prev, - [kind]: visible, - } - ); - }, - [] - ); - - const activePreviewAnchorPointId = useMemo(() => { - if (!activePreviewSupportsDistanceLine) return null; - return resolveDistanceRelationSourcePointId("__live-preview-target__"); - }, [activePreviewSupportsDistanceLine, resolveDistanceRelationSourcePointId]); - - const hasDistancePreviewAnchor = useMemo(() => { - if (annotationMode !== ANNOTATION_TYPE_DISTANCE) { - return false; - } - - if (distanceModeStickyToFirstPoint && referencePointMeasurementId) { - return true; - } - - return Boolean( - doubleClickChainSourcePointId && - pointMeasurementIds.has(doubleClickChainSourcePointId) - ); - }, [ - distanceModeStickyToFirstPoint, - doubleClickChainSourcePointId, - annotationMode, - pointMeasurementIds, - referencePointMeasurementId, - ]); - - const activeMeasurementId = useMemo(() => { - if (moveGizmoPointId && pointMeasurementIds.has(moveGizmoPointId)) { - return moveGizmoPointId; - } - - if (activePreviewAnchorPointId) { - return activePreviewAnchorPointId; - } - - if ( - doubleClickChainSourcePointId && - pointMeasurementIds.has(doubleClickChainSourcePointId) - ) { - return doubleClickChainSourcePointId; - } - - if ( - selectedMeasurementId && - pointMeasurementIds.has(selectedMeasurementId) - ) { - return selectedMeasurementId; - } - - return null; - }, [ - activePreviewAnchorPointId, - doubleClickChainSourcePointId, - moveGizmoPointId, - pointMeasurementIds, - selectedMeasurementId, - ]); - - const livePreviewDistanceLine = useMemo(() => { - if (!livePreviewPointECEF || !activePreviewAnchorPointId) { - return null; - } - - const sourcePoint = getPointById(annotations, activePreviewAnchorPointId); - if (!sourcePoint || !isPointAnnotationEntry(sourcePoint)) { - return null; - } - - const showDirectLine = activePreviewForceDirectDistanceLine - ? true - : activePreviewUsesPolylineDistanceRules - ? polylineSegmentLineMode === LINEAR_SEGMENT_LINE_MODE_DIRECT - : distanceCreationLineVisibility.direct; - const showComponentLines = activePreviewForceDirectDistanceLine - ? false - : activePreviewUsesPolylineDistanceRules - ? polylineSegmentLineMode === LINEAR_SEGMENT_LINE_MODE_COMPONENTS - : distanceCreationLineVisibility.vertical || - distanceCreationLineVisibility.horizontal; - const showVerticalLine = activePreviewUsesPolylineDistanceRules - ? showComponentLines - : distanceCreationLineVisibility.vertical; - const showHorizontalLine = activePreviewUsesPolylineDistanceRules - ? showComponentLines - : distanceCreationLineVisibility.horizontal; - - if (!showDirectLine && !showVerticalLine && !showHorizontalLine) { - return null; - } - - return { - anchorPointECEF: Cartesian3.clone(sourcePoint.geometryECEF), - targetPointECEF: Cartesian3.clone(livePreviewPointECEF), - showDirectLine, - showVerticalLine, - showHorizontalLine, - previewTotalDistanceMeters: previewIsPolylineCreateMode - ? (focusedPolylineDistanceToStartByPointId[ - activePreviewAnchorPointId - ] ?? 0) + - Cartesian3.distance(sourcePoint.geometryECEF, livePreviewPointECEF) - : undefined, - }; - }, [ - distanceCreationLineVisibility.direct, - distanceCreationLineVisibility.horizontal, - distanceCreationLineVisibility.vertical, - livePreviewPointECEF, - activePreviewAnchorPointId, - activePreviewForceDirectDistanceLine, - activePreviewUsesPolylineDistanceRules, - previewIsPolylineCreateMode, - focusedPolylineDistanceToStartByPointId, - annotations, - polylineSegmentLineMode, - ]); - - const handlePointQueryBeforePointCreate = useCallback( - (_positionECEF: Cartesian3 | null, screenPosition: Cartesian2) => { - // Check if click hit a polygon fill primitive - if (scene && !scene.isDestroyed()) { - const picked = scene.pick(screenPosition); - const pickedPolygonGroupId = picked?.id?.polygonGroupId; - if (pickedPolygonGroupId) { - selectPlanarPolygonGroupById(pickedPolygonGroupId); - return false; - } - } - - if (isActiveDrawMode) { - return true; - } - - if (selectedPlanarPolygonGroupId) { - selectPlanarPolygonGroupById(null); - if (annotationMode === ANNOTATION_TYPE_POLYLINE) { - return true; - } - return false; - } - - return true; - }, - [ - annotationMode, - scene, - isActiveDrawMode, - selectPlanarPolygonGroupById, - selectedPlanarPolygonGroupId, - ] - ); - - useEffect(() => { - if (!scene || scene.isDestroyed() || !selectionModeActive) { - return; - } - - const clickHandler = new ScreenSpaceEventHandler(scene.canvas); - clickHandler.setInputAction((event) => { - const screenPosition = event.position; - if (!screenPosition) return; - - const picked = scene.pick(screenPosition); - if (!picked) { - selectMeasurementById(null); - selectPlanarPolygonGroupById(null); - return; - } - const pickedPolygonGroupId = picked?.id?.polygonGroupId; - if (typeof pickedPolygonGroupId !== "string") return; - if (!pickedPolygonGroupId.trim()) return; - - selectMeasurementById(null); - selectPlanarPolygonGroupById(pickedPolygonGroupId); - }, ScreenSpaceEventType.LEFT_CLICK); - - return () => { - clickHandler.destroy(); - }; - }, [ - scene, - selectionModeActive, - selectMeasurementById, - selectPlanarPolygonGroupById, - ]); - - const upsertDirectDistanceRelation = useCallback( - (sourcePointId: string, targetPointId: string) => { - if (!sourcePointId || !targetPointId || sourcePointId === targetPointId) { - return; - } - - setDistanceRelations((prev) => { - const relationIndex = prev.findIndex((relation) => - isSameDistanceRelationPair(relation, sourcePointId, targetPointId) - ); - const relation = - relationIndex >= 0 - ? withDistanceRelationEdgeId(prev[relationIndex]) - : ({ - id: getDistanceRelationId(sourcePointId, targetPointId), - edgeId: getMeasurementEdgeId(sourcePointId, targetPointId), - pointAId: sourcePointId, - pointBId: targetPointId, - anchorPointId: sourcePointId, - showDirectLine: distanceCreationLineVisibility.direct, - showVerticalLine: distanceCreationLineVisibility.vertical, - showHorizontalLine: distanceCreationLineVisibility.horizontal, - showComponentLines: - distanceCreationLineVisibility.vertical || - distanceCreationLineVisibility.horizontal, - labelVisibilityByKind: - DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - } satisfies PointDistanceRelation); - - const nextRelation: PointDistanceRelation = { - ...relation, - edgeId: getMeasurementEdgeId(sourcePointId, targetPointId), - anchorPointId: sourcePointId, - showDirectLine: - relation.showDirectLine ?? distanceCreationLineVisibility.direct, - showVerticalLine: - relation.showVerticalLine ?? - relation.showComponentLines ?? - distanceCreationLineVisibility.vertical, - showHorizontalLine: - relation.showHorizontalLine ?? - relation.showComponentLines ?? - distanceCreationLineVisibility.horizontal, - showComponentLines: - relation.showComponentLines ?? - relation.showVerticalLine ?? - relation.showHorizontalLine ?? - (distanceCreationLineVisibility.vertical || - distanceCreationLineVisibility.horizontal), - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - }, - directLabelMode: - relation.directLabelMode ?? DEFAULT_DIRECT_LINE_LABEL_MODE, - }; - - if (relationIndex < 0) return [...prev, nextRelation]; - return prev.map((entry, index) => - index === relationIndex ? nextRelation : entry - ); - }); - }, - [distanceCreationLineVisibility] - ); - - const syncPolygonEdgeDistanceRelations = useCallback( - ( - prevRelations: PointDistanceRelation[], - groups: PlanarPolygonGroup[] - ): PointDistanceRelation[] => { - const desiredById = new Map< - string, - { - groupId: string; - pointAId: string; - pointBId: string; - showDirectLine: boolean; - showComponentLines: boolean; - } - >(); - - groups.forEach((group) => { - if (group.vertexPointIds.length < 2) return; - const segmentLineMode = - group.segmentLineMode ?? - (group.closed - ? LINEAR_SEGMENT_LINE_MODE_DIRECT - : defaultPolylineSegmentLineMode); - const showDirectLine = - segmentLineMode === LINEAR_SEGMENT_LINE_MODE_DIRECT; - const showComponentLines = - segmentLineMode === LINEAR_SEGMENT_LINE_MODE_COMPONENTS; - const orderedVertices = group.vertexPointIds; - for (let index = 0; index < orderedVertices.length - 1; index += 1) { - const pointAId = orderedVertices[index]; - const pointBId = orderedVertices[index + 1]; - if (!pointAId || !pointBId) continue; - const relationId = getDistanceRelationId(pointAId, pointBId); - desiredById.set(relationId, { - groupId: group.id, - pointAId, - pointBId, - showDirectLine, - showComponentLines, - }); - } - if (group.closed && orderedVertices.length >= 3) { - const first = orderedVertices[0]; - const last = orderedVertices[orderedVertices.length - 1]; - if (first && last) { - const relationId = getDistanceRelationId(last, first); - desiredById.set(relationId, { - groupId: group.id, - pointAId: last, - pointBId: first, - showDirectLine, - showComponentLines, - }); - } - } - }); - - const next: PointDistanceRelation[] = []; - const handledIds = new Set(); - - prevRelations.forEach((relation) => { - const desired = desiredById.get(relation.id); - if (!desired) { - if (!relation.polygonGroupId) { - next.push(relation); - } - return; - } - - handledIds.add(relation.id); - next.push({ - ...withDistanceRelationEdgeId(relation), - edgeId: getMeasurementEdgeId(desired.pointAId, desired.pointBId), - pointAId: desired.pointAId, - pointBId: desired.pointBId, - anchorPointId: desired.pointAId, - polygonGroupId: desired.groupId, - showDirectLine: desired.showDirectLine, - showVerticalLine: desired.showComponentLines, - showHorizontalLine: desired.showComponentLines, - showComponentLines: desired.showComponentLines, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - }, - directLabelMode: - relation.directLabelMode ?? DEFAULT_DIRECT_LINE_LABEL_MODE, - }); - }); - - desiredById.forEach((desired, relationId) => { - if (handledIds.has(relationId)) return; - next.push({ - id: relationId, - edgeId: getMeasurementEdgeId(desired.pointAId, desired.pointBId), - pointAId: desired.pointAId, - pointBId: desired.pointBId, - anchorPointId: desired.pointAId, - polygonGroupId: desired.groupId, - showDirectLine: desired.showDirectLine, - showVerticalLine: desired.showComponentLines, - showHorizontalLine: desired.showComponentLines, - showComponentLines: desired.showComponentLines, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - }, - directLabelMode: DEFAULT_DIRECT_LINE_LABEL_MODE, - }); - }); - - return next; - }, - [defaultPolylineSegmentLineMode] - ); - - const handlePointMeasurePointCreated = useCallback( - (newPointId: string) => { - setDoubleClickChainSourcePointId(null); - setActivePlanarPolygonGroupId(null); - setSelectedPlanarPolygonGroupId(null); - if (pointLabelOnCreate) { - setLabelInputPromptPointId(newPointId); - } - selectMeasurementByIdImmediate(newPointId); - }, - [pointLabelOnCreate, selectMeasurementByIdImmediate] - ); - - const confirmPointLabelInputById = useCallback((id: string) => { - if (!id) return; - setLabelInputPromptPointId((previousPromptPointId) => - previousPromptPointId === id ? null : previousPromptPointId - ); - }, []); - - const handleDistancePointCreated = useCallback( - (newPointId: string, newPointPositionECEF: Cartesian3) => { - const sourcePointId = resolveDistanceRelationSourcePointId(newPointId); - if (sourcePointId) { - upsertDirectDistanceRelation(sourcePointId, newPointId); - // Distance-mode nodes stay node-only via `distanceAdhocNode`. - // No per-relation point ownership metadata is written. - } - - setActivePlanarPolygonGroupId(null); - setSelectedPlanarPolygonGroupId(null); - if (distanceModeStickyToFirstPoint) { - if (!referencePointMeasurementId) { - setReferencePoint(newPointPositionECEF); - } - setDoubleClickChainSourcePointId( - referencePointMeasurementId ?? newPointId - ); - } else { - setDoubleClickChainSourcePointId(sourcePointId ? null : newPointId); - } - selectMeasurementByIdImmediate(newPointId); - }, - [ - distanceModeStickyToFirstPoint, - referencePointMeasurementId, - resolveDistanceRelationSourcePointId, - selectMeasurementByIdImmediate, - setReferencePoint, - upsertDirectDistanceRelation, - ] - ); - - const handlePolylinePointCreated = useCallback( - (newPointId: string, newPointPositionECEF: Cartesian3) => { - const sourcePointId = resolveDistanceRelationSourcePointId(newPointId); - - let projectedPointPosition: Cartesian3 | null = null; - const activeGroupSnapshot = - (activePlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId - ) - : null) ?? null; - const creatingNewGroup = - !activeGroupSnapshot || Boolean(activeGroupSnapshot.closed); - const nextActiveGroupId = creatingNewGroup - ? `planar-polygon-${Date.now()}-${newPointId}` - : activeGroupSnapshot.id; - const pointByIdSnapshot = getPointPositionMap(annotations, { - [newPointId]: newPointPositionECEF, - }); - const seedPlanarGroupConfig = resolvePlanarGroupSeedConfig({ - planarToolCreationMode, - polygonSurfaceTypePreset, - defaultPolylineSegmentLineMode, - }); - const seedMeasurementKindForCreation = - seedPlanarGroupConfig.measurementKind; - const seedSurfaceTypeForCreation = seedPlanarGroupConfig.surfaceType; - const facadeAutoCloseFromNewPoint = (() => { - if (planarToolCreationMode !== PLANAR_TOOL_CREATION_MODE_POLYGON) - return null; - - const candidateVertexPointIds = creatingNewGroup - ? sourcePointId && - sourcePointId !== newPointId && - pointByIdSnapshot.has(sourcePointId) - ? [sourcePointId, newPointId] - : [newPointId] - : [...(activeGroupSnapshot?.vertexPointIds ?? []), newPointId]; - - const candidateSurfaceType = creatingNewGroup - ? seedSurfaceTypeForCreation - : activeGroupSnapshot?.surfaceType ?? "roof"; - - if (candidateSurfaceType !== "facade") return null; - if (candidateVertexPointIds.length !== 2) return null; - - return buildFacadeAutoCloseRectangle( - pointByIdSnapshot, - candidateVertexPointIds[0] ?? null, - candidateVertexPointIds[1] ?? null - ); - })(); - const createdFacadeAutoCorners = facadeAutoCloseFromNewPoint?.autoCorners; - const autoClosedAsFacadeRectangle = Boolean(facadeAutoCloseFromNewPoint); - - if (sourcePointId && !autoClosedAsFacadeRectangle) { - upsertDirectDistanceRelation(sourcePointId, newPointId); - } - - setPlanarPolygonGroups((prev) => { - const activeGroup = - (activePlanarPolygonGroupId - ? prev.find((group) => group.id === activePlanarPolygonGroupId) - : null) ?? null; - - const pointById = getPointPositionMap(annotations, { - [newPointId]: newPointPositionECEF, - }); - - if (!activeGroup || activeGroup.closed) { - const seedVertexPointIds = - sourcePointId && - sourcePointId !== newPointId && - pointById.has(sourcePointId) - ? [sourcePointId, newPointId] - : [newPointId]; - const seedSurfaceType = seedPlanarGroupConfig.surfaceType; - const seedMeasurementKind = seedPlanarGroupConfig.measurementKind; - const seedSegmentLineMode = seedPlanarGroupConfig.segmentLineMode; - - if ( - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - seedSurfaceType === "facade" && - seedVertexPointIds.length === 2 && - facadeAutoCloseFromNewPoint - ) { - facadeAutoCloseFromNewPoint.autoCorners.forEach( - ({ id, position }) => { - pointById.set(id, position); - } - ); - const closedVertexPointIds = [ - ...facadeAutoCloseFromNewPoint.closedVertexPointIds, - ]; - const closedEdgeRelationIds = buildEdgeRelationIdsForPolygon( - closedVertexPointIds, - true, - getDistanceRelationId - ); - return [ - ...prev, - computePolygonGroupDerivedDataWithCamera( - { - id: nextActiveGroupId, - measurementKind: seedMeasurementKindForCreation, - segmentLineMode: seedSegmentLineMode, - verticalOffsetMeters: polylineVerticalOffsetMeters, - vertexPointIds: closedVertexPointIds, - edgeRelationIds: closedEdgeRelationIds, - distanceMeasurementStartPointId: - closedVertexPointIds[0] ?? undefined, - closed: true, - planeLocked: true, - areaSquareMeters: 0, - verticalityDeg: 0, - surfaceType: seedSurfaceType, - }, - pointById - ), - ]; - } - - const seedEdgeRelationIds = buildEdgeRelationIdsForPolygon( - seedVertexPointIds, - false, - getDistanceRelationId - ); - return [ - ...prev, - { - id: nextActiveGroupId, - measurementKind: seedMeasurementKind, - segmentLineMode: seedSegmentLineMode, - verticalOffsetMeters: polylineVerticalOffsetMeters, - vertexPointIds: seedVertexPointIds, - edgeRelationIds: seedEdgeRelationIds, - distanceMeasurementStartPointId: - seedVertexPointIds[0] ?? undefined, - closed: false, - planeLocked: false, - areaSquareMeters: 0, - verticalityDeg: 0, - surfaceType: seedSurfaceType, - }, - ]; - } - - let nextVertexPointIds = [...activeGroup.vertexPointIds, newPointId]; - let shouldCloseGroup = activeGroup.closed; - let nextPlane = activeGroup.plane; - let nextPlaneLocked = activeGroup.planeLocked; - let nextPointPosition = newPointPositionECEF; - const shouldKeepSurfaceSampledVertices = - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "footprint"; - const isRoofSurface = - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "roof"; - - if ( - isRoofSurface && - !nextPlaneLocked && - activeGroup.vertexPointIds.length === 1 - ) { - const firstVertexPointId = activeGroup.vertexPointIds[0] ?? null; - const firstVertexPointPosition = firstVertexPointId - ? pointById.get(firstVertexPointId) ?? null - : null; - if (firstVertexPointPosition) { - nextPointPosition = projectPointToHorizontalPlaneAtAnchor( - nextPointPosition, - firstVertexPointPosition - ); - projectedPointPosition = nextPointPosition; - pointById.set(newPointId, nextPointPosition); - } - } - - if (!shouldKeepSurfaceSampledVertices && nextPlaneLocked && nextPlane) { - nextPointPosition = projectPointOntoPlane( - nextPointPosition, - nextPlane - ); - projectedPointPosition = nextPointPosition; - pointById.set(newPointId, nextPointPosition); - } else if ( - !shouldKeepSurfaceSampledVertices && - isRoofSurface && - !nextPlaneLocked && - nextVertexPointIds.length >= 3 - ) { - const first = pointById.get(nextVertexPointIds[0] ?? ""); - const second = pointById.get(nextVertexPointIds[1] ?? ""); - if (first && second) { - const candidatePlane = createPlaneFromThreePoints( - first, - second, - nextPointPosition - ); - if (candidatePlane) { - nextPlane = orientPlaneTowardSceneCamera(candidatePlane); - nextPlaneLocked = true; - nextPointPosition = projectPointOntoPlane( - nextPointPosition, - nextPlane - ); - projectedPointPosition = nextPointPosition; - pointById.set(newPointId, nextPointPosition); - } - } - } else if ( - !shouldKeepSurfaceSampledVertices && - !isRoofSurface && - nextVertexPointIds.length >= 4 - ) { - const first = pointById.get(nextVertexPointIds[0] ?? ""); - const second = pointById.get(nextVertexPointIds[1] ?? ""); - const third = pointById.get(nextVertexPointIds[2] ?? ""); - if (first && second && third) { - const candidatePlane = createPlaneFromThreePoints( - first, - second, - third - ); - if (candidatePlane) { - const orientedCandidatePlane = - orientPlaneTowardSceneCamera(candidatePlane); - const planeDistance = distancePointToPlane( - nextPointPosition, - orientedCandidatePlane - ); - const firstFourPoints = nextVertexPointIds - .slice(0, 4) - .map((pointId) => pointById.get(pointId)) - .filter((point): point is Cartesian3 => Boolean(point)); - const planarAngleSum = computePolylinePlanarAngleSumDeg( - firstFourPoints, - orientedCandidatePlane - ); - - if ( - planeDistance <= PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS && - planarAngleSum < PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG - ) { - nextPlane = orientedCandidatePlane; - nextPlaneLocked = true; - nextPointPosition = projectPointOntoPlane( - nextPointPosition, - orientedCandidatePlane - ); - projectedPointPosition = nextPointPosition; - pointById.set(newPointId, nextPointPosition); - } - } - } - } - - if ( - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "facade" && - nextVertexPointIds.length === 2 && - facadeAutoCloseFromNewPoint - ) { - facadeAutoCloseFromNewPoint.autoCorners.forEach( - ({ id, position }) => { - pointById.set(id, position); - } - ); - nextVertexPointIds = [ - ...facadeAutoCloseFromNewPoint.closedVertexPointIds, - ]; - shouldCloseGroup = true; - nextPlaneLocked = true; - } - - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - shouldCloseGroup, - getDistanceRelationId - ); - const updatedGroup = computePolygonGroupDerivedDataWithCamera( - { - ...activeGroup, - measurementKind: activeGroup.measurementKind, - vertexPointIds: nextVertexPointIds, - edgeRelationIds: nextEdgeRelationIds, - closed: shouldCloseGroup, - planeLocked: shouldKeepSurfaceSampledVertices - ? false - : nextPlaneLocked, - plane: shouldKeepSurfaceSampledVertices ? undefined : nextPlane, - }, - pointById - ); - return prev.map((group) => - group.id === activeGroup.id ? updatedGroup : group - ); - }); - - setActivePlanarPolygonGroupId(nextActiveGroupId); - - if (projectedPointPosition) { - const geometryWGS84 = getDegreesFromCartesian(projectedPointPosition); - setAnnotations((prev) => - prev.map((measurement) => { - if ( - !isPointAnnotationEntry(measurement) || - measurement.id !== newPointId - ) { - return measurement; - } - return { - ...measurement, - geometryECEF: projectedPointPosition as Cartesian3, - geometryWGS84: { - longitude: geometryWGS84.longitude, - latitude: geometryWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(geometryWGS84.altitude), - }, - }; - }) - ); - } - - if (createdFacadeAutoCorners && createdFacadeAutoCorners.length > 0) { - setAnnotations((prev) => { - const pointMeasurements = prev.filter(isPointAnnotationEntry); - const maxPointIndex = pointMeasurements.reduce( - (maxIndex, measurement) => - Math.max(maxIndex, measurement.index ?? 0), - 0 - ); - const autoCornerEntries: AnnotationEntry[] = - createdFacadeAutoCorners.map(({ id, position }, index) => { - const cornerWGS84 = getDegreesFromCartesian(position); - return { - type: ANNOTATION_TYPE_DISTANCE, - id, - index: maxPointIndex + index + 1, - isFacadeAutoCorner: true, - geometryECEF: position, - geometryWGS84: { - longitude: cornerWGS84.longitude, - latitude: cornerWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(cornerWGS84.altitude), - }, - timestamp: Date.now() + index, - }; - }); - return [...prev, ...autoCornerEntries]; - }); - } - - if (autoClosedAsFacadeRectangle) { - setDoubleClickChainSourcePointId(null); - setActivePlanarPolygonGroupId(null); - setSelectedPlanarPolygonGroupId(nextActiveGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setPreviousSelectedMeasurementId(null); - } else { - setDoubleClickChainSourcePointId(newPointId); - if (sourcePointId) { - setSelectedPlanarPolygonGroupId(nextActiveGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setPreviousSelectedMeasurementId(null); - } else { - selectMeasurementById(newPointId); - } - } - }, - [ - activePlanarPolygonGroupId, - annotations, - planarPolygonGroups, - resolveDistanceRelationSourcePointId, - selectMeasurementById, - upsertDirectDistanceRelation, - setAnnotations, - defaultPolylineSegmentLineMode, - polylineVerticalOffsetMeters, - planarToolCreationMode, - polygonSurfaceTypePreset, - orientPlaneTowardSceneCamera, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - const pointCreatedHandlerByMode = useMemo< - Partial< - Record void> - > - >( - () => ({ - [ANNOTATION_TYPE_POINT]: (id) => handlePointMeasurePointCreated(id), - [ANNOTATION_TYPE_DISTANCE]: (id, positionECEF) => - handleDistancePointCreated(id, positionECEF), - [ANNOTATION_TYPE_POLYLINE]: (id, positionECEF) => - handlePolylinePointCreated(id, positionECEF), - }), - [ - handlePointMeasurePointCreated, - handleDistancePointCreated, - handlePolylinePointCreated, - ] - ); - - const handlePointQueryPointCreated = useCallback( - (newPointId: string, newPointPositionECEF: Cartesian3) => { - pointCreatedHandlerByMode[annotationMode]?.( - newPointId, - newPointPositionECEF - ); - }, - [annotationMode, pointCreatedHandlerByMode] - ); - - const closeActivePlanarPolygonGroup = useCallback( - (surfaceTypeOverride?: PolygonSurfacePreset) => { - let closedGroupId: string | null = null; - - setPlanarPolygonGroups((prev) => { - if (!activePlanarPolygonGroupId) return prev; - const activeGroup = prev.find( - (group) => group.id === activePlanarPolygonGroupId - ); - if ( - !activeGroup || - activeGroup.closed || - activeGroup.vertexPointIds.length < 3 - ) { - return prev; - } - - const pointById = getPointPositionMap(annotations); - const closedGroup = computePolygonGroupDerivedDataWithCamera( - { - ...activeGroup, - closed: true, - surfaceType: - surfaceTypeOverride ?? activeGroup.surfaceType ?? "roof", - edgeRelationIds: buildEdgeRelationIdsForPolygon( - activeGroup.vertexPointIds, - true, - getDistanceRelationId - ), - }, - pointById - ); - closedGroupId = activeGroup.id; - return prev.map((group) => - group.id === activeGroup.id ? closedGroup : group - ); - }); - - setActivePlanarPolygonGroupId(null); - setDoubleClickChainSourcePointId(null); - - if (closedGroupId) { - setSelectedPlanarPolygonGroupId(closedGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setPreviousSelectedMeasurementId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - } - }, - [ - activePlanarPolygonGroupId, - annotations, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - const confirmPolylineRingPromotion = useCallback( - (surfaceType: PolygonSurfacePreset) => { - if (!pendingPolylinePromotionRingClosurePointId) return; - setPendingPolylinePromotionRingClosurePointId(null); - closeActivePlanarPolygonGroup(surfaceType); - }, - [ - pendingPolylinePromotionRingClosurePointId, - closeActivePlanarPolygonGroup, - setPendingPolylinePromotionRingClosurePointId, - ] - ); - - const cancelPolylineRingPromotion = useCallback(() => { - if (!pendingPolylinePromotionRingClosurePointId) return; - const ringClosurePointId = pendingPolylinePromotionRingClosurePointId; - setPendingPolylinePromotionRingClosurePointId(null); - closeActivePlanarPolylineGroupAsRing(ringClosurePointId); - }, [ - pendingPolylinePromotionRingClosurePointId, - setPendingPolylinePromotionRingClosurePointId, - ]); - - const closeActivePlanarPolylineGroupAsRing = useCallback( - (ringClosurePointId: string) => { - if (!activePlanarPolygonGroupId) return; - const finishedGroupId = activePlanarPolygonGroupId; - - setPlanarPolygonGroups((prev) => { - const pointById = getPointPositionMap(annotations); - return prev.map((group) => { - if (group.id !== activePlanarPolygonGroupId || group.closed) { - return group; - } - if (group.vertexPointIds.length < 3) { - return group; - } - - const lastPointId = - group.vertexPointIds[group.vertexPointIds.length - 1] ?? null; - const nextVertexPointIds = - lastPointId === ringClosurePointId - ? [...group.vertexPointIds] - : [...group.vertexPointIds, ringClosurePointId]; - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - false, - getDistanceRelationId - ); - - return computePolygonGroupDerivedDataWithCamera( - { - ...group, - closed: false, - edgeRelationIds: nextEdgeRelationIds, - vertexPointIds: nextVertexPointIds, - }, - pointById - ); - }); - }); - - setActivePlanarPolygonGroupId(null); - setDoubleClickChainSourcePointId(null); - setSelectedPlanarPolygonGroupId(finishedGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - }, - [ - activePlanarPolygonGroupId, - annotations, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - const finishActivePlanarPolylineGroup = useCallback(() => { - if (!activePlanarPolygonGroupId) return; - const finishedGroupId = activePlanarPolygonGroupId; - setActivePlanarPolygonGroupId(null); - setDoubleClickChainSourcePointId(null); - setSelectedPlanarPolygonGroupId(finishedGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - }, [activePlanarPolygonGroupId]); - - const handlePointQueryDoubleClick = useCallback(() => { - if ( - annotationMode === ANNOTATION_TYPE_POLYLINE && - activePlanarPolygonGroupId - ) { - const activeOpenGroup = - planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId && !group.closed - ) ?? null; - const firstVertexId = activeOpenGroup?.vertexPointIds[0] ?? null; - const canCloseRing = Boolean( - firstVertexId && - activeOpenGroup && - activeOpenGroup.vertexPointIds.length >= 3 - ); - - if (canCloseRing && firstVertexId) { - if (planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON) { - closeActivePlanarPolygonGroup(); - } else { - finishActivePlanarPolylineGroup(); - } - return; - } - } - - // Finish current open line chain when no ring closure can be performed. - finishActivePlanarPolylineGroup(); - }, [ - annotationMode, - activePlanarPolygonGroupId, - planarPolygonGroups, - planarToolCreationMode, - closeActivePlanarPolygonGroup, - finishActivePlanarPolylineGroup, - ]); - - const appendExistingPointToActivePlanarPolygonGroup = useCallback( - (existingPointId: string, sourcePointId?: string | null) => { - const existingPoint = getPointById(annotations, existingPointId); - if (!existingPoint || !isPointAnnotationEntry(existingPoint)) return; - const existingPointPosition = existingPoint.geometryECEF; - const activeGroupSnapshot = - (activePlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId - ) - : null) ?? null; - const creatingNewGroup = - !activeGroupSnapshot || Boolean(activeGroupSnapshot.closed); - const nextActiveGroupId = creatingNewGroup - ? `planar-polygon-${Date.now()}-${existingPointId}` - : activeGroupSnapshot.id; - const pointByIdSnapshot = getPointPositionMap(annotations); - const seedPlanarGroupConfig = resolvePlanarGroupSeedConfig({ - planarToolCreationMode, - polygonSurfaceTypePreset, - defaultPolylineSegmentLineMode, - }); - const seedMeasurementKindForCreation = - seedPlanarGroupConfig.measurementKind; - const seedSurfaceTypeForCreation = seedPlanarGroupConfig.surfaceType; - const facadeAutoCloseFromExistingPoint = (() => { - if (planarToolCreationMode !== PLANAR_TOOL_CREATION_MODE_POLYGON) - return null; - - const candidateVertexPointIds = creatingNewGroup - ? sourcePointId && - sourcePointId !== existingPointId && - pointByIdSnapshot.has(sourcePointId) - ? [sourcePointId, existingPointId] - : [existingPointId] - : [...(activeGroupSnapshot?.vertexPointIds ?? []), existingPointId]; - - const candidateSurfaceType = creatingNewGroup - ? seedSurfaceTypeForCreation - : activeGroupSnapshot?.surfaceType ?? "roof"; - - if (candidateSurfaceType !== "facade") return null; - if (candidateVertexPointIds.length !== 2) return null; - - return buildFacadeAutoCloseRectangle( - pointByIdSnapshot, - candidateVertexPointIds[0] ?? null, - candidateVertexPointIds[1] ?? null - ); - })(); - const createdFacadeAutoCorners = - facadeAutoCloseFromExistingPoint?.autoCorners; - const autoClosedAsFacadeRectangle = Boolean( - facadeAutoCloseFromExistingPoint - ); - - setPlanarPolygonGroups((prev) => { - const activeGroup = - (activePlanarPolygonGroupId - ? prev.find((group) => group.id === activePlanarPolygonGroupId) - : null) ?? null; - const pointById = getPointPositionMap(annotations); - - if (!activeGroup || activeGroup.closed) { - const seedVertexPointIds = - sourcePointId && - sourcePointId !== existingPointId && - pointById.has(sourcePointId) - ? [sourcePointId, existingPointId] - : [existingPointId]; - const seedSurfaceType = seedPlanarGroupConfig.surfaceType; - const seedMeasurementKind = seedPlanarGroupConfig.measurementKind; - const seedSegmentLineMode = seedPlanarGroupConfig.segmentLineMode; - - if ( - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - seedSurfaceType === "facade" && - seedVertexPointIds.length === 2 && - facadeAutoCloseFromExistingPoint - ) { - facadeAutoCloseFromExistingPoint.autoCorners.forEach( - ({ id, position }) => { - pointById.set(id, position); - } - ); - const closedVertexPointIds = [ - ...facadeAutoCloseFromExistingPoint.closedVertexPointIds, - ]; - const closedEdgeRelationIds = buildEdgeRelationIdsForPolygon( - closedVertexPointIds, - true, - getDistanceRelationId - ); - return [ - ...prev, - computePolygonGroupDerivedDataWithCamera( - { - id: nextActiveGroupId, - measurementKind: seedMeasurementKindForCreation, - segmentLineMode: seedSegmentLineMode, - verticalOffsetMeters: polylineVerticalOffsetMeters, - vertexPointIds: closedVertexPointIds, - edgeRelationIds: closedEdgeRelationIds, - distanceMeasurementStartPointId: - closedVertexPointIds[0] ?? undefined, - closed: true, - planeLocked: true, - areaSquareMeters: 0, - verticalityDeg: 0, - surfaceType: seedSurfaceType, - }, - pointById - ), - ]; - } - - const seedEdgeRelationIds = buildEdgeRelationIdsForPolygon( - seedVertexPointIds, - false, - getDistanceRelationId - ); - return [ - ...prev, - { - id: nextActiveGroupId, - measurementKind: seedMeasurementKind, - segmentLineMode: seedSegmentLineMode, - verticalOffsetMeters: polylineVerticalOffsetMeters, - vertexPointIds: seedVertexPointIds, - edgeRelationIds: seedEdgeRelationIds, - distanceMeasurementStartPointId: - seedVertexPointIds[0] ?? undefined, - closed: false, - planeLocked: false, - areaSquareMeters: 0, - verticalityDeg: 0, - surfaceType: seedSurfaceType, - }, - ]; - } - - const lastVertexId = - activeGroup.vertexPointIds[activeGroup.vertexPointIds.length - 1] ?? - null; - if (lastVertexId === existingPointId) { - return prev; - } - - let nextVertexPointIds = [ - ...activeGroup.vertexPointIds, - existingPointId, - ]; - let shouldCloseGroup = activeGroup.closed; - let nextPlane = activeGroup.plane; - let nextPlaneLocked = activeGroup.planeLocked; - const shouldKeepSurfaceSampledVertices = - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "footprint"; - const isRoofSurface = - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "roof"; - - if ( - !shouldKeepSurfaceSampledVertices && - isRoofSurface && - !nextPlaneLocked && - nextVertexPointIds.length >= 3 - ) { - const first = pointById.get(nextVertexPointIds[0] ?? ""); - const second = pointById.get(nextVertexPointIds[1] ?? ""); - if (first && second) { - const candidatePlane = createPlaneFromThreePoints( - first, - second, - existingPointPosition - ); - if (candidatePlane) { - nextPlane = orientPlaneTowardSceneCamera(candidatePlane); - nextPlaneLocked = true; - } - } - } else if ( - !shouldKeepSurfaceSampledVertices && - !isRoofSurface && - !nextPlaneLocked && - nextVertexPointIds.length >= 4 - ) { - const first = pointById.get(nextVertexPointIds[0] ?? ""); - const second = pointById.get(nextVertexPointIds[1] ?? ""); - const third = pointById.get(nextVertexPointIds[2] ?? ""); - if (first && second && third) { - const candidatePlane = createPlaneFromThreePoints( - first, - second, - third - ); - if (candidatePlane) { - const orientedCandidatePlane = - orientPlaneTowardSceneCamera(candidatePlane); - const planeDistance = distancePointToPlane( - existingPointPosition, - orientedCandidatePlane - ); - const firstFourPoints = nextVertexPointIds - .slice(0, 4) - .map((pointId) => pointById.get(pointId)) - .filter((point): point is Cartesian3 => Boolean(point)); - const planarAngleSum = computePolylinePlanarAngleSumDeg( - firstFourPoints, - orientedCandidatePlane - ); - - if ( - planeDistance <= PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS && - planarAngleSum < PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG - ) { - nextPlane = orientedCandidatePlane; - nextPlaneLocked = true; - } - } - } - } - - if ( - planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON && - (activeGroup.surfaceType ?? "roof") === "facade" && - nextVertexPointIds.length === 2 && - facadeAutoCloseFromExistingPoint - ) { - facadeAutoCloseFromExistingPoint.autoCorners.forEach( - ({ id, position }) => { - pointById.set(id, position); - } - ); - nextVertexPointIds = [ - ...facadeAutoCloseFromExistingPoint.closedVertexPointIds, - ]; - shouldCloseGroup = true; - nextPlaneLocked = true; - } - - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - shouldCloseGroup, - getDistanceRelationId - ); - const updatedGroup = computePolygonGroupDerivedDataWithCamera( - { - ...activeGroup, - measurementKind: activeGroup.measurementKind, - vertexPointIds: nextVertexPointIds, - edgeRelationIds: nextEdgeRelationIds, - closed: shouldCloseGroup, - planeLocked: shouldKeepSurfaceSampledVertices - ? false - : nextPlaneLocked, - plane: shouldKeepSurfaceSampledVertices ? undefined : nextPlane, - }, - pointById - ); - return prev.map((group) => - group.id === activeGroup.id ? updatedGroup : group - ); - }); - - if (createdFacadeAutoCorners && createdFacadeAutoCorners.length > 0) { - setAnnotations((prev) => { - const pointMeasurements = prev.filter(isPointAnnotationEntry); - const maxPointIndex = pointMeasurements.reduce( - (maxIndex, measurement) => - Math.max(maxIndex, measurement.index ?? 0), - 0 - ); - const autoCornerEntries: AnnotationEntry[] = - createdFacadeAutoCorners.map(({ id, position }, index) => { - const cornerWGS84 = getDegreesFromCartesian(position); - return { - type: ANNOTATION_TYPE_DISTANCE, - id, - index: maxPointIndex + index + 1, - isFacadeAutoCorner: true, - geometryECEF: position, - geometryWGS84: { - longitude: cornerWGS84.longitude, - latitude: cornerWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(cornerWGS84.altitude), - }, - timestamp: Date.now() + index, - }; - }); - return [...prev, ...autoCornerEntries]; - }); - } - - if (autoClosedAsFacadeRectangle) { - setDoubleClickChainSourcePointId(null); - setActivePlanarPolygonGroupId(null); - setSelectedPlanarPolygonGroupId(nextActiveGroupId); - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - return true; - } - - setActivePlanarPolygonGroupId(nextActiveGroupId); - return false; - }, - [ - activePlanarPolygonGroupId, - annotations, - planarPolygonGroups, - defaultPolylineSegmentLineMode, - polylineVerticalOffsetMeters, - planarToolCreationMode, - polygonSurfaceTypePreset, - orientPlaneTowardSceneCamera, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - const setShowSelectedReferenceLine = useCallback< - Dispatch> - >( - (value) => { - if (!selectedDistancePair) return; - - const { activePointId, previousPointId } = selectedDistancePair; - setDistanceRelations((prev) => { - const relationIndex = prev.findIndex((relation) => - isSameDistanceRelationPair(relation, activePointId, previousPointId) - ); - const relation = - relationIndex >= 0 - ? withDistanceRelationEdgeId(prev[relationIndex]) - : ({ - id: getDistanceRelationId(activePointId, previousPointId), - edgeId: getMeasurementEdgeId(activePointId, previousPointId), - pointAId: activePointId, - pointBId: previousPointId, - anchorPointId: activePointId, - showDirectLine: false, - showVerticalLine: false, - showHorizontalLine: false, - showComponentLines: false, - labelVisibilityByKind: - DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - } satisfies PointDistanceRelation); - const currentValue = relation.showDirectLine ?? false; - const nextValue = - typeof value === "function" ? value(currentValue) : value; - - if (nextValue === currentValue && relationIndex >= 0) { - return prev; - } - - const nextRelation: PointDistanceRelation = { - ...relation, - edgeId: getMeasurementEdgeId(activePointId, previousPointId), - anchorPointId: activePointId, - showDirectLine: nextValue, - showVerticalLine: - relation.showVerticalLine ?? relation.showComponentLines ?? false, - showHorizontalLine: - relation.showHorizontalLine ?? relation.showComponentLines ?? false, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - }, - }; - - if (!hasAnyVisibleDistanceRelationLine(nextRelation)) { - if (relationIndex < 0) return prev; - return prev.filter((_, index) => index !== relationIndex); - } - - if (relationIndex < 0) return [...prev, nextRelation]; - return prev.map((entry, index) => - index === relationIndex ? nextRelation : entry - ); - }); - }, - [selectedDistancePair] - ); - - const setShowSelectedReferenceLineComponents = useCallback< - Dispatch> - >( - (value) => { - if (!selectedDistancePair) return; - - const { activePointId, previousPointId } = selectedDistancePair; - setDistanceRelations((prev) => { - const relationIndex = prev.findIndex((relation) => - isSameDistanceRelationPair(relation, activePointId, previousPointId) - ); - const relation = - relationIndex >= 0 - ? withDistanceRelationEdgeId(prev[relationIndex]) - : ({ - id: getDistanceRelationId(activePointId, previousPointId), - edgeId: getMeasurementEdgeId(activePointId, previousPointId), - pointAId: activePointId, - pointBId: previousPointId, - anchorPointId: activePointId, - showDirectLine: false, - showVerticalLine: false, - showHorizontalLine: false, - showComponentLines: false, - labelVisibilityByKind: - DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - } satisfies PointDistanceRelation); - const currentValue = - (relation.showVerticalLine ?? relation.showComponentLines ?? false) || - (relation.showHorizontalLine ?? relation.showComponentLines ?? false); - const nextValue = - typeof value === "function" ? value(currentValue) : value; - - if (nextValue === currentValue && relationIndex >= 0) { - return prev; - } - - const nextRelation: PointDistanceRelation = { - ...relation, - edgeId: getMeasurementEdgeId(activePointId, previousPointId), - anchorPointId: activePointId, - showVerticalLine: nextValue, - showHorizontalLine: nextValue, - showComponentLines: nextValue, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - }, - }; - - if (!hasAnyVisibleDistanceRelationLine(nextRelation)) { - if (relationIndex < 0) return prev; - return prev.filter((_, index) => index !== relationIndex); - } - - if (relationIndex < 0) return [...prev, nextRelation]; - return prev.map((entry, index) => - index === relationIndex ? nextRelation : entry - ); - }); - }, - [selectedDistancePair] - ); - - const toggleDistanceRelationLineLabelVisibility = useCallback( - (relationId: string, kind: ReferenceLineLabelKind) => { - if (!relationId) return; - setDistanceRelations((prev) => - prev.map((relation) => { - if (relation.id !== relationId) return relation; - const currentValue = - relation.labelVisibilityByKind?.[kind] ?? - DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY[kind]; - return { - ...relation, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - [kind]: !currentValue, - }, - }; - }) - ); - }, - [] - ); - - const handleDistanceRelationLineLabelToggle = useCallback( - (relationId: string, kind: ReferenceLineLabelKind) => { - if (!relationId) return; - - const ownerGroupIdsFromPolygons = planarPolygonGroups - .filter((group) => group.edgeRelationIds.includes(relationId)) - .map((group) => group.id); - const ownerGroupIdsFromPolylines = polylines - .filter((polyline) => polyline.edgeRelationIds.includes(relationId)) - .map((polyline) => polyline.id); - const ownerGroupIds = Array.from( - new Set([...ownerGroupIdsFromPolygons, ...ownerGroupIdsFromPolylines]) - ); - const selectedGroupOwnsRelation = - !!selectedPlanarPolygonGroupId && - ownerGroupIds.includes(selectedPlanarPolygonGroupId); - - if (ownerGroupIds.length > 0 && !selectedGroupOwnsRelation) { - const preferredOwnerGroupId = - (activePlanarPolygonGroupId && - ownerGroupIds.includes(activePlanarPolygonGroupId) - ? activePlanarPolygonGroupId - : ownerGroupIds[0]) ?? null; - selectPlanarPolygonGroupById(preferredOwnerGroupId); - return; - } - - // For "direct" kind on open polylines, cycle mode on ALL edges in the connected polyline - if (kind === "direct" && selectedPlanarPolygonGroupId) { - const connectedOpenGroupIds = getConnectedOpenPolylineGroupIds( - planarPolygonGroups, - selectedPlanarPolygonGroupId - ); - if (connectedOpenGroupIds.size > 0) { - const allRelationIds = new Set(); - planarPolygonGroups.forEach((group) => { - if (!connectedOpenGroupIds.has(group.id)) return; - group.edgeRelationIds.forEach((rid) => allRelationIds.add(rid)); - }); - - if (allRelationIds.size > 0) { - setDistanceRelations((prev) => { - const currentMode: DirectLineLabelMode = - prev.find((r) => r.id === relationId)?.directLabelMode ?? - DEFAULT_DIRECT_LINE_LABEL_MODE; - const nextMode = getNextDirectLineLabelMode(currentMode); - return prev.map((relation) => { - if (!allRelationIds.has(relation.id)) return relation; - return { - ...relation, - directLabelMode: nextMode, - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - direct: nextMode !== "none", - }, - }; - }); - }); - return; - } - } - } - - toggleDistanceRelationLineLabelVisibility(relationId, kind); - }, - [ - activePlanarPolygonGroupId, - planarPolygonGroups, - polylines, - selectedPlanarPolygonGroupId, - selectPlanarPolygonGroupById, - setDistanceRelations, - toggleDistanceRelationLineLabelVisibility, - ] - ); - - const handleDistanceRelationLineClick = useCallback( - (relationId: string, kind: ReferenceLineLabelKind) => { - if (!relationId || kind !== "direct") return; - - const ownerGroupIdsFromPolygons = planarPolygonGroups - .filter((group) => group.edgeRelationIds.includes(relationId)) - .map((group) => group.id); - const ownerGroupIdsFromPolylines = polylines - .filter((polyline) => polyline.edgeRelationIds.includes(relationId)) - .map((polyline) => polyline.id); - const ownerGroupIds = Array.from( - new Set([...ownerGroupIdsFromPolygons, ...ownerGroupIdsFromPolylines]) - ); - const selectedGroupOwnsRelation = - !!selectedPlanarPolygonGroupId && - ownerGroupIds.includes(selectedPlanarPolygonGroupId); - - if (ownerGroupIds.length > 0) { - if (selectedGroupOwnsRelation) { - return; - } - const preferredOwnerGroupId = - (activePlanarPolygonGroupId && - ownerGroupIds.includes(activePlanarPolygonGroupId) - ? activePlanarPolygonGroupId - : ownerGroupIds[0]) ?? null; - selectPlanarPolygonGroupById(preferredOwnerGroupId); - return; - } - }, - [ - activePlanarPolygonGroupId, - planarPolygonGroups, - polylines, - selectedPlanarPolygonGroupId, - selectPlanarPolygonGroupById, - ] - ); - - const { updateLabelAppearanceById: updatePointLabelAppearanceById } = - useAnnotationEntryMutations({ - setAnnotations, - isLabelAppearanceTarget: isPointAnnotationEntry, - getLabelAppearance: (measurement) => - isPointAnnotationEntry(measurement) - ? measurement.labelAppearance - : undefined, - applyLabelAppearance: (measurement, appearance) => { - if (!isPointAnnotationEntry(measurement)) { - return measurement; - } - return applyLabelAppearance(measurement, appearance); - }, - normalizeLabelAppearance: normalizeLabelAppearance, - }); - - const updatePlanarPolygonNameById = useCallback( - (id: string, name: string) => { - const nextName = name.trim(); - setPlanarPolygonGroups((prev) => { - let hasChanged = false; - const next = prev.map((group) => { - if (group.id !== id) return group; - if ((group.name ?? "") === nextName) return group; - hasChanged = true; - return { - ...group, - name: nextName.length > 0 ? nextName : undefined, - }; - }); - return hasChanged ? next : prev; - }); - }, - [] - ); - - const setPointLabelMetricModeById = useCallback( - (id: string, mode: PointLabelMetricMode) => { - setAnnotations((prev) => { - let hasChanged = false; - const next = prev.map((measurement) => { - if (!isPointAnnotationEntry(measurement) || measurement.id !== id) { - return measurement; - } - const normalizedMode = - mode === DEFAULT_POINT_LABEL_METRIC_MODE ? undefined : mode; - if (measurement.pointLabelMode === normalizedMode) { - return measurement; - } - hasChanged = true; - return { ...measurement, pointLabelMode: normalizedMode }; - }); - return hasChanged ? next : prev; - }); - }, - [setAnnotations] - ); - - const cyclePointLabelMetricModeByMeasurementId = useCallback( - (id: string) => { - setAnnotations((prev) => { - let hasChanged = false; - - const next = prev.map((measurement) => { - if (!isPointAnnotationEntry(measurement) || measurement.id !== id) { - return measurement; - } - - const currentMode = - measurement.pointLabelMode ?? DEFAULT_POINT_LABEL_METRIC_MODE; - const nextMode = getNextPointLabelMetricMode(currentMode); - const normalizedNextMode = - nextMode === DEFAULT_POINT_LABEL_METRIC_MODE ? undefined : nextMode; - - if (measurement.pointLabelMode === normalizedNextMode) { - return measurement; - } - - hasChanged = true; - return { ...measurement, pointLabelMode: normalizedNextMode }; - }); - - return hasChanged ? next : prev; - }); - }, - [setAnnotations] - ); - - const handlePointLabelDoubleClick = useCallback( - (id: string) => { - if (!pointMeasurementIds.has(id)) { - return; - } - - const clickedPoint = annotations.find( - (measurement) => - isPointAnnotationEntry(measurement) && measurement.id === id - ); - if (clickedPoint && isPointAnnotationEntry(clickedPoint)) { - setReferencePoint(clickedPoint.geometryECEF); - } - - // Double click finishes the current line chain. - setDoubleClickChainSourcePointId(null); - selectMeasurementById(id); - }, - [annotations, pointMeasurementIds, selectMeasurementById, setReferencePoint] - ); - - const { - updatePointMeasurementPositionById, - setPointAnnotationElevationById, - setPointAnnotationCoordinatesById, - setMoveGizmoPointElevationFromMeasurementById, - } = useAnnotationPointEditingController({ - annotations, - referencePoint, - moveGizmoPointId, - setAnnotations, - setReferencePoint, - referencePointSyncEpsilonMeters: REFERENCE_POINT_SYNC_EPSILON_METERS, - }); - - const handleMoveGizmoPointPositionChange = useCallback( - (pointId: string, nextPosition: Cartesian3) => { - const movedPointMeasurement = annotations.find( - (measurement) => - isPointAnnotationEntry(measurement) && measurement.id === pointId - ); - if ( - !movedPointMeasurement || - !isPointAnnotationEntry(movedPointMeasurement) - ) { - return; - } - if (lockedMeasurementIdSet.has(pointId)) { - return; - } - - const selectedPointIds = getSelectedPointIds( - selectedMeasurementIds, - pointMeasurementIds - ).filter((id) => !lockedMeasurementIdSet.has(id)); - const moveSelectionAsGroup = shouldMoveSelectionAsGroup( - pointId, - moveGizmoPointId, - selectedPointIds - ); - - const movedPointAnchor = movedPointMeasurement.verticalOffsetAnchorECEF - ? new Cartesian3( - movedPointMeasurement.verticalOffsetAnchorECEF.x, - movedPointMeasurement.verticalOffsetAnchorECEF.y, - movedPointMeasurement.verticalOffsetAnchorECEF.z - ) - : null; - const currentMoveOrigin = - movedPointAnchor ?? movedPointMeasurement.geometryECEF; - - const delta = computeMoveDelta(nextPosition, currentMoveOrigin); - const targetVerticalPolygonGroup = - planarPolygonGroups.find( - (group) => - group.closed && - (group.surfaceType ?? "roof") === "facade" && - group.vertexPointIds.includes(pointId) - ) ?? null; - const moveNorthAxisCandidate = - moveGizmoAxisCandidates?.find( - (candidate) => candidate.id === VERTICAL_POLYGON_AXIS_ID_ENU_NORTH - ) ?? - moveGizmoAxisCandidates?.find( - (candidate) => candidate.id === "horizontal-north" - ) ?? - null; - const moveEastAxisCandidate = - moveGizmoAxisCandidates?.find( - (candidate) => candidate.id === VERTICAL_POLYGON_AXIS_ID_ENU_EAST - ) ?? - moveGizmoAxisCandidates?.find( - (candidate) => candidate.id === "horizontal-east" - ) ?? - null; - const normalizedActiveAxisDirection = moveGizmoAxisDirection - ? normalizeDirection(moveGizmoAxisDirection) - : null; - const normalizedNorthAxisDirection = moveNorthAxisCandidate - ? normalizeDirection(moveNorthAxisCandidate.direction) - : null; - const normalizedEastAxisDirection = moveEastAxisCandidate - ? normalizeDirection(moveEastAxisCandidate.direction) - : null; - const isVerticalPolygonNorthAxisActive = Boolean( - targetVerticalPolygonGroup && - normalizedActiveAxisDirection && - normalizedNorthAxisDirection && - Math.abs( - Cartesian3.dot( - normalizedActiveAxisDirection, - normalizedNorthAxisDirection - ) - ) >= VERTICAL_POLYGON_AXIS_ALIGNMENT_DOT_EPSILON - ); - - const verticalPolygonCoupledPointIdSet = new Set(); - if ( - targetVerticalPolygonGroup && - isVerticalPolygonNorthAxisActive && - normalizedNorthAxisDirection && - normalizedEastAxisDirection - ) { - const pointById = getPointPositionMap(annotations); - targetVerticalPolygonGroup.vertexPointIds.forEach( - (candidatePointId) => { - if (!candidatePointId || candidatePointId === pointId) { - return; - } - if (lockedMeasurementIdSet.has(candidatePointId)) { - return; - } - const candidatePosition = pointById.get(candidatePointId); - if (!candidatePosition) { - return; - } - const candidateDelta = Cartesian3.subtract( - candidatePosition, - movedPointMeasurement.geometryECEF, - new Cartesian3() - ); - const deltaE = Cartesian3.dot( - candidateDelta, - normalizedEastAxisDirection - ); - const deltaN = Cartesian3.dot( - candidateDelta, - normalizedNorthAxisDirection - ); - if ( - Math.abs(deltaE) <= VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS && - Math.abs(deltaN) <= VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS - ) { - verticalPolygonCoupledPointIdSet.add(candidatePointId); - } - } - ); - } - - if (movedPointAnchor && moveGizmoVerticalOffsetEditMode) { - const deltaFromAnchor = Cartesian3.subtract( - nextPosition, - movedPointAnchor, - new Cartesian3() - ); - const nextOffsetMeters = Cartesian3.dot( - deltaFromAnchor, - getLocalUpDirectionAtAnchor(movedPointAnchor) - ); - - if (moveGizmoVerticalOffsetEditMode === ANNOTATION_TYPE_POLYLINE) { - const targetPlanarGroupId = - moveGizmoVerticalOffsetPlanarGroupId ?? - planarPolygonGroups.find( - (group) => !group.closed && group.vertexPointIds.includes(pointId) - )?.id ?? - null; - - if (targetPlanarGroupId) { - const targetGroup = planarPolygonGroups.find( - (group) => group.id === targetPlanarGroupId - ); - if (targetGroup) { - const targetVertexIdSet = new Set(targetGroup.vertexPointIds); - setPlanarPolygonGroups((prev) => - prev.map((group) => - group.id === targetPlanarGroupId - ? { - ...group, - verticalOffsetMeters: nextOffsetMeters, - } - : group - ) - ); - setAnnotations((prev) => - prev.map((measurement) => { - if ( - !isPointAnnotationEntry(measurement) || - !targetVertexIdSet.has(measurement.id) || - !measurement.verticalOffsetAnchorECEF - ) { - return measurement; - } - - const anchorECEF = new Cartesian3( - measurement.verticalOffsetAnchorECEF.x, - measurement.verticalOffsetAnchorECEF.y, - measurement.verticalOffsetAnchorECEF.z - ); - const nextGeometry = getPositionWithVerticalOffsetFromAnchor( - anchorECEF, - nextOffsetMeters - ); - const nextWGS84 = getDegreesFromCartesian(nextGeometry); - - return { - ...measurement, - geometryECEF: nextGeometry, - geometryWGS84: { - longitude: nextWGS84.longitude, - latitude: nextWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero( - nextWGS84.altitude - ), - }, - }; - }) - ); - return; - } - } - } - - const nextGeometry = getPositionWithVerticalOffsetFromAnchor( - movedPointAnchor, - nextOffsetMeters - ); - const nextWGS84 = getDegreesFromCartesian(nextGeometry); - setAnnotations((prev) => - prev.map((measurement) => { - if ( - !isPointAnnotationEntry(measurement) || - measurement.id !== pointId - ) { - return measurement; - } - - return { - ...measurement, - geometryECEF: nextGeometry, - geometryWGS84: { - longitude: nextWGS84.longitude, - latitude: nextWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(nextWGS84.altitude), - }, - }; - }) - ); - - if ( - referencePoint && - Cartesian3.distance( - movedPointMeasurement.geometryECEF, - referencePoint - ) <= REFERENCE_POINT_SYNC_EPSILON_METERS - ) { - setReferencePoint(nextGeometry); - } - return; - } - - if (!moveSelectionAsGroup) { - updatePointMeasurementPositionById(pointId, nextPosition, { - treatNextPositionAsOffsetAnchor: true, - }); - return; - } - - if (!delta) { - return; - } - - updatePointMeasurementPositionById(pointId, nextPosition, { - treatNextPositionAsOffsetAnchor: true, - }); - - const selectedPointIdSet = new Set( - selectedPointIds.filter((selectedId) => selectedId !== pointId) - ); - verticalPolygonCoupledPointIdSet.forEach((candidatePointId) => { - if (candidatePointId !== pointId) { - selectedPointIdSet.add(candidatePointId); - } - }); - if (selectedPointIdSet.size === 0) { - return; - } - setAnnotations((prev) => - applyDeltaToSelectedPoints(prev, selectedPointIdSet, delta) - ); - - if ( - hasReferencePointInSelection( - annotations, - selectedPointIdSet, - referencePoint, - REFERENCE_POINT_SYNC_EPSILON_METERS - ) && - referencePoint - ) { - const movedReferencePoint = Cartesian3.add( - referencePoint, - delta, - new Cartesian3() - ); - setReferencePoint(movedReferencePoint); - } - }, - [ - lockedMeasurementIdSet, - annotations, - moveGizmoAxisCandidates, - moveGizmoAxisTitle, - moveGizmoOptions.labelDistanceScale, - moveGizmoOptions.markerSizeScale, - moveGizmoPointId, - moveGizmoPreferredAxisId, - occlusionChecksEnabled, - setReferencePoint, - updatePointMeasurementPositionById, - ] - ); - - const startMoveGizmoForMeasurementId = useCallback( - (id: string, options?: MoveGizmoStartOptions) => { - const measurement = annotations.find( - (entry) => isPointAnnotationEntry(entry) && entry.id === id - ); - if (!measurement || !isPointAnnotationEntry(measurement)) return; - if (measurement.locked) { - setLockedEditMeasurementId(id); - return; - } - - const axisDirection = options?.axisDirection ?? null; - const axisCandidates = options?.axisCandidates?.map((candidate) => ({ - ...candidate, - direction: Cartesian3.clone(candidate.direction), - })); - setLockedEditMeasurementId(null); - setSelectedMeasurementId((prev) => (prev === id ? prev : id)); - setMoveGizmoPointId(id); - setMoveGizmoAxisDirection(axisDirection); - setMoveGizmoAxisTitle(options?.axisTitle ?? null); - setMoveGizmoAxisCandidates(axisCandidates ?? null); - setMoveGizmoPreferredAxisId(options?.preferredAxisId ?? null); - setMoveGizmoVerticalOffsetEditMode( - options?.verticalOffsetEditMode ?? null - ); - setMoveGizmoVerticalOffsetPlanarGroupId( - options?.verticalOffsetPlanarGroupId ?? null - ); - setIsMoveGizmoDragging(false); - }, - [annotations] - ); - - const stopMoveGizmo = useCallback(() => { - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setMoveGizmoPreferredAxisId(null); - setMoveGizmoVerticalOffsetEditMode(null); - setMoveGizmoVerticalOffsetPlanarGroupId(null); - setIsMoveGizmoDragging(false); - }, []); - - const handlePointVerticalOffsetStemLongPress = useCallback( - (pointId: string) => { - const pointMeasurement = annotations.find( - (measurement) => - isPointAnnotationEntry(measurement) && measurement.id === pointId - ); - if (!pointMeasurement || !isPointAnnotationEntry(pointMeasurement)) { - return; - } - - const anchorECEF = pointMeasurement.verticalOffsetAnchorECEF - ? new Cartesian3( - pointMeasurement.verticalOffsetAnchorECEF.x, - pointMeasurement.verticalOffsetAnchorECEF.y, - pointMeasurement.verticalOffsetAnchorECEF.z - ) - : pointMeasurement.geometryECEF; - const upDirection = getLocalUpDirectionAtAnchor(anchorECEF); - const targetPolylineGroup = - planarPolygonGroups.find( - (group) => !group.closed && group.vertexPointIds.includes(pointId) - ) ?? null; - - if (targetPolylineGroup) { - setSelectedPlanarPolygonGroupId(targetPolylineGroup.id); - } - - selectMeasurementById(pointId); - startMoveGizmoForMeasurementId(pointId, { - axisDirection: upDirection, - axisTitle: "Vertikalversatz", - verticalOffsetEditMode: targetPolylineGroup - ? ANNOTATION_TYPE_POLYLINE - : ANNOTATION_TYPE_POINT, - verticalOffsetPlanarGroupId: targetPolylineGroup?.id ?? null, - }); - }, - [ - annotations, - planarPolygonGroups, - selectMeasurementById, - startMoveGizmoForMeasurementId, - ] - ); - - const handlePointLabelLongPress = useCallback( - (id: string) => { - const targetVerticalPolygonGroup = - (selectedPlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => - group.id === selectedPlanarPolygonGroupId && - (group.surfaceType ?? "roof") === "facade" && - group.vertexPointIds.includes(id) - ) - : null) ?? - planarPolygonGroups.find( - (group) => - group.closed && - (group.surfaceType ?? "roof") === "facade" && - group.vertexPointIds.includes(id) - ) ?? - null; - - if (targetVerticalPolygonGroup) { - const pointById = getPointPositionMap(annotations); - const pointPosition = pointById.get(id); - if (pointPosition) { - const persistedVerticalPolygonFrame = resolveLocalFrameVectors( - targetVerticalPolygonGroup.planarPolygonLocalFrame - ); - if (persistedVerticalPolygonFrame) { - const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); - const enuEastAxis4 = Matrix4.getColumn( - enuMatrix, - 0, - new Cartesian4() - ); - const enuEastDirection = normalizeDirection( - new Cartesian3(enuEastAxis4.x, enuEastAxis4.y, enuEastAxis4.z) - ); - const eastRotationDegVsEnuEast = - enuEastDirection && - getSignedAngleDegAroundAxis( - enuEastDirection, - persistedVerticalPolygonFrame.east, - persistedVerticalPolygonFrame.north - ); - const axisRotationSuffix = getVerticalPolygonAxisRotationSuffix( - eastRotationDegVsEnuEast - ); - const upAxisTitle = `Punkt entlang der ENU-U-Achse${axisRotationSuffix} verschieben`; - const verticalPolygonAxisCandidates = [ - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_UP, - direction: persistedVerticalPolygonFrame.up, - color: "rgba(59, 130, 246, 0.98)", - title: upAxisTitle, - }, - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_EAST, - direction: persistedVerticalPolygonFrame.east, - color: "rgba(239, 68, 68, 0.98)", - title: `Punkt entlang der ENU-E-Achse${axisRotationSuffix} verschieben`, - }, - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_NORTH, - direction: persistedVerticalPolygonFrame.north, - color: "rgba(34, 197, 94, 0.98)", - title: - "Punkt entlang der ENU-N-Achse (Flächennormale) verschieben", - }, - ] as const; - - selectMeasurementById(id); - startMoveGizmoForMeasurementId(id, { - axisDirection: persistedVerticalPolygonFrame.up, - axisTitle: upAxisTitle, - preferredAxisId: VERTICAL_POLYGON_AXIS_ID_ENU_UP, - axisCandidates: verticalPolygonAxisCandidates.map( - (axisCandidate) => ({ - ...axisCandidate, - direction: Cartesian3.clone(axisCandidate.direction), - }) - ), - }); - return; - } - - const pointIndex = - targetVerticalPolygonGroup.vertexPointIds.findIndex( - (vertexId) => vertexId === id - ); - const oppositePointId = - pointIndex >= 0 && - targetVerticalPolygonGroup.vertexPointIds.length === 4 - ? targetVerticalPolygonGroup.vertexPointIds[ - (pointIndex + 2) % 4 - ] ?? null - : null; - const oppositePointPosition = oppositePointId - ? pointById.get(oppositePointId) ?? null - : null; - - const planeNormalFromGroup = targetVerticalPolygonGroup.plane - ? normalizeDirection( - cartesian3FromJson(targetVerticalPolygonGroup.plane.normalECEF) - ) - : null; - let planeNormal = planeNormalFromGroup; - if (!planeNormal) { - const vertices = targetVerticalPolygonGroup.vertexPointIds - .map((vertexId) => pointById.get(vertexId)) - .filter((vertex): vertex is Cartesian3 => Boolean(vertex)); - if (vertices.length >= 3) { - const derivedPlane = createPlaneFromThreePoints( - vertices[0], - vertices[1], - vertices[2] - ); - if (derivedPlane) { - const orientedDerivedPlane = - orientPlaneTowardSceneCamera(derivedPlane); - planeNormal = normalizeDirection( - cartesian3FromJson(orientedDerivedPlane.normalECEF) - ); - } - } - } - - if (planeNormal) { - const upDirection = getLocalUpDirectionAtAnchor(pointPosition); - const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); - const eastAxis4 = Matrix4.getColumn(enuMatrix, 0, new Cartesian4()); - const fallbackEastDirection = normalizeDirection( - new Cartesian3(eastAxis4.x, eastAxis4.y, eastAxis4.z) - ); - - let eastDirection: Cartesian3 | null = null; - if (oppositePointPosition) { - const oppositeDelta = Cartesian3.subtract( - oppositePointPosition, - pointPosition, - new Cartesian3() - ); - const horizontalHint = Cartesian3.subtract( - oppositeDelta, - Cartesian3.multiplyByScalar( - upDirection, - Cartesian3.dot(oppositeDelta, upDirection), - new Cartesian3() - ), - new Cartesian3() - ); - const hintOnPlane = Cartesian3.subtract( - horizontalHint, - Cartesian3.multiplyByScalar( - planeNormal, - Cartesian3.dot(horizontalHint, planeNormal), - new Cartesian3() - ), - new Cartesian3() - ); - eastDirection = normalizeDirection(hintOnPlane); - } - - if (!eastDirection) { - const inPlaneHorizontal = Cartesian3.cross( - planeNormal, - upDirection, - new Cartesian3() - ); - eastDirection = normalizeDirection(inPlaneHorizontal); - } - - if (!eastDirection && fallbackEastDirection) { - const fallbackOnPlane = Cartesian3.subtract( - fallbackEastDirection, - Cartesian3.multiplyByScalar( - planeNormal, - Cartesian3.dot(fallbackEastDirection, planeNormal), - new Cartesian3() - ), - new Cartesian3() - ); - eastDirection = normalizeDirection(fallbackOnPlane); - } - - if (eastDirection) { - let northDirection = normalizeDirection( - Cartesian3.cross(upDirection, eastDirection, new Cartesian3()) - ); - if (!northDirection) { - northDirection = planeNormal; - } - - if (Cartesian3.dot(northDirection, planeNormal) < 0) { - northDirection = Cartesian3.multiplyByScalar( - northDirection, - -1, - new Cartesian3() - ); - eastDirection = Cartesian3.multiplyByScalar( - eastDirection, - -1, - new Cartesian3() - ); - } - - const eastRotationDegVsEnuEast = - fallbackEastDirection && - getSignedAngleDegAroundAxis( - fallbackEastDirection, - eastDirection, - northDirection - ); - const axisRotationSuffix = getVerticalPolygonAxisRotationSuffix( - eastRotationDegVsEnuEast - ); - const upAxisTitle = `Punkt entlang der ENU-U-Achse${axisRotationSuffix} verschieben`; - - const verticalPolygonAxisCandidates = [ - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_UP, - direction: upDirection, - color: "rgba(59, 130, 246, 0.98)", - title: upAxisTitle, - }, - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_EAST, - direction: eastDirection, - color: "rgba(239, 68, 68, 0.98)", - title: `Punkt entlang der ENU-E-Achse${axisRotationSuffix} verschieben`, - }, - { - id: VERTICAL_POLYGON_AXIS_ID_ENU_NORTH, - direction: northDirection, - color: "rgba(34, 197, 94, 0.98)", - title: - "Punkt entlang der ENU-N-Achse (Flächennormale) verschieben", - }, - ] as const; - - selectMeasurementById(id); - startMoveGizmoForMeasurementId(id, { - axisDirection: upDirection, - axisTitle: upAxisTitle, - preferredAxisId: VERTICAL_POLYGON_AXIS_ID_ENU_UP, - axisCandidates: verticalPolygonAxisCandidates.map( - (axisCandidate) => ({ - ...axisCandidate, - direction: Cartesian3.clone(axisCandidate.direction), - }) - ), - }); - return; - } - } - } - } - - const targetRoofPolygonGroup = - (selectedPlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => - group.id === selectedPlanarPolygonGroupId && - (group.surfaceType ?? "roof") === "roof" && - group.planeLocked && - group.vertexPointIds.includes(id) - ) - : null) ?? - planarPolygonGroups.find( - (group) => - (group.surfaceType ?? "roof") === "roof" && - group.planeLocked && - group.vertexPointIds.includes(id) - ) ?? - null; - - if (targetRoofPolygonGroup) { - const pointById = getPointPositionMap(annotations); - const pointPosition = pointById.get(id); - if (pointPosition) { - const planeNormalFromGroup = targetRoofPolygonGroup.plane - ? normalizeDirection( - cartesian3FromJson(targetRoofPolygonGroup.plane.normalECEF) - ) - : null; - let planeNormal = planeNormalFromGroup; - if (!planeNormal) { - const vertices = targetRoofPolygonGroup.vertexPointIds - .map((vertexId) => pointById.get(vertexId)) - .filter((vertex): vertex is Cartesian3 => Boolean(vertex)); - if (vertices.length >= 3) { - const derivedPlane = createPlaneFromThreePoints( - vertices[0], - vertices[1], - vertices[2] - ); - if (derivedPlane) { - const orientedDerivedPlane = - orientPlaneTowardSceneCamera(derivedPlane); - planeNormal = normalizeDirection( - cartesian3FromJson(orientedDerivedPlane.normalECEF) - ); - } - } - } - - if (planeNormal) { - const upDirection = getLocalUpDirectionAtAnchor(pointPosition); - const orientedPlaneNormal = - Cartesian3.dot(planeNormal, upDirection) < 0 - ? Cartesian3.multiplyByScalar(planeNormal, -1, new Cartesian3()) - : Cartesian3.clone(planeNormal); - const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); - const enuEastAxis4 = Matrix4.getColumn( - enuMatrix, - 0, - new Cartesian4() - ); - const enuNorthAxis4 = Matrix4.getColumn( - enuMatrix, - 1, - new Cartesian4() - ); - const enuEastDirection = normalizeDirection( - new Cartesian3(enuEastAxis4.x, enuEastAxis4.y, enuEastAxis4.z) - ); - const enuNorthDirection = normalizeDirection( - new Cartesian3(enuNorthAxis4.x, enuNorthAxis4.y, enuNorthAxis4.z) - ); - - const projectDirectionOntoRoofPlane = ( - direction: Cartesian3 | null - ) => { - if (!direction) return null; - return normalizeDirection( - Cartesian3.subtract( - direction, - Cartesian3.multiplyByScalar( - orientedPlaneNormal, - Cartesian3.dot(direction, orientedPlaneNormal), - new Cartesian3() - ), - new Cartesian3() - ) - ); - }; - - const inPlanePrimaryDirection = - projectDirectionOntoRoofPlane(enuNorthDirection) ?? - projectDirectionOntoRoofPlane(upDirection); - - const inPlaneSecondaryDirection = inPlanePrimaryDirection - ? normalizeDirection( - Cartesian3.cross( - inPlanePrimaryDirection, - orientedPlaneNormal, - new Cartesian3() - ) - ) ?? projectDirectionOntoRoofPlane(enuEastDirection) - : null; - - if (inPlanePrimaryDirection && inPlaneSecondaryDirection) { - const roofNormalAxisTitle = - "Punkt entlang der Dachflächennormale verschieben"; - const roofAxisCandidates = [ - { - id: ROOF_POLYGON_AXIS_ID_NORMAL, - direction: orientedPlaneNormal, - color: "rgba(59, 130, 246, 0.98)", - title: roofNormalAxisTitle, - }, - { - id: ROOF_POLYGON_AXIS_ID_IN_PLANE_PRIMARY, - direction: inPlanePrimaryDirection, - color: "rgba(34, 197, 94, 0.98)", - title: - "Punkt entlang der ENU-N-Projektion in der Dachebene verschieben", - }, - { - id: ROOF_POLYGON_AXIS_ID_IN_PLANE_SECONDARY, - direction: inPlaneSecondaryDirection, - color: "rgba(239, 68, 68, 0.98)", - title: - "Punkt orthogonal zur ENU-N-Projektion in der Dachebene verschieben", - }, - ] as const; - - selectMeasurementById(id); - startMoveGizmoForMeasurementId(id, { - axisDirection: orientedPlaneNormal, - axisTitle: roofNormalAxisTitle, - preferredAxisId: ROOF_POLYGON_AXIS_ID_NORMAL, - axisCandidates: roofAxisCandidates.map((axisCandidate) => ({ - ...axisCandidate, - direction: Cartesian3.clone(axisCandidate.direction), - })), - }); - return; - } - } - } - } - - selectMeasurementById(id); - startMoveGizmoForMeasurementId(id); - }, - [ - annotations, - planarPolygonGroups, - selectedPlanarPolygonGroupId, - selectMeasurementById, - startMoveGizmoForMeasurementId, - orientPlaneTowardSceneCamera, - ] - ); - - const handleMoveGizmoExit = useCallback(() => { - stopMoveGizmo(); - }, [stopMoveGizmo]); - - const handleMoveGizmoAxisChange = useCallback( - (axisDirection: Cartesian3, axisTitle?: string | null) => { - setMoveGizmoAxisDirection(Cartesian3.clone(axisDirection)); - setMoveGizmoAxisTitle(axisTitle ?? null); - }, - [] - ); - - const handlePointLabelClick = useCallback( - (id: string) => { - if (moveGizmoPointId) { - setMoveGizmoPointElevationFromMeasurementById(id); - return; - } - - if (selectionModeActive) { - selectMeasurementIds([id], effectiveSelectModeAdditive); - return; - } - - const clickedMeasurement = annotations.find( - (measurement) => measurement.id === id - ); - const isAuxiliaryLabelAnchor = Boolean( - clickedMeasurement?.auxiliaryLabelAnchor - ); - - if (annotationMode === ANNOTATION_TYPE_POINT) { - selectMeasurementById(id); - return; - } - - if (annotationMode === ANNOTATION_TYPE_DISTANCE) { - if (!pointMeasurementIds.has(id)) return; - - if (isAuxiliaryLabelAnchor) { - selectMeasurementById(id); - return; - } - - if (!isActiveDrawMode) { - const sourcePointId = resolveDistanceRelationSourcePointId(id); - if (sourcePointId) { - upsertDirectDistanceRelation(sourcePointId, id); - setDoubleClickChainSourcePointId( - distanceModeStickyToFirstPoint ? sourcePointId : null - ); - selectMeasurementById(id); - return; - } - setDoubleClickChainSourcePointId(id); - selectMeasurementById(id); - return; - } - - const activeOpenGroup = - activePlanarPolygonGroupId !== null - ? planarPolygonGroups.find( - (group) => - group.id === activePlanarPolygonGroupId && !group.closed - ) ?? null - : null; - const firstVertexId = activeOpenGroup?.vertexPointIds[0] ?? null; - const shouldCloseRingOnFirstVertexClick = Boolean( - firstVertexId && - firstVertexId === id && - activeOpenGroup && - activeOpenGroup.vertexPointIds.length >= 3 - ); - if (shouldCloseRingOnFirstVertexClick) { - closeActivePlanarPolygonGroup(); - return; - } - - const sourcePointId = resolveDistanceRelationSourcePointId(id); - const didAutoCloseFacadeRectangle = - appendExistingPointToActivePlanarPolygonGroup(id, sourcePointId); - if (didAutoCloseFacadeRectangle) { - return; - } - - if (sourcePointId) { - upsertDirectDistanceRelation(sourcePointId, id); - } - - setDoubleClickChainSourcePointId(id); - selectMeasurementById(id); - return; - } - - if (annotationMode === ANNOTATION_TYPE_POLYLINE) { - if (!pointMeasurementIds.has(id)) return; - - if (isAuxiliaryLabelAnchor) { - selectMeasurementById(id); - return; - } - - if (!isActiveDrawMode) { - appendExistingPointToActivePlanarPolygonGroup(id, null); - setDoubleClickChainSourcePointId(id); - selectMeasurementById(id); - return; - } - - const activeOpenGroup = - activePlanarPolygonGroupId !== null - ? planarPolygonGroups.find( - (group) => - group.id === activePlanarPolygonGroupId && !group.closed - ) ?? null - : null; - const firstVertexId = activeOpenGroup?.vertexPointIds[0] ?? null; - const shouldHandleRingClosure = Boolean( - firstVertexId && - firstVertexId === id && - activeOpenGroup && - activeOpenGroup.vertexPointIds.length >= 3 - ); - if (shouldHandleRingClosure && firstVertexId) { - if (planarToolCreationMode === PLANAR_TOOL_CREATION_MODE_POLYGON) { - closeActivePlanarPolygonGroup(); - } else { - finishActivePlanarPolylineGroup(); - } - return; - } - - const sourcePointId = resolveDistanceRelationSourcePointId(id); - const didAutoCloseFacadeRectangle = - appendExistingPointToActivePlanarPolygonGroup(id, sourcePointId); - if (didAutoCloseFacadeRectangle) { - return; - } - - if (sourcePointId) { - upsertDirectDistanceRelation(sourcePointId, id); - } - - setDoubleClickChainSourcePointId(id); - selectMeasurementById(id); - return; - } - - runPointLabelClickInteraction({ - pointId: id, - selectedMeasurementId, - selectMeasurementById, - cyclePointLabelMetricModeByMeasurementId, - }); - }, - [ - moveGizmoPointId, - selectMeasurementIds, - effectiveSelectModeAdditive, - annotations, - annotationMode, - selectionModeActive, - activePlanarPolygonGroupId, - isActiveDrawMode, - planarPolygonGroups, - pointMeasurementIds, - resolveDistanceRelationSourcePointId, - closeActivePlanarPolygonGroup, - finishActivePlanarPolylineGroup, - setMoveGizmoPointElevationFromMeasurementById, - selectedMeasurementId, - selectMeasurementIds, - effectiveSelectModeAdditive, - selectMeasurementById, - cyclePointLabelMetricModeByMeasurementId, - upsertDirectDistanceRelation, - appendExistingPointToActivePlanarPolygonGroup, - distanceModeStickyToFirstPoint, - planarToolCreationMode, - ] - ); - - useEffect(() => { - if (annotationMode === ANNOTATION_TYPE_POLYLINE) return; - setPendingPolylinePromotionRingClosurePointId(null); - }, [annotationMode]); - - // point query hooks - const { - isPointMeasureLabelModeActive, - isPointMeasureLabelInputPending, - isPointMeasureCreateModeActive, - pointQueryToolActive, - activePointCreateConfig, - } = usePointCreateConfigState({ - annotationMode, - pointLabelOnCreate, - labelInputPromptPointId, - setLabelInputPromptPointId, - annotations, - temporaryMode, - pointVerticalOffsetMeters, - lastCustomLabelOnCreate, - previewIsPolylineCreateMode, - polylineVerticalOffsetMeters, - }); - - const handlePointQueryPointerMoveWithHoveredNodeAnchor = useCallback( - ( - positionECEF: Cartesian3 | null, - screenPosition?: Cartesian2, - surfaceNormalECEF?: Cartesian3 | null - ) => { - const hoveredPointId = hoveredLivePreviewPointIdRef.current; - if (hoveredPointId) { - const hoveredPoint = getPointById(annotations, hoveredPointId); - if (hoveredPoint && isPointAnnotationEntry(hoveredPoint)) { - const localUp = getLocalUpDirectionAtAnchor( - hoveredPoint.geometryECEF - ); - handlePointQueryPointerMove( - hoveredPoint.geometryECEF, - screenPosition, - localUp - ); - return; - } - hoveredLivePreviewPointIdRef.current = null; - } - - handlePointQueryPointerMove( - positionECEF, - screenPosition, - surfaceNormalECEF - ); - }, - [annotations, handlePointQueryPointerMove] - ); - - usePointQueryCreationController({ - scene, - annotationMode, - pointQueryToolActive, - pointQueryEnabled, - selectionModeActive, - moveGizmoPointId, - isMoveGizmoDragging, - activePointCreateConfig, - setAnnotations, - handlePointQueryPointCreated, - handlePointQueryDoubleClick, - handlePointQueryBeforePointCreate, - handlePointQueryPointerMoveWithHoveredNodeAnchor, - }); - - const handleDistanceRelationCornerClick = useCallback( - (relationId: string) => { - if (!relationId) return; - setDistanceRelations((prev) => - prev.map((relation) => { - if (relation.id !== relationId) return relation; - const nextAnchorPointId = - relation.anchorPointId === relation.pointAId - ? relation.pointBId - : relation.pointAId; - return { - ...relation, - anchorPointId: nextAnchorPointId, - }; - }) - ); - }, - [] - ); - - const handleDistanceRelationMidpointClick = useCallback( - (relationId: string) => { - if (!relationId) return; - const targetGroup = planarPolygonGroups.find((group) => - group.edgeRelationIds.includes(relationId) - ); - if (!targetGroup) return; - - const vertexIds = targetGroup.vertexPointIds; - if (vertexIds.length < 2) return; - - let edgeStartId: string | null = null; - let edgeEndId: string | null = null; - let insertIndex = -1; - - for (let index = 0; index < vertexIds.length - 1; index += 1) { - const startId = vertexIds[index]; - const endId = vertexIds[index + 1]; - if (!startId || !endId) continue; - const edgeId = getDistanceRelationId(startId, endId); - if (edgeId === relationId) { - edgeStartId = startId; - edgeEndId = endId; - insertIndex = index + 1; - break; - } - } - - if (!edgeStartId || !edgeEndId) { - if (targetGroup.closed && vertexIds.length >= 3) { - const startId = vertexIds[vertexIds.length - 1]; - const endId = vertexIds[0]; - if (startId && endId) { - const edgeId = getDistanceRelationId(startId, endId); - if (edgeId === relationId) { - edgeStartId = startId; - edgeEndId = endId; - insertIndex = vertexIds.length; - } - } - } - } - - if (!edgeStartId || !edgeEndId || insertIndex < 0) return; - - const pointById = getPointPositionMap(annotations); - const startPoint = pointById.get(edgeStartId); - const endPoint = pointById.get(edgeEndId); - if (!startPoint || !endPoint) return; - - let midpointPosition = Cartesian3.midpoint( - startPoint, - endPoint, - new Cartesian3() - ); - const targetGroupVerticalPolygonFrame = - (targetGroup.surfaceType ?? "roof") === "facade" - ? resolveLocalFrameVectors(targetGroup.planarPolygonLocalFrame) - : null; - if (targetGroupVerticalPolygonFrame) { - const startLocal = getPositionInLocalFrame( - startPoint, - targetGroupVerticalPolygonFrame - ); - const endLocal = getPositionInLocalFrame( - endPoint, - targetGroupVerticalPolygonFrame - ); - midpointPosition = getPositionFromLocalFrame( - targetGroupVerticalPolygonFrame, - (startLocal.eastMeters + endLocal.eastMeters) / 2, - (startLocal.northMeters + endLocal.northMeters) / 2, - (startLocal.upMeters + endLocal.upMeters) / 2 - ); - } - if ( - (targetGroup.surfaceType ?? "roof") !== "footprint" && - targetGroup.planeLocked && - targetGroup.plane - ) { - midpointPosition = projectPointOntoPlane( - midpointPosition, - targetGroup.plane - ); - } - - const nextPointId = `point-${Date.now()}-split`; - const midpointWGS84 = getDegreesFromCartesian(midpointPosition); - setAnnotations((prev) => { - const insertionBaseIndex = - prev.find( - (measurement) => - isPointAnnotationEntry(measurement) && - measurement.id === edgeStartId - )?.index ?? prev.filter(isPointAnnotationEntry).length; - const insertionIndex = insertionBaseIndex + 1; - - const nextMeasurements = prev.map((measurement) => { - if ( - isPointAnnotationEntry(measurement) && - measurement.index >= insertionIndex - ) { - return { - ...measurement, - index: measurement.index + 1, - }; - } - return measurement; - }); - - return [ - ...nextMeasurements, - { - type: ANNOTATION_TYPE_DISTANCE, - id: nextPointId, - index: insertionIndex, - geometryECEF: midpointPosition, - geometryWGS84: { - longitude: midpointWGS84.longitude, - latitude: midpointWGS84.latitude, - altitude: getEllipsoidalAltitudeOrZero(midpointWGS84.altitude), - }, - timestamp: new Date().getTime(), - }, - ]; - }); - - const updatedPointById = getPointPositionMap(annotations, { - [nextPointId]: midpointPosition, - }); - setPlanarPolygonGroups((prev) => - prev.map((group) => { - if (group.id !== targetGroup.id) return group; - const nextVertexPointIds = [ - ...group.vertexPointIds.slice(0, insertIndex), - nextPointId, - ...group.vertexPointIds.slice(insertIndex), - ]; - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - group.closed, - getDistanceRelationId - ); - return computePolygonGroupDerivedDataWithCamera( - { - ...group, - vertexPointIds: nextVertexPointIds, - edgeRelationIds: nextEdgeRelationIds, - }, - updatedPointById - ); - }) - ); - - setActivePlanarPolygonGroupId(targetGroup.id); - setDoubleClickChainSourcePointId(nextPointId); - selectMeasurementById(nextPointId); - }, - [ - annotations, - planarPolygonGroups, - selectMeasurementById, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - useEffect(() => { - setDistanceRelations((prev) => - syncPolygonEdgeDistanceRelations(prev, planarPolygonGroups) - ); - }, [planarPolygonGroups, syncPolygonEdgeDistanceRelations]); - - const hiddenMeasurementIdSet = useMemo( - () => - new Set( - annotations - .filter( - (measurement) => - measurement.hidden && !measurement.auxiliaryLabelAnchor - ) - .map((measurement) => measurement.id) - ), - [annotations] - ); - - const auxiliaryLabelAnchorIdSet = useMemo( - () => - new Set( - annotations - .filter((measurement) => measurement.auxiliaryLabelAnchor) - .map((measurement) => measurement.id) - ), - [annotations] - ); - - const closedFacadeRectangleVertexIdSet = useMemo(() => { - const ids = new Set(); - planarPolygonGroups.forEach((group) => { - if (!group.closed) return; - if ((group.surfaceType ?? "roof") !== "facade") return; - if (group.vertexPointIds.length !== 4) return; - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - ids.add(pointId); - } - }); - }); - return ids; - }, [planarPolygonGroups]); - - const markerlessPointIds = useMemo(() => { - const ids = new Set(auxiliaryLabelAnchorIdSet); - closedFacadeRectangleVertexIdSet.forEach((pointId) => { - ids.delete(pointId); - }); - return ids; - }, [auxiliaryLabelAnchorIdSet, closedFacadeRectangleVertexIdSet]); - - const hiddenPlanarPolygonGroupIdSet = useMemo( - () => - new Set( - planarPolygonGroups - .filter((group) => group.hidden) - .map((group) => group.id) - ), - [planarPolygonGroups] - ); - - const visibleMeasurementsForRendering = useMemo( - () => - annotations.filter( - (measurement) => !measurement.hidden || measurement.auxiliaryLabelAnchor - ), - [annotations] - ); - - const visiblePlanarPolygonGroupsForRendering = useMemo( - () => planarPolygonGroups.filter((group) => !group.hidden), - [planarPolygonGroups] - ); - - const visibleDistanceRelationsForRendering = useMemo( - () => - distanceRelations.filter((relation) => { - if ( - relation.polygonGroupId && - hiddenPlanarPolygonGroupIdSet.has(relation.polygonGroupId) - ) { - return false; - } - return ( - !hiddenMeasurementIdSet.has(relation.pointAId) && - !hiddenMeasurementIdSet.has(relation.pointBId) - ); - }), - [distanceRelations, hiddenMeasurementIdSet, hiddenPlanarPolygonGroupIdSet] - ); - - const selectedStandaloneDistanceRelationIdSet = useMemo(() => { - const selectedPointIdSet = new Set(selectedMeasurementIds); - if (selectedMeasurementId) { - selectedPointIdSet.add(selectedMeasurementId); - } - if (selectedPointIdSet.size === 0) { - return new Set(); - } - - const standaloneRelations = visibleDistanceRelationsForRendering.filter( - (relation) => !relation.polygonGroupId - ); - if (standaloneRelations.length === 0) { - return new Set(); - } - - const neighborPointIdsByPointId = new Map>(); - const relationIdsByPointId = new Map>(); - - standaloneRelations.forEach((relation) => { - const { pointAId, pointBId, id } = relation; - if (!neighborPointIdsByPointId.has(pointAId)) { - neighborPointIdsByPointId.set(pointAId, new Set()); - } - if (!neighborPointIdsByPointId.has(pointBId)) { - neighborPointIdsByPointId.set(pointBId, new Set()); - } - neighborPointIdsByPointId.get(pointAId)?.add(pointBId); - neighborPointIdsByPointId.get(pointBId)?.add(pointAId); - - if (!relationIdsByPointId.has(pointAId)) { - relationIdsByPointId.set(pointAId, new Set()); - } - if (!relationIdsByPointId.has(pointBId)) { - relationIdsByPointId.set(pointBId, new Set()); - } - relationIdsByPointId.get(pointAId)?.add(id); - relationIdsByPointId.get(pointBId)?.add(id); - }); - - const queue: string[] = []; - selectedPointIdSet.forEach((pointId) => { - if (neighborPointIdsByPointId.has(pointId)) { - queue.push(pointId); - } - }); - if (queue.length === 0) { - return new Set(); - } - - const visitedPointIds = new Set(); - const selectedRelationIds = new Set(); - - while (queue.length > 0) { - const pointId = queue.shift(); - if (!pointId || visitedPointIds.has(pointId)) continue; - visitedPointIds.add(pointId); - - relationIdsByPointId.get(pointId)?.forEach((relationId) => { - selectedRelationIds.add(relationId); - }); - neighborPointIdsByPointId.get(pointId)?.forEach((neighborPointId) => { - if (!visitedPointIds.has(neighborPointId)) { - queue.push(neighborPointId); - } - }); - } - - return selectedRelationIds; - }, [ - selectedMeasurementId, - selectedMeasurementIds, - visibleDistanceRelationsForRendering, - ]); - - const effectiveDistanceRelationsForRendering = useMemo< - PointDistanceRelation[] - >(() => { - const planarPolygonGroupById = new Map( - planarPolygonGroups.map((group) => [group.id, group] as const) - ); - - return visibleDistanceRelationsForRendering.map((relation) => { - const owningGroup = relation.polygonGroupId - ? planarPolygonGroupById.get(relation.polygonGroupId) ?? null - : null; - const isStandaloneDistanceRelation = !relation.polygonGroupId; - const isSelectedStandaloneDistanceRelation = - isStandaloneDistanceRelation && - selectedStandaloneDistanceRelationIdSet.has(relation.id); - const isDistanceMeasureRelation = !owningGroup || !owningGroup.closed; - if (!isDistanceMeasureRelation) { - return relation; - } - - if (isSelectedStandaloneDistanceRelation) { - return { - ...relation, - directLabelMode: "segment", - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - direct: true, - vertical: true, - horizontal: true, - }, - }; - } - - return { - ...relation, - directLabelMode: "none", - labelVisibilityByKind: { - ...DEFAULT_DISTANCE_RELATION_LABEL_VISIBILITY, - ...(relation.labelVisibilityByKind ?? {}), - direct: false, - vertical: false, - horizontal: false, - }, - }; - }); - }, [ - visibleDistanceRelationsForRendering, - planarPolygonGroups, - selectedStandaloneDistanceRelationIdSet, - ]); - - const hiddenPointLabelIds = useMemo(() => { - const ids = new Set([ - ...polygonOnlyPointIdSet, - ...hiddenMeasurementIdSet, - ...pointIdsWithoutLabelAnchor, - ...unselectedClosedAreaVertexPointIdSet, - ...unfocusedStandaloneDistanceNonHighestPointIds, - ...focusedStandaloneDistanceNonHighestPointIds, - ]); - openFacadeSingleVertexPointIdSet.forEach((pointId) => { - ids.add(pointId); - }); - annotations.forEach((measurement) => { - if ( - isPointAnnotationEntry(measurement) && - measurement.isFacadeAutoCorner && - !unselectedClosedAreaVertexPointIdSet.has(measurement.id) - ) { - ids.delete(measurement.id); - } - }); - labelAnchorPointIdsWithForcedVisibility.forEach((pointId) => { - ids.delete(pointId); - }); - return ids; - }, [ - annotations, - polygonOnlyPointIdSet, - hiddenMeasurementIdSet, - pointIdsWithoutLabelAnchor, - unselectedClosedAreaVertexPointIdSet, - unfocusedStandaloneDistanceNonHighestPointIds, - focusedStandaloneDistanceNonHighestPointIds, - openFacadeSingleVertexPointIdSet, - labelAnchorPointIdsWithForcedVisibility, - ]); - - const fullyHiddenPointIds = useMemo(() => { - const ids = new Set([ - ...unfocusedPolylineNonLastIds, - ...unfocusedStandaloneDistanceNonHighestPointIds, - ...hiddenMeasurementIdSet, - ]); - annotations.forEach((measurement) => { - if ( - isPointAnnotationEntry(measurement) && - measurement.isFacadeAutoCorner - ) { - ids.delete(measurement.id); - } - }); - closedFacadeRectangleVertexIdSet.forEach((pointId) => { - ids.delete(pointId); - }); - return ids; - }, [ - annotations, - unfocusedPolylineNonLastIds, - unfocusedStandaloneDistanceNonHighestPointIds, - hiddenMeasurementIdSet, - closedFacadeRectangleVertexIdSet, - ]); - const effectiveFullyHiddenPointIds = useMemo(() => { - if (!isLivePointPreviewModeActive) { - return fullyHiddenPointIds; - } - - return new Set(hiddenMeasurementIdSet); - }, [ - fullyHiddenPointIds, - hiddenMeasurementIdSet, - isLivePointPreviewModeActive, - ]); - const markerOnlyOverlayNodeInteractions = - (isLivePointPreviewModeActive && !isPointMeasureLabelModeActive) || - isPointMeasureLabelInputPending; - const suppressLivePreviewLabelOverlay = isPointMeasureLabelModeActive; - - useAnnotationVisualizerAdapter({ - scene, - annotations: visibleMeasurementsForRendering, - showPoints, - showPointLabels, - pointRadius, - referenceElevation: effectiveReferenceElevation, - selectedMeasurementId, - selectedMeasurementIds, - hiddenPointLabelIds, - fullyHiddenPointIds: effectiveFullyHiddenPointIds, - markerlessPointIds, - collapsedPillPointIds, - moveGizmoPointId, - selectionModeActive, - selectModeRectangle, - effectiveSelectModeAdditive, - selectMeasurementIds, - handlePointLabelClick, - handlePointLabelDoubleClick, - handlePointLabelLongPress, - handlePointLabelHoverChange, - handlePointVerticalOffsetStemLongPress, - pointLongPressDurationMs: POINT_LABEL_LONG_PRESS_DURATION_MS, - occlusionChecksEnabled, - labelLayoutConfig: options?.labels, - effectiveDistanceToReferenceByPointId, - pointMarkerBadgeByPointId, - labelInputPromptPointId, - markerOnlyOverlayNodeInteractions, - suppressLivePreviewLabelOverlay, - livePreviewPointECEF: isLivePointPreviewModeActive - ? livePreviewPointECEF - : null, - livePreviewSurfaceNormalECEF: isLivePointPreviewModeActive - ? livePreviewSurfaceNormalECEF - : null, - livePreviewVerticalOffsetAnchorECEF: - isLivePointPreviewModeActive && annotationMode === ANNOTATION_TYPE_POINT - ? livePreviewVerticalOffsetAnchorECEF - : null, - livePreviewDistanceLine, - showDistanceAndPolygonVisuals, - distanceRelations: effectiveDistanceRelationsForRendering, - planarPolygonGroups: visiblePlanarPolygonGroupsForRendering, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - cumulativeDistanceByRelationId, - handleDistanceRelationLineLabelToggle, - handleDistanceRelationLineClick, - handleDistanceRelationMidpointClick, - handleDistanceRelationCornerClick, - referencePoint, - moveGizmoAxisDirection, - moveGizmoPreferredAxisId, - moveGizmoMarkerSizeScale: moveGizmoOptions.markerSizeScale ?? 1, - moveGizmoLabelDistanceScale: moveGizmoOptions.labelDistanceScale ?? 1, - moveGizmoSnapPlaneDragToGround: - moveGizmoOptions.snapPlaneDragToGround ?? false, - moveGizmoShowRotationHandle: moveGizmoOptions.showRotationHandle ?? true, - isMoveGizmoDragging, - handleMoveGizmoPointPositionChange, - setIsMoveGizmoDragging, - handleMoveGizmoAxisChange, - handleMoveGizmoExit, - }); - - const clearAllMeasurements = useCallback(() => { - setAnnotations([]); - setDistanceRelations([]); - setPlanarPolygonGroups([]); - setSelectedPlanarPolygonGroupId(null); - setActivePlanarPolygonGroupId(null); - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setDoubleClickChainSourcePointId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - // resetVisibility - if (hideMeasurementsOfType.size > 0) { - setHideMeasurementsOfType(new Set()); - } - }, [hideMeasurementsOfType.size]); - - const clearMeasurementsByType = useCallback((type: AnnotationMode) => { - setAnnotations((prev) => - prev.filter((measurement) => measurement.type !== type) - ); - if (type === ANNOTATION_TYPE_DISTANCE) { - setDistanceRelations([]); - setPlanarPolygonGroups([]); - setSelectedPlanarPolygonGroupId(null); - setActivePlanarPolygonGroupId(null); - setDoubleClickChainSourcePointId(null); - } - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - // resetVisibility - setHideMeasurementsOfType(deleteFromHideMeasurementsOfType(type)); - }, []); - - const clearAnnotationsByIds = useCallback( - (ids: string[]) => { - const pointById = new Map( - annotations - .filter(isPointAnnotationEntry) - .map((measurement) => [measurement.id, measurement] as const) - ); - - const requestedIdSet = new Set(ids); - const protectedPolygonVertexPointIdSet = new Set(); - planarPolygonGroups.forEach((group) => { - if (!group.closed || group.vertexPointIds.length > 3) { - return; - } - const vertexPointIds = group.vertexPointIds.filter( - (vertexId): vertexId is string => Boolean(vertexId) - ); - if (vertexPointIds.length === 0) { - return; - } - const includesAnyVertex = vertexPointIds.some((vertexId) => - requestedIdSet.has(vertexId) - ); - if (!includesAnyVertex) { - return; - } - const includesAllVertices = vertexPointIds.every((vertexId) => - requestedIdSet.has(vertexId) - ); - if (includesAllVertices) { - return; - } - vertexPointIds.forEach((vertexId) => { - protectedPolygonVertexPointIdSet.add(vertexId); - }); - }); - - const idsToDelete = new Set( - ids.filter((id) => !protectedPolygonVertexPointIdSet.has(id)) - ); - if (idsToDelete.size === 0) { - return; - } - let remainingRelations = [...distanceRelations]; - - // Expand deletion set: if deleting a point removes a relation, and the - // opposite endpoint was created exclusively for that removed relation, - // remove that endpoint too (unless still referenced by another relation). - let expanded = true; - while (expanded) { - expanded = false; - - const nextRemainingRelations: PointDistanceRelation[] = []; - const removedRelations: PointDistanceRelation[] = []; - remainingRelations.forEach((relation) => { - if ( - idsToDelete.has(relation.pointAId) || - idsToDelete.has(relation.pointBId) - ) { - removedRelations.push(relation); - return; - } - nextRemainingRelations.push(relation); - }); - remainingRelations = nextRemainingRelations; - - removedRelations.forEach((relation) => { - [relation.pointAId, relation.pointBId].forEach((pointId) => { - if (idsToDelete.has(pointId)) return; - const point = pointById.get(pointId); - if (!point) return; - const ownsByAdhocFlag = Boolean(point.distanceAdhocNode); - if (!ownsByAdhocFlag) return; - - const stillReferencedByRemainingRelation = remainingRelations.some( - (candidate) => - candidate.pointAId === pointId || candidate.pointBId === pointId - ); - if (stillReferencedByRemainingRelation) return; - - idsToDelete.add(pointId); - expanded = true; - }); - }); - } - - setAnnotations((prev) => prev.filter((m) => !idsToDelete.has(m.id))); - setDistanceRelations(remainingRelations); - setSelectedMeasurementId((prev) => - prev && idsToDelete.has(prev) ? null : prev - ); - setSelectedMeasurementIds((prev) => - prev.filter((selectedId) => !idsToDelete.has(selectedId)) - ); - setPreviousSelectedMeasurementId((prev) => - prev && idsToDelete.has(prev) ? null : prev - ); - setDoubleClickChainSourcePointId((prev) => - prev && idsToDelete.has(prev) ? null : prev - ); - setMoveGizmoPointId((prev) => - prev && idsToDelete.has(prev) ? null : prev - ); - setMoveGizmoAxisDirection((prev) => - moveGizmoPointId && idsToDelete.has(moveGizmoPointId) ? null : prev - ); - setMoveGizmoAxisTitle((prev) => - moveGizmoPointId && idsToDelete.has(moveGizmoPointId) ? null : prev - ); - setMoveGizmoAxisCandidates((prev) => - moveGizmoPointId && idsToDelete.has(moveGizmoPointId) ? null : prev - ); - setIsMoveGizmoDragging(false); - - const remainingPointById = getPointPositionMap(annotations); - idsToDelete.forEach((id) => remainingPointById.delete(id)); - setPlanarPolygonGroups((prev) => - prev.flatMap((group) => { - const nextVertexPointIds = group.vertexPointIds.filter( - (vertexId) => !idsToDelete.has(vertexId) - ); - if (nextVertexPointIds.length < 3) { - return []; - } - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - group.closed, - getDistanceRelationId - ); - return [ - computePolygonGroupDerivedDataWithCamera( - { - ...group, - vertexPointIds: nextVertexPointIds, - edgeRelationIds: nextEdgeRelationIds, - }, - remainingPointById - ), - ]; - }) - ); - setSelectedPlanarPolygonGroupId((prev) => { - if (!prev) return prev; - const hasSelectedPolygonAfterRemoval = planarPolygonGroups.some( - (group) => - group.id === prev && - !group.vertexPointIds.some((vertexId) => idsToDelete.has(vertexId)) - ); - return hasSelectedPolygonAfterRemoval ? prev : null; - }); - setActivePlanarPolygonGroupId((prev) => { - if (!prev) return prev; - const activeGroup = planarPolygonGroups.find( - (group) => group.id === prev - ); - if (!activeGroup) return null; - return activeGroup.vertexPointIds.some((id) => idsToDelete.has(id)) - ? null - : prev; - }); - }, - [ - distanceRelations, - annotations, - moveGizmoPointId, - planarPolygonGroups, - computePolygonGroupDerivedDataWithCamera, - ] - ); - - const deleteSelectedPointAnnotations = useCallback(() => { - const selectedIds = selectedMeasurementIds.filter( - (id) => pointMeasurementIds.has(id) && !lockedMeasurementIdSet.has(id) - ); - if (selectedIds.length > 0) { - clearAnnotationsByIds(selectedIds); - return; - } - if ( - selectedMeasurementId && - pointMeasurementIds.has(selectedMeasurementId) && - !lockedMeasurementIdSet.has(selectedMeasurementId) - ) { - clearAnnotationsByIds([selectedMeasurementId]); - } - }, [ - clearAnnotationsByIds, - lockedMeasurementIdSet, - pointMeasurementIds, - selectedMeasurementId, - selectedMeasurementIds, - ]); - - const flyToMeasurementById = useCallback( - (id: string) => { - if (!id) return; - const measurement = annotations.find((entry) => entry.id === id); - if (!measurement) return; - flyToMeasurementPointGroup( - scene, - getMeasurementEntryFlyToPoints(measurement) - ); - }, - [annotations, scene] - ); - - const flyToAllMeasurements = useCallback(() => { - if (annotations.length === 0) return; - const points = annotations.flatMap(getMeasurementEntryFlyToPoints); - flyToMeasurementPointGroup(scene, points); - }, [annotations, scene]); - - useEffect(() => { - if (selectedMeasurementIds.length === 0) return; - const idsInState = new Set( - annotations.map((measurement) => measurement.id) - ); - setSelectedMeasurementIds((prev) => { - const next = prev.filter((id) => idsInState.has(id)); - return next.length === prev.length ? prev : next; - }); - }, [annotations, selectedMeasurementIds.length]); - - useEffect(() => { - const handleDeleteKey = (event: KeyboardEvent) => { - if (event.key !== "Delete" && event.key !== "Backspace") return; - if (event.defaultPrevented) return; - if (event.metaKey || event.ctrlKey || event.altKey) return; - if (isKeyboardTargetEditable(event.target)) return; - const selectedIds = selectedMeasurementIds.filter( - (id) => pointMeasurementIds.has(id) && !lockedMeasurementIdSet.has(id) - ); - if (selectedIds.length > 1) { - return; - } - const hasDeletablePrimarySelection = - Boolean(selectedMeasurementId) && - pointMeasurementIds.has(selectedMeasurementId) && - !lockedMeasurementIdSet.has(selectedMeasurementId); - if (selectedIds.length === 0 && !hasDeletablePrimarySelection) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - deleteSelectedPointAnnotations(); - }; - - window.addEventListener("keydown", handleDeleteKey, true); - return () => { - window.removeEventListener("keydown", handleDeleteKey, true); - }; - }, [ - deleteSelectedPointAnnotations, - lockedMeasurementIdSet, - pointMeasurementIds, - selectedMeasurementId, - selectedMeasurementIds, - ]); - - useEffect(() => { - const handlePointModeKeyboardShortcuts = (event: KeyboardEvent) => { - if (event.defaultPrevented) return; - if (event.metaKey || event.ctrlKey || event.altKey) return; - if (isKeyboardTargetEditable(event.target)) return; - if ( - annotationMode !== ANNOTATION_TYPE_DISTANCE && - annotationMode !== ANNOTATION_TYPE_POLYLINE && - annotationMode !== ANNOTATION_TYPE_POINT - ) { - return; - } - - const hasSelection = - selectedMeasurementIds.length > 0 || Boolean(selectedMeasurementId); - - if ( - event.key === "Enter" && - isPointMeasureCreateModeActive && - temporaryMode - ) { - const latestTemporaryPointMeasurement = [...annotations] - .reverse() - .find( - (measurement) => - isPointAnnotationEntry(measurement) && measurement.temporary - ); - if (!latestTemporaryPointMeasurement) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - setAnnotations((prev) => - prev.map((measurement) => - measurement.temporary - ? { ...measurement, temporary: false } - : measurement - ) - ); - selectMeasurementById(latestTemporaryPointMeasurement.id); - return; - } - - if (event.key !== "Backspace") return; - if (hasSelection) return; - - const pointMeasurements = annotations.filter(isPointAnnotationEntry); - const latestPointMeasurement = - pointMeasurements[pointMeasurements.length - 1]; - if (!latestPointMeasurement) { - return; - } - - event.preventDefault(); - event.stopPropagation(); - clearAnnotationsByIds([latestPointMeasurement.id]); - selectMeasurementById( - pointMeasurements[pointMeasurements.length - 2]?.id ?? null - ); - }; - - window.addEventListener("keydown", handlePointModeKeyboardShortcuts, true); - return () => { - window.removeEventListener( - "keydown", - handlePointModeKeyboardShortcuts, - true - ); - }; - }, [ - clearAnnotationsByIds, - annotationMode, - annotations, - selectedMeasurementId, - selectedMeasurementIds.length, - selectMeasurementById, - setAnnotations, - isPointMeasureCreateModeActive, - temporaryMode, - ]); - - useEffect(() => { - if (options?.mode !== undefined) { - setAnnotationMode(options.mode); - if (options.mode === SELECT_TOOL_TYPE) { - setSelectedMeasurementId(null); - setPreviousSelectedMeasurementId(null); - setDoubleClickChainSourcePointId(null); - setSelectedPlanarPolygonGroupId(null); - setActivePlanarPolygonGroupId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - } - } - }, [options?.mode, setAnnotationMode]); - - useEffect(() => { - if (isInteractionActive) { - setAnnotationMode((prev) => - prev === SELECT_TOOL_TYPE ? ANNOTATION_TYPE_POINT : prev - ); - } else { - setAnnotationMode(SELECT_TOOL_TYPE); - setSelectedMeasurementId(null); - setPreviousSelectedMeasurementId(null); - setDoubleClickChainSourcePointId(null); - setSelectedPlanarPolygonGroupId(null); - setActivePlanarPolygonGroupId(null); - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - } - }, [isInteractionActive, setAnnotationMode]); - - useEffect(() => { - const previousMode = previousMeasurementModeRef.current; - if (previousMode === annotationMode) return; - previousMeasurementModeRef.current = annotationMode; - - if ( - previousMode === ANNOTATION_TYPE_DISTANCE && - annotationMode !== ANNOTATION_TYPE_DISTANCE && - doubleClickChainSourcePointId - ) { - const pendingSourceMeasurement = annotations.find( - (measurement) => measurement.id === doubleClickChainSourcePointId - ); - if ( - pendingSourceMeasurement && - isPointAnnotationEntry(pendingSourceMeasurement) && - pendingSourceMeasurement.distanceAdhocNode - ) { - const sourcePointId = pendingSourceMeasurement.id; - const hasDistanceRelation = distanceRelations.some( - (relation) => - relation.pointAId === sourcePointId || - relation.pointBId === sourcePointId - ); - const belongsToPlanarGroup = planarPolygonGroups.some((group) => - group.vertexPointIds.includes(sourcePointId) - ); - - if (!hasDistanceRelation && !belongsToPlanarGroup) { - setAnnotations((prev) => - prev.filter((measurement) => measurement.id !== sourcePointId) - ); - } - } - } - - selectedMeasurementIdRef.current = null; - setSelectedMeasurementId(null); - setSelectedMeasurementIds([]); - setPreviousSelectedMeasurementId(null); - setSelectedPlanarPolygonGroupId(null); - setDoubleClickChainSourcePointId(null); - setActivePlanarPolygonGroupId(null); - setPendingPolylinePromotionRingClosurePointId(null); - }, [ - annotationMode, - doubleClickChainSourcePointId, - annotations, - distanceRelations, - planarPolygonGroups, - ]); - - useEffect(() => { - if (annotationMode === ANNOTATION_TYPE_POLYLINE) return; - - const invalidOpenFacadeGroups = planarPolygonGroups.filter((group) => { - return ( - !group.closed && - (group.surfaceType ?? "roof") === "facade" && - group.vertexPointIds.length === 1 - ); - }); - if (invalidOpenFacadeGroups.length === 0) return; - - const invalidGroupIdSet = new Set( - invalidOpenFacadeGroups.map((group) => group.id) - ); - const removablePointIdSet = new Set(); - invalidOpenFacadeGroups.forEach((group) => { - const onlyPointId = group.vertexPointIds[0]; - if (onlyPointId) { - removablePointIdSet.add(onlyPointId); - } - }); - - const remainingGroups = planarPolygonGroups.filter( - (group) => !invalidGroupIdSet.has(group.id) - ); - const protectedPointIdSet = new Set(); - remainingGroups.forEach((group) => { - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - protectedPointIdSet.add(pointId); - } - }); - }); - distanceRelations.forEach((relation) => { - protectedPointIdSet.add(relation.pointAId); - protectedPointIdSet.add(relation.pointBId); - protectedPointIdSet.add(relation.anchorPointId); - }); - - setPlanarPolygonGroups(remainingGroups); - - if ( - selectedPlanarPolygonGroupId && - invalidGroupIdSet.has(selectedPlanarPolygonGroupId) - ) { - setSelectedPlanarPolygonGroupId(null); - } - - if (removablePointIdSet.size === 0) return; - setAnnotations((prev) => - prev.filter((measurement) => { - if (!isPointAnnotationEntry(measurement)) { - return true; - } - if (!removablePointIdSet.has(measurement.id)) { - return true; - } - return protectedPointIdSet.has(measurement.id); - }) - ); - }, [ - annotationMode, - planarPolygonGroups, - distanceRelations, - selectedPlanarPolygonGroupId, - ]); - - useEffect(() => { - if (!doubleClickChainSourcePointId) return; - const hasChainSourceMeasurement = annotations.some( - (measurement) => measurement.id === doubleClickChainSourcePointId - ); - if (!hasChainSourceMeasurement) { - setDoubleClickChainSourcePointId(null); - } - }, [doubleClickChainSourcePointId, annotations]); - - useEffect(() => { - if (!selectedMeasurementId) return; - const hasSelectedMeasurement = annotations.some( - (measurement) => measurement.id === selectedMeasurementId - ); - if (!hasSelectedMeasurement) { - setSelectedMeasurementId(null); - } - }, [annotations, selectedMeasurementId]); - - useEffect(() => { - if (!previousSelectedMeasurementId) return; - const hasPreviousSelection = annotations.some( - (measurement) => measurement.id === previousSelectedMeasurementId - ); - if (!hasPreviousSelection) { - setPreviousSelectedMeasurementId(null); - } - }, [annotations, previousSelectedMeasurementId]); - - useEffect(() => { - const pointMeasurementIds = new Set( - annotations - .filter(isPointAnnotationEntry) - .map((measurement) => measurement.id) - ); - setDistanceRelations((prev) => { - const next = prev - .filter( - (relation) => - pointMeasurementIds.has(relation.pointAId) && - pointMeasurementIds.has(relation.pointBId) - ) - .map((relation) => { - const fallbackAnchorPointId = relation.pointAId; - const anchorPointId = pointMeasurementIds.has(relation.anchorPointId) - ? relation.anchorPointId - : fallbackAnchorPointId; - return { - ...relation, - anchorPointId, - }; - }); - if (next.length !== prev.length) return next; - for (let index = 0; index < next.length; index += 1) { - if (next[index]?.anchorPointId !== prev[index]?.anchorPointId) { - return next; - } - } - return prev; - }); - }, [annotations]); - - useEffect(() => { - const pointMeasurementIds = new Set( - annotations - .filter(isPointAnnotationEntry) - .map((measurement) => measurement.id) - ); - const pointById = getPointPositionMap(annotations); - setPlanarPolygonGroups((prev) => - prev.flatMap((group) => { - const nextVertexPointIds = group.vertexPointIds.filter((vertexId) => - pointMeasurementIds.has(vertexId) - ); - if (nextVertexPointIds.length === 0) return []; - const nextClosed = group.closed && nextVertexPointIds.length >= 3; - const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( - nextVertexPointIds, - nextClosed, - getDistanceRelationId - ); - return [ - computePolygonGroupDerivedDataWithCamera( - { - ...group, - vertexPointIds: nextVertexPointIds, - edgeRelationIds: nextEdgeRelationIds, - closed: nextClosed, - }, - pointById - ), - ]; - }) - ); - }, [annotations, computePolygonGroupDerivedDataWithCamera]); - - useEffect(() => { - if (!referencePoint) return; - - const pointMeasurements = annotations.filter(isPointAnnotationEntry); - if (pointMeasurements.length === 0) { - setReferencePoint(null); - return; - } - - const hasReferenceMeasurement = pointMeasurements.some( - (measurement) => - Cartesian3.distance(measurement.geometryECEF, referencePoint) <= - REFERENCE_POINT_SYNC_EPSILON_METERS - ); - - if (hasReferenceMeasurement) { - return; - } - - // Reference point was deleted: fallback to the latest remaining point. - const nextReferencePoint = - pointMeasurements[pointMeasurements.length - 1]?.geometryECEF ?? null; - setReferencePoint(nextReferencePoint); - }, [annotations, referencePoint, setReferencePoint]); - - useEffect(() => { - if (selectedMeasurementId || selectedPlanarPolygonGroupId) return; - - const relationWithVisibleLine = distanceRelations.find( - hasAnyVisibleDistanceRelationLine - ); - if (relationWithVisibleLine) { - selectMeasurementById(relationWithVisibleLine.anchorPointId); - } - }, [ - distanceRelations, - selectedMeasurementId, - selectedPlanarPolygonGroupId, - selectMeasurementById, - ]); - - useEffect(() => { - if (!moveGizmoPointId) return; - const hasMoveGizmoPoint = annotations.some( - (measurement) => measurement.id === moveGizmoPointId - ); - if (!hasMoveGizmoPoint) { - setMoveGizmoPointId(null); - setMoveGizmoAxisDirection(null); - setMoveGizmoAxisTitle(null); - setMoveGizmoAxisCandidates(null); - setIsMoveGizmoDragging(false); - } - }, [annotations, moveGizmoPointId]); - - useEffect(() => { - if (!activePlanarPolygonGroupId) return; - const hasActiveGroup = planarPolygonGroups.some( - (group) => group.id === activePlanarPolygonGroupId - ); - if (!hasActiveGroup) { - setActivePlanarPolygonGroupId(null); - } - }, [activePlanarPolygonGroupId, planarPolygonGroups]); - - useEffect(() => { - if (!selectedPlanarPolygonGroupId) return; - const hasSelectedPolygonGroup = planarPolygonGroups.some( - (group) => group.id === selectedPlanarPolygonGroupId - ); - if (!hasSelectedPolygonGroup) { - setSelectedPlanarPolygonGroupId(null); - } - }, [planarPolygonGroups, selectedPlanarPolygonGroupId]); - - useEffect(() => { - if (referencePoint !== null) return; - // if more than one point measurement is present, set the reference point to the first one - if ( - annotations.length > 0 && - isPointAnnotationEntry(annotations[0]) && - annotations.length > 1 - ) { - setReferencePoint(annotations[0].geometryECEF); - } - }, [annotations, setReferencePoint, referencePoint]); - - const planarVertexPointIdSet = useMemo(() => { - const ids = new Set(); - planarPolygonGroups.forEach((group) => { - group.vertexPointIds.forEach((pointId) => { - if (pointId) { - ids.add(pointId); - } - }); - }); - return ids; - }, [planarPolygonGroups]); - - const distanceEndpointPointIdSet = useMemo(() => { - const ids = new Set(); - distanceRelations.forEach((relation) => { - ids.add(relation.pointAId); - ids.add(relation.pointBId); - }); - return ids; - }, [distanceRelations]); - - const distanceAnchorPointIdSet = useMemo(() => { - const ids = new Set(); - distanceRelations.forEach((relation) => { - ids.add(relation.anchorPointId || relation.pointAId); - }); - return ids; - }, [distanceRelations]); - - const annotationsByType = useCallback( - (type: AnnotationListType): AnnotationEntry[] => { - if (type === "pointLabel") { - return annotations.filter( - (measurement) => - isPointAnnotationEntry(measurement) && - Boolean(measurement.auxiliaryLabelAnchor) - ); - } - - if (type === "pointMeasure") { - return annotations.filter( - (measurement) => - isPointAnnotationEntry(measurement) && - !measurement.auxiliaryLabelAnchor && - !measurement.distanceAdhocNode && - !planarVertexPointIdSet.has(measurement.id) - ); - } - - if (type === "distanceMeasure") { - return annotations.filter((measurement) => { - if (!isPointAnnotationEntry(measurement)) return false; - if (measurement.auxiliaryLabelAnchor) return false; - if (planarVertexPointIdSet.has(measurement.id)) return false; - - const ownsDistanceByRelationFlag = Boolean( - measurement.distanceRelationId - ); - const ownsDistanceByAnchor = distanceAnchorPointIdSet.has( - measurement.id - ); - const isStandaloneDistanceNode = - Boolean(measurement.distanceAdhocNode) && - !distanceEndpointPointIdSet.has(measurement.id); - - return ( - ownsDistanceByRelationFlag || - ownsDistanceByAnchor || - isStandaloneDistanceNode - ); - }); - } - - return annotations.filter((measurement) => measurement.type === type); - }, - [ - distanceAnchorPointIdSet, - distanceEndpointPointIdSet, - annotations, - planarVertexPointIdSet, - ] - ); - - const navigationAnnotationTypes = useMemo< - AnnotationListType[] - >(() => ["pointMeasure", "distanceMeasure"], []); - - const { - getAnnotationsForNavigation, - getAnnotationIndexByType, - getAnnotationOrderByType, - getNextAnnotationOrderByType, - } = useAnnotationCollectionSelectors({ - annotationsByType, - navigationTypes: navigationAnnotationTypes, - }); - - const addAnnotation = useCallback( - (payload: AnnotationCreatePayload): string => { - const generatedId = - payload.id?.trim() || - `${payload.type}-${Date.now()}-${Math.random() - .toString(36) - .slice(2, 9)}`; - const nextMeasurement: AnnotationEntry = { - ...payload, - id: generatedId, - timestamp: payload.timestamp ?? Date.now(), - }; - setAnnotations((prev) => [...prev, nextMeasurement]); - return generatedId; - }, - [setAnnotations] - ); - - const updateAnnotationById = useCallback( - (id: string, patch: Partial) => { - if (!id) return; - setAnnotations((prev) => { - let hasChanged = false; - const next = prev.map((measurement) => { - if (measurement.id !== id) return measurement; - hasChanged = true; - return { - ...measurement, - ...patch, - id: measurement.id, - }; - }); - return hasChanged ? next : prev; - }); - }, - [setAnnotations] - ); - - const deleteAnnotationById = useCallback( - (id: string) => { - if (!id) return; - clearAnnotationsByIds([id]); - }, - [clearAnnotationsByIds] - ); - - const deleteAnnotationsByIds = useCallback( - (ids: string[]) => { - if (ids.length === 0) return; - clearAnnotationsByIds(ids); - }, - [clearAnnotationsByIds] - ); - - const liveAnnotationCandidate = useMemo(() => { - const isPointLivePreviewMode = - annotationMode === ANNOTATION_TYPE_POINT && !pointLabelOnCreate; - const isDistanceLivePreviewMode = - annotationMode === ANNOTATION_TYPE_DISTANCE; - - if (!isPointLivePreviewMode && !isDistanceLivePreviewMode) { - return null; - } - if (!livePreviewPointECEF) { - return null; - } - - const previewPoint = getDegreesFromCartesian(livePreviewPointECEF); - if ( - !Number.isFinite(previewPoint.latitude) || - !Number.isFinite(previewPoint.longitude) - ) { - return null; - } - - const previewMeasurementType = isDistanceLivePreviewMode - ? ANNOTATION_TYPE_DISTANCE - : ANNOTATION_TYPE_POINT; - - return { - id: "__live-preview-measurement__", - type: previewMeasurementType, - timestamp: -1, - isLivePreview: true, - distanceAdhocNode: isDistanceLivePreviewMode ? true : undefined, - geometryECEF: Cartesian3.clone(livePreviewPointECEF), - geometryWGS84: { - latitude: previewPoint.latitude, - longitude: previewPoint.longitude, - altitude: getEllipsoidalAltitudeOrZero(previewPoint.altitude), - }, - }; - }, [livePreviewPointECEF, annotationMode, pointLabelOnCreate]); - - const annotationsContextValue = useMemo< - AnnotationsContextType - >( - () => ({ - annotationMode, - setAnnotationMode, - annotations, - liveAnnotationCandidate, - annotationsByType, - getAnnotationsForNavigation, - getAnnotationIndexByType, - getAnnotationOrderByType, - getNextAnnotationOrderByType, - addAnnotation, - updateAnnotationById, - deleteAnnotationById, - deleteAnnotationsByIds, - setAnnotations, - updateAnnotationNameById, - updatePointLabelAppearanceById, - toggleAnnotationLockById, - clearAnnotationsByIds, - deleteSelectedPointAnnotations, - setPointAnnotationElevationById, - setPointAnnotationCoordinatesById, - temporaryMode, - setTemporaryMode, - pointVerticalOffsetMeters, - setPointVerticalOffsetMeters, - pointLabelOnCreate, - setPointLabelOnCreate, - labelInputPromptPointId, - confirmPointLabelInputById, - showLabels, - setShowLabels, - }), - [ - annotationMode, - setAnnotationMode, - annotations, - liveAnnotationCandidate, - annotationsByType, - getAnnotationsForNavigation, - getAnnotationIndexByType, - getAnnotationOrderByType, - getNextAnnotationOrderByType, - addAnnotation, - updateAnnotationById, - deleteAnnotationById, - deleteAnnotationsByIds, - setAnnotations, - updateAnnotationNameById, - updatePointLabelAppearanceById, - toggleAnnotationLockById, - clearAnnotationsByIds, - deleteSelectedPointAnnotations, - setPointAnnotationElevationById, - setPointAnnotationCoordinatesById, - temporaryMode, - setTemporaryMode, - pointVerticalOffsetMeters, - setPointVerticalOffsetMeters, - pointLabelOnCreate, - setPointLabelOnCreate, - labelInputPromptPointId, - confirmPointLabelInputById, - showLabels, - setShowLabels, - ] - ); - - const selectionContextValue = useMemo( - () => ({ - selectedMeasurementId, - selectedMeasurementIds, - selectMeasurementById, - selectMeasurementIds, - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - effectiveSelectModeAdditive, - }), - [ - selectedMeasurementId, - selectedMeasurementIds, - selectMeasurementById, - selectMeasurementIds, - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - effectiveSelectModeAdditive, - ] - ); - - const polylineGroups = useMemo( - () => - planarPolygonGroups.filter( - (group) => - getPlanarGroupMeasurementKind(group) === ANNOTATION_TYPE_POLYLINE - ), - [planarPolygonGroups] - ); - const areaPolygonGroups = useMemo( - () => - planarPolygonGroups.filter((group) => { - if ( - getPlanarGroupMeasurementKind(group) !== ANNOTATION_TYPE_AREA_GROUND - ) { - return false; - } - const surfaceType = group.surfaceType ?? "roof"; - return surfaceType === "footprint" || surfaceType === "terrain"; - }), - [planarPolygonGroups] - ); - const planarSurfacePolygonGroups = useMemo( - () => - planarPolygonGroups.filter((group) => { - if ( - getPlanarGroupMeasurementKind(group) !== ANNOTATION_TYPE_AREA_GROUND - ) { - return false; - } - return (group.surfaceType ?? "roof") === "roof"; - }), - [planarPolygonGroups] - ); - const verticalPolygonGroups = useMemo( - () => - planarPolygonGroups.filter((group) => { - if ( - getPlanarGroupMeasurementKind(group) !== ANNOTATION_TYPE_AREA_GROUND - ) { - return false; - } - return (group.surfaceType ?? "roof") === "facade"; - }), - [planarPolygonGroups] - ); - - const modeOptionsContextValue = useMemo( - () => ({ - planarPolygonGroups, - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - distanceModeStickyToFirstPoint, - setDistanceModeStickyToFirstPoint, - distanceCreationLineVisibility, - setDistanceCreationLineVisibilityByKind, - polylineVerticalOffsetMeters, - setPolylineVerticalOffsetMeters, - polylineSegmentLineMode, - setPolylineSegmentLineMode, - planarToolCreationMode, - setPlanarToolCreationMode, - polygonSurfaceTypePreset, - setPolygonSurfaceTypePreset, - }), - [ - planarPolygonGroups, - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - distanceModeStickyToFirstPoint, - setDistanceModeStickyToFirstPoint, - distanceCreationLineVisibility, - setDistanceCreationLineVisibilityByKind, - polylineVerticalOffsetMeters, - setPolylineVerticalOffsetMeters, - polylineSegmentLineMode, - setPolylineSegmentLineMode, - planarToolCreationMode, - setPlanarToolCreationMode, - polygonSurfaceTypePreset, - setPolygonSurfaceTypePreset, - ] - ); - - const visibilityContextValue = useMemo< - AnnotationVisibilityContextType - >( - () => ({ - hideMeasurementsOfType, - setHideMeasurementsOfType, - hideLabelsOfType, - setHideLabelsOfType, - }), - [ - hideMeasurementsOfType, - setHideMeasurementsOfType, - hideLabelsOfType, - setHideLabelsOfType, - ] - ); - - const editContextValue = useMemo( - () => ({ - lockedEditMeasurementId, - clearLockedEditMeasurementId, - }), - [clearLockedEditMeasurementId, lockedEditMeasurementId] - ); - - const contextValue = useMemo( - () => ({ - annotationMode, - setAnnotationMode, - annotations, - setAnnotations, - selectedMeasurementId, - activeMeasurementId, - selectedMeasurementIds, - selectMeasurementIds, - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - selectMeasurementById, - updateAnnotationNameById, - updatePointLabelAppearanceById, - toggleAnnotationLockById, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - selectPlanarPolygonGroupById, - updatePlanarPolygonNameById, - moveGizmoPointId, - isMoveGizmoDragging, - startMoveGizmoForMeasurementId, - handleMoveGizmoPointPositionChange, - stopMoveGizmo, - setPointAnnotationElevationById, - setPointAnnotationCoordinatesById, - clearAllMeasurements, - clearAnnotationsByIds, - deleteSelectedPointAnnotations, - clearMeasurementsByType, - flyToMeasurementById, - flyToAllMeasurements, - showLabels, - setShowLabels, - temporaryMode, - setTemporaryMode, - pointRadius, - setPointRadius, - pointVerticalOffsetMeters, - setPointVerticalOffsetMeters, - polylineVerticalOffsetMeters, - setPolylineVerticalOffsetMeters, - polylineVerticalOffsetVisualOnly, - setPolylineVerticalOffsetVisualOnly, - polylineSegmentLineMode, - setPolylineSegmentLineMode, - planarToolCreationMode, - setPlanarToolCreationMode, - polygonSurfaceTypePreset, - setPolygonSurfaceTypePreset, - distanceModeStickyToFirstPoint, - setDistanceModeStickyToFirstPoint, - distanceCreationLineVisibility, - setDistanceCreationLineVisibilityByKind, - heightOffset, - setHeightOffset, - referencePoint, - livePreviewPointECEF, - hasDistancePreviewAnchor, - setReferencePoint, - referenceElevation, - geometryNodeTable, - distanceRelations, - setDistanceRelations, - planarPolygonGroups, - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - setPlanarPolygonGroups, - polylines, - setPolylines, - showSelectedReferenceLine, - setShowSelectedReferenceLine, - showSelectedReferenceLineComponents, - setShowSelectedReferenceLineComponents, - occlusionChecksEnabled, - setOcclusionChecksEnabled, - setPointLabelMetricModeById, - pointLabelOnCreate, - setPointLabelOnCreate, - labelInputPromptPointId, - confirmPointLabelInputById, - pointMarkerBadgeByPointId, - pendingPolylinePromotionRingClosurePointId, - confirmPolylineRingPromotion, - cancelPolylineRingPromotion, - }), - [ - annotationMode, - setAnnotationMode, - annotations, - setAnnotations, - selectedMeasurementId, - activeMeasurementId, - selectedMeasurementIds, - selectMeasurementIds, - selectionModeActive, - setSelectionModeActive, - selectModeAdditive, - setSelectModeAdditive, - selectModeRectangle, - setSelectModeRectangle, - selectMeasurementById, - updateAnnotationNameById, - updatePointLabelAppearanceById, - toggleAnnotationLockById, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - selectPlanarPolygonGroupById, - updatePlanarPolygonNameById, - moveGizmoPointId, - isMoveGizmoDragging, - startMoveGizmoForMeasurementId, - stopMoveGizmo, - setPointAnnotationElevationById, - setPointAnnotationCoordinatesById, - clearAllMeasurements, - clearAnnotationsByIds, - deleteSelectedPointAnnotations, - clearMeasurementsByType, - flyToMeasurementById, - flyToAllMeasurements, - showLabels, - setShowLabels, - occlusionChecksEnabled, - setOcclusionChecksEnabled, - temporaryMode, - setTemporaryMode, - pointRadius, - setPointRadius, - pointVerticalOffsetMeters, - setPointVerticalOffsetMeters, - polylineVerticalOffsetMeters, - setPolylineVerticalOffsetMeters, - polylineVerticalOffsetVisualOnly, - setPolylineVerticalOffsetVisualOnly, - polylineSegmentLineMode, - setPolylineSegmentLineMode, - planarToolCreationMode, - setPlanarToolCreationMode, - polygonSurfaceTypePreset, - setPolygonSurfaceTypePreset, - distanceModeStickyToFirstPoint, - setDistanceModeStickyToFirstPoint, - distanceCreationLineVisibility, - setDistanceCreationLineVisibilityByKind, - heightOffset, - setHeightOffset, - referencePoint, - livePreviewPointECEF, - hasDistancePreviewAnchor, - setReferencePoint, - referenceElevation, - geometryNodeTable, - distanceRelations, - setDistanceRelations, - planarPolygonGroups, - polylineGroups, - areaPolygonGroups, - planarSurfacePolygonGroups, - verticalPolygonGroups, - setPlanarPolygonGroups, - polylines, - setPolylines, - showSelectedReferenceLine, - setShowSelectedReferenceLine, - showSelectedReferenceLineComponents, - setShowSelectedReferenceLineComponents, - pointLabelOnCreate, - labelInputPromptPointId, - confirmPointLabelInputById, - pointMarkerBadgeByPointId, - pendingPolylinePromotionRingClosurePointId, - confirmPolylineRingPromotion, - cancelPolylineRingPromotion, - ] - ); - - return ( - - - {children} - - - ); -}; - -// eslint-disable-next-line react-refresh/only-export-components -export const useAnnotationsAdapter = (): AnnotationsAdapterContextType => { - const context = useContext(AnnotationsAdapterContext); - if (context === undefined) { - throw new Error( - "useAnnotationsAdapter must be used within a AnnotationsAdapterProvider" - ); - } - return context; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/AnnotationsProvider.tsx b/libraries/mapping/annotations/provider/src/lib/context/AnnotationsProvider.tsx new file mode 100644 index 0000000000..9293ad2948 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/AnnotationsProvider.tsx @@ -0,0 +1,1564 @@ +/* @refresh reset */ +import React, { + createContext, + useContext, + useCallback, + useLayoutEffect, + useMemo, + useRef, + type Dispatch, + type SetStateAction, +} from "react"; +import { + Cartesian3, + Cartesian4, + Matrix4, + cartesian3FromJson, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, + getLocalUpDirectionAtAnchor, + getPositionFromLocalFrame, + getPositionInLocalFrame, + getPositionWithVerticalOffsetFromAnchor, + getSignedAngleDegAroundAxis, + normalizeDirection, + projectPointToHorizontalPlaneAtAnchor, + resolveLocalFrameVectors, + type Scene, + Transforms, +} from "@carma/cesium"; +import { useStoreSelector } from "@carma-commons/react-store"; +import { normalizeOptions } from "@carma-commons/utils"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + LINEAR_SEGMENT_LINE_MODE_DIRECT, + areDistanceRelationsEquivalent, + arePolygonAnnotationsEquivalent, + buildEdgeRelationIdsForPolygon, + buildDerivedPolylinePaths, + buildVerticalAutoCloseRectangle, + computePolylinePlanarAngleSumDeg, + createPlaneFromThreePoints, + distancePointToPlane, + getConnectedOpenPolylineGroupIds, + getDistanceRelationId, + getMeasurementEdgeId, + getNextDirectLineLabelMode, + getPointPositionMap, + getVerticalPolygonAxisRotationSuffix, + hasAnyVisibleDistanceRelationLine, + type AnnotationEntry, + type AnnotationCollection, + type AnnotationMode, + type AnnotationPersistenceEnvelopeV2, + type AnnotationToolType, + type DirectLineLabelMode, + type DistanceRelationLabelVisibilityByKind, + type DerivedPolylinePath, + isPointAnnotationEntry, + isAreaToolType, + type LinearSegmentLineMode, + type NodeChainAnnotation, + type PlanarPolygonPlane, + type PointDistanceRelation, + projectPointOntoPlane, + type PolygonAreaType, + type ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; +import type { PointLabelLayoutConfigOverrides } from "@carma-providers/label-overlay"; + +import { useAnnotationCollectionDomain } from "./annotation-entries/useAnnotationCollectionDomain"; +import { useAnnotationEntriesDomainActions } from "./annotation-entries/useAnnotationEntriesDomainActions"; +import { useAnnotationEntryStoreState } from "./annotation-entries/useAnnotationEntryStoreState"; +import { useAnnotationEntryNameAction } from "./annotation-entries/useAnnotationEntryNameAction"; +import { useAnnotationModelIntegritySync } from "./annotation-entries/useAnnotationModelIntegritySync"; +import { useAnnotationPersistenceSync } from "./annotation-entries/useAnnotationPersistenceSync"; +import { useSyncNodeChainEdgeRelations } from "./annotation-entries/useSyncNodeChainEdgeRelations"; +import { syncNodeChainEdgeDistanceRelations } from "./annotation-entries/syncNodeChainEdgeDistanceRelations"; +import { useAnnotationsEditing } from "./interaction/editing/useAnnotationsEditing"; +import type { AnnotationEditState } from "./interaction/editing/useAnnotationEditState"; +import { useAnnotationDraftSessionState } from "./interaction/mode-lifecycle/useAnnotationDraftSessionState"; +import type { AnnotationDraftSessionState } from "./interaction/mode-lifecycle/useAnnotationDraftSessionState"; +import { useActiveToolType } from "./interaction/mode-lifecycle/useActiveToolType"; +import { useAnnotationDraftActions } from "./interaction/mode-lifecycle/useAnnotationDraftActions"; +import { useAnnotationModeTransition } from "./interaction/mode-lifecycle/useAnnotationModeTransition"; +import { useAnnotationToolLifecycle } from "./interaction/mode-lifecycle/useAnnotationToolLifecycle"; +import { useDistanceMeasureAuthoring } from "./interaction/mode-lifecycle/useDistanceMeasureAuthoring"; +import { useLabelPlacementDraftActions } from "./interaction/mode-lifecycle/useLabelPlacementDraftActions"; +import { useNodeChainFinishing } from "./interaction/mode-lifecycle/useNodeChainFinishing"; +import { useAnnotationsUserInteraction } from "./interaction/useAnnotationsUserInteraction"; +import { useActiveDrawModeState } from "./interaction/useActiveDrawModeState"; +import { useAnnotationsInteractionLifecycle } from "./interaction/useAnnotationsInteractionLifecycle"; +import { useOverlayPositionSync } from "./interaction/useOverlayPositionSync"; +import { usePointQuerySelectionGuard } from "./interaction/usePointQuerySelectionGuard"; +import { usePointAnnotationCreatedHandlers } from "./interaction/create/usePointAnnotationCreatedHandlers"; +import { useNodeChainPointCreation } from "./interaction/create/useNodeChainPointCreation"; +import { useAnnotationCursorCandidateState } from "./interaction/candidate/useAnnotationCursorCandidateState"; +import { useCandidatePreviewAnnotation } from "./interaction/candidate/useCandidatePreviewAnnotation"; +import { useAnnotationsVisualization } from "./render/useAnnotationsVisualization"; +import { useAnnotationsVisualizationState } from "./render/useAnnotationsVisualizationState"; +import { useAnnotationVisibilityFilterState } from "./render/useAnnotationVisibilityFilterState"; +import { useAnnotationsSelectionState } from "./selection"; +import type { AnnotationsSelectionState } from "./selection"; +import { usePointMeasurementCollections } from "./topology/point/usePointMeasurementCollections"; +import type { PointMeasurementCollections } from "./topology/point/usePointMeasurementCollections"; +import { usePolylineMeasureState } from "./topology/polyline/usePolylineMeasureState"; +import { useAnnotationTopologyIndex } from "./topology/useAnnotationTopologyIndex"; +import type { MeasurementOwnershipIndex } from "./topology/useAnnotationTopologyIndex"; +import { useNodeChainPlaneDerivation } from "./topology/useNodeChainPlaneDerivation"; +import { useAnnotationSettingsState } from "./interaction/settings/useAnnotationSettingsState"; +import type { AnnotationSettingsState } from "./interaction/settings/useAnnotationSettingsState"; +import { useAnnotationDraftRollbackState } from "./interaction/mode-lifecycle/useAnnotationDraftRollbackState"; +import type { MeasurementDraftRollbackState } from "./interaction/mode-lifecycle/useAnnotationDraftRollbackState"; +import { useAnnotationEditState } from "./interaction/editing/useAnnotationEditState"; +import { useAnnotationCreateDefaults } from "./interaction/create/useAnnotationCreateDefaults"; +import type { AnnotationCreateDefaults } from "./interaction/create/useAnnotationCreateDefaults"; +import { useReferencePointMeasurementId } from "./interaction/useReferencePointMeasurementId"; +import type { + AnnotationsContextType, + AnnotationCollectionContextType, + AnnotationEditingContextType, + AnnotationSelectionContextType, + AnnotationSettingsContextType, + AnnotationToolsContextType, +} from "./annotationsContext.types"; +import { + createAnnotationsStore, + createInitialAnnotationsStoreState, + type AnnotationsStore, + type AnnotationsStoreSnapshot, +} from "./store"; +import type { AnnotationEntryStoreState } from "./annotation-entries/useAnnotationEntryStoreState"; + +export type { + AnnotationsContextType, + AnnotationCollectionContextType, + AnnotationEditingContextType, + AnnotationSelectionContextType, + AnnotationSettingsContextType, + AnnotationToolsContextType, +}; + +interface AnnotationsProviderProps { + children: React.ReactNode; + options?: AnnotationsOptions; + enabled?: boolean; + cesiumScene: Scene; +} + +const AnnotationsStoreContext = createContext( + undefined +); + +const useRequiredAnnotationsStore = ( + store: AnnotationsStore | undefined, + hookName: string +): AnnotationsStore => { + if (store === undefined) { + throw new Error(`${hookName} must be used within a AnnotationsProvider`); + } + + return store; +}; + +export const AnnotationsProvider: React.FC = ({ + children, + options, + enabled = true, + cesiumScene, +}) => { + const annotationsStoreRef = useRef(null); + + if (annotationsStoreRef.current === null) { + annotationsStoreRef.current = createAnnotationsStore( + createInitialAnnotationsStoreState({ + initialToolType: options?.initialToolType ?? "point", + initialPointRadius: options?.pointQueries?.radius ?? 1, + initialPointVerticalOffsetMeters: + options?.pointQueries?.verticalOffsetMeters ?? 0, + initialPointTemporaryMode: + options?.pointQueries?.temporaryMode ?? false, + initialDistanceStickyToFirstPoint: + options?.distance?.stickyToFirstPoint, + initialDistanceCreationLineVisibility: + options?.distance?.creationLineVisibility, + initialDistanceLabelVisibilityByKind: + options?.distance?.defaultLabelVisibilityByKind, + initialDistanceDirectLineLabelMode: + options?.distance?.defaultDirectLineLabelMode, + initialPolylineVerticalOffsetMeters: + options?.pointQueries?.verticalOffsetMeters ?? 0, + initialHeightOffset: options?.pointQueries?.heightOffset ?? 1.5, + }) + ); + } + + const annotationsStore = annotationsStoreRef.current; + const annotationEntryState = useAnnotationEntryStoreState(annotationsStore); + const annotationSettingsState = useAnnotationSettingsState(annotationsStore); + const annotationDraftSessionState = + useAnnotationDraftSessionState(annotationsStore); + const annotationDraftRollbackState = + useAnnotationDraftRollbackState(annotationsStore); + const pointMeasurementCollections = usePointMeasurementCollections( + annotationEntryState.annotations + ); + const annotationTopologyIndex = useAnnotationTopologyIndex( + annotationEntryState.nodeChainAnnotations + ); + const annotationEditState = useAnnotationEditState( + annotationsStore, + annotationEntryState.annotations + ); + const annotationCreateDefaults = useAnnotationCreateDefaults( + annotationEntryState.annotations + ); + const referencePointMeasurementId = useReferencePointMeasurementId( + annotationEntryState.annotations, + annotationEntryState.referencePoint, + 0.001 + ); + const annotationSelectionState = useAnnotationsSelectionState( + annotationsStore, + cesiumScene, + pointMeasurementCollections.selectablePointIds, + annotationTopologyIndex.getOwnerGroupIdsForPointId, + annotationDraftSessionState.activeNodeChainAnnotationId + ); + const scene = cesiumScene; + const selectionState = annotationSelectionState; + const { + getPreferredPlaneFacingPosition, + orientPlaneTowardSceneCamera, + computePolygonGroupDerivedDataWithCamera, + } = useNodeChainPlaneDerivation(scene); + useOverlayPositionSync(scene); + + const pointQueryOptions = normalizeOptions( + options?.pointQueries, + defaultPointQueryOptions + ); + const pointQueryEnabled = pointQueryOptions.enabled !== false; + + const moveGizmoOptions = normalizeOptions( + options?.moveGizmo, + defaultMoveGizmoOptions + ); + + const normalizedOptions = normalizeOptions(options, defaultOptions); + const { initialPersistenceState, onPersistenceStateChange } = + normalizedOptions; + const isInteractionActive = enabled; + const { + annotations, + distanceRelations, + nodeChainAnnotations, + referencePoint, + annotationToolType, + showLabels, + occlusionChecksEnabled, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setReferencePoint, + } = annotationEntryState; + const updateAnnotationEntryNameById = + useAnnotationEntryNameAction(setAnnotations); + const { + pointRadius, + pointVerticalOffsetMeters, + pointTemporaryMode, + defaultPolylineVerticalOffsetMeters, + defaultPolylineSegmentLineMode, + distanceModeStickyToFirstPoint, + distanceCreationLineVisibility, + distanceDefaultLabelVisibilityByKind, + distanceDefaultDirectLineLabelMode, + heightOffset, + setPointVerticalOffsetMeters, + setPointTemporaryMode, + setDefaultPolylineVerticalOffsetMeters, + setDefaultPolylineSegmentLineMode, + setDistanceModeStickyToFirstPoint, + setDistanceCreationLineVisibilityByKind, + } = annotationSettingsState; + const { + hideAnnotationsOfType: hideMeasurementsOfType, + setHideAnnotationsOfType: setHideMeasurementsOfType, + hideLabelsOfType, + setHideLabelsOfType, + } = useAnnotationVisibilityFilterState(); + const { + createdPointIds, + createdRelationIds, + clearMeasurementDraftSession, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + pruneMeasurementDraftSession, + } = annotationDraftRollbackState; + const { + activeNodeChainAnnotationId, + pendingPolylinePromotionRingClosurePointId, + labelInputPromptPointId, + doubleClickChainSourcePointId, + setActiveNodeChainAnnotationId, + setPendingPolylinePromotionRingClosurePointId, + setLabelInputPromptPointId, + setDoubleClickChainSourcePointId, + } = annotationDraftSessionState; + const { pointEntries, pointMeasureEntries, selectablePointIds } = + pointMeasurementCollections; + const { + selectedAnnotationId, + selectedAnnotationIds, + selectionModeActive, + setSelectionModeActive, + selectAnnotationById, + selectAnnotationByIdImmediate, + clearPointSelection, + clearAnnotationSelection, + pruneSelectionByRemovedIds, + focusedNodeChainAnnotationId, + } = selectionState; + const { + getOwnerGroupIdsForPointId, + getOwnerGroupIdsForEdgeRelationId, + getRepresentativePointIdForGroupId, + } = annotationTopologyIndex; + + const { moveGizmo, clearMoveGizmo } = annotationEditState; + + useAnnotationModelIntegritySync({ + annotations, + pointEntries, + defaultPolylineSegmentLineMode, + setDistanceRelations, + setNodeChainAnnotations, + computePolygonGroupDerivedDataWithCamera, + }); + + useAnnotationPersistenceSync({ + initialPersistenceState, + onPersistenceStateChange, + annotations, + distanceRelations, + nodeChainAnnotations, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + }); + + const { + polylineVerticalOffsetMeters, + setPolylineVerticalOffsetMeters, + polylineSegmentLineMode, + setPolylineSegmentLineMode, + } = usePolylineMeasureState({ + focusedNodeChainAnnotationId, + nodeChainAnnotations, + defaultPolylineVerticalOffsetMeters, + defaultPolylineSegmentLineMode, + setDefaultPolylineVerticalOffsetMeters, + setDefaultPolylineSegmentLineMode, + setNodeChainAnnotations, + setAnnotations, + }); + const activeToolType = useActiveToolType( + annotationToolType, + selectionModeActive + ); + + const { + activeCandidateNodeECEF, + cursorScreenPosition, + activeCandidateNodeSurfaceNormalECEF, + activeCandidateNodeVerticalOffsetAnchorECEF, + clearAnnotationCursor, + handleAnnotationCursorMove, + isPolylineCandidateMode, + hasCandidateNode, + candidateSupportsEdgeLine, + candidateUsesPolylineEdgeRules, + candidateForcesDirectEdgeLine, + annotationCursorEnabled, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + } = useAnnotationCursorCandidateState({ + scene, + annotations, + activeToolType, + activeNodeChainAnnotationId, + labelInputPromptPointId, + nodeChainAnnotations, + pointVerticalOffsetMeters, + polylineVerticalOffsetMeters, + pointQueryEnabled, + moveGizmoPointId: moveGizmo.pointId, + isMoveGizmoDragging: moveGizmo.isDragging, + setNodeChainAnnotations, + }); + const { lastCustomPointAnnotationName } = annotationCreateDefaults; + + const { + clearPendingPolylineRingPromotion, + clearPendingLabelPlacementAnnotation, + clearActiveNodeChainDrawingState, + discardActiveMeasurementDraft, + } = useAnnotationDraftActions({ + createdPointIds, + createdRelationIds, + moveGizmoPointId: moveGizmo.pointId, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setPendingPolylinePromotionRingClosurePointId, + setLabelInputPromptPointId, + setNodeChainAnnotations, + setDistanceRelations, + setAnnotations, + pruneSelectionByRemovedIds, + clearMeasurementDraftSession, + clearAnnotationCursor, + clearAnnotationSelection, + clearMoveGizmo, + }); + + const selectRepresentativeNodeForMeasurementId = useCallback( + (id: string | null) => { + if (id === null) { + clearAnnotationSelection(); + return; + } + + const representativePointId = getRepresentativePointIdForGroupId(id); + if (!representativePointId) { + return; + } + + clearActiveNodeChainDrawingState(); + clearMoveGizmo(); + selectAnnotationById(representativePointId); + }, + [ + clearActiveNodeChainDrawingState, + clearAnnotationSelection, + clearMoveGizmo, + getRepresentativePointIdForGroupId, + selectAnnotationById, + ] + ); + + const focusAnnotationById = useCallback( + (id: string | null) => { + if (id === null) { + clearAnnotationSelection(); + return; + } + + const isNodeChainAnnotationId = nodeChainAnnotations.some( + (annotation) => annotation.id === id + ); + if (isNodeChainAnnotationId) { + selectRepresentativeNodeForMeasurementId(id); + return; + } + + selectAnnotationById(id); + }, + [ + clearAnnotationSelection, + nodeChainAnnotations, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + ] + ); + + const isActiveDrawMode = useActiveDrawModeState( + doubleClickChainSourcePointId, + selectablePointIds, + activeNodeChainAnnotationId, + nodeChainAnnotations + ); + + const { + finishDistanceMeasurementSession, + handleDistancePointCreated, + resolveDistanceRelationSourcePointId, + upsertDirectDistanceRelation, + } = useDistanceMeasureAuthoring({ + distanceCreationLineVisibility, + defaultDistanceRelationLabelVisibility: + distanceDefaultLabelVisibilityByKind, + defaultDirectLineLabelMode: distanceDefaultDirectLineLabelMode, + distanceModeStickyToFirstPoint, + distanceRelations, + doubleClickChainSourcePointId, + selectablePointIds, + referencePointMeasurementId, + clearMeasurementDraftSession, + selectAnnotationById, + selectAnnotationByIdImmediate, + setDoubleClickChainSourcePointId, + setDistanceRelations, + setActiveNodeChainAnnotationId, + setReferencePoint, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + }); + + const { + cumulativeDistanceByRelationId, + effectiveReferenceElevation, + effectiveDistanceToReferenceByPointId, + pointMarkerBadgeByPointId, + collapsedPillPointIds, + showPoints, + showPointLabels, + lockedMeasurementIdSet, + markerlessPointIds, + visibleMeasurementsForRendering, + visiblePolygonAnnotationsForRendering, + effectiveDistanceRelationsForRendering, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + activeMeasurementId, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + } = useAnnotationsVisualizationState({ + scene, + annotations, + distanceRelations, + nodeChainAnnotations, + pointEntries, + pointMeasureEntries, + referencePoint, + defaultPolylineVerticalOffsetMeters, + hideMeasurementsOfType, + hideLabelsOfType, + showLabels, + setAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + annotationCursorEnabled, + activeToolType, + distanceModeStickyToFirstPoint, + referencePointMeasurementId, + doubleClickChainSourcePointId, + selectablePointIds, + moveGizmoPointId: moveGizmo.pointId, + activeCandidateNodeECEF, + candidateSupportsEdgeLine, + resolveDistanceRelationSourcePointId, + candidateForcesDirectEdgeLine, + candidateUsesPolylineEdgeRules, + polylineSegmentLineMode, + distanceCreationLineVisibility, + isPolylineCandidateMode, + defaultDistanceRelationLabelVisibility: + distanceDefaultLabelVisibilityByKind, + }); + + const { + cancelPolylineRingPromotion, + closeActivePolygonAnnotation, + confirmPolylineRingPromotion, + finishActivePolylineAnnotation, + handlePointQueryDoubleClick, + } = useNodeChainFinishing({ + sceneCameraPosition: getPreferredPlaneFacingPosition(), + activeToolType, + activeNodeChainAnnotationId, + pendingPolylineRingPromotionPointId: + pendingPolylinePromotionRingClosurePointId, + annotations, + nodeChainAnnotations: nodeChainAnnotations, + setAnnotations, + setNodeChainAnnotations, + setPendingPolylineRingPromotionPointId: + setPendingPolylinePromotionRingClosurePointId, + clearAnnotationCursor, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + selectRepresentativeNodeForMeasurementId, + }); + + const { handlePointQueryBeforePointCreate } = usePointQuerySelectionGuard({ + scene, + activeToolType, + isActiveDrawMode, + focusedSelectedNodeChainAnnotationId: focusedNodeChainAnnotationId, + selectionModeActive, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + }); + + const { handlePointAnnotationCreated, handleLabelAnnotationCreated } = + usePointAnnotationCreatedHandlers({ + selectAnnotationByIdImmediate, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setLabelInputPromptPointId, + }); + + const { requestEnterToolType, clearSharedModeExitState } = + useAnnotationModeTransition({ + annotationsStore, + setSelectionModeActive, + clearAnnotationCursor, + clearAnnotationSelection, + clearActiveNodeChainDrawingState: clearActiveNodeChainDrawingState, + clearMoveGizmo, + clearPendingPolylineRingPromotion, + clearPendingLabelPlacementAnnotation, + }); + + const { + updatePointLabelAppearanceById, + updateNodeChainAnnotationNameById, + updateNodeChainAnnotationSegmentLineModeById, + updateAnnotationNameById, + updateAnnotationVisualizerOptionsById, + toggleNodeChainAnnotationVisibilityById, + toggleAnnotationsVisibilityByIds, + toggleNodeChainAnnotationLockById, + toggleAnnotationsLockByIds, + cyclePointLabelMetricModeByMeasurementId, + clearAllAnnotations, + clearAnnotationsByType, + clearAnnotationsByIds, + deletePolygonAnnotationById, + deleteAnnotationsByIds, + deleteSelectedAnnotations, + } = useAnnotationEntriesDomainActions({ + annotations, + distanceRelations, + nodeChainAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + selectablePointIds, + lockedMeasurementIdSet, + moveGizmoPointId: moveGizmo.pointId, + hideMeasurementsOfType, + setHideMeasurementsOfType, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + clearAnnotationSelection, + clearPointSelection, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + getOwnerGroupIdsForPointId, + computePolygonGroupDerivedDataWithCamera, + pruneMeasurementDraftSession, + pruneSelectionByRemovedIds, + updateAnnotationEntryNameById, + }); + + useSyncNodeChainEdgeRelations({ + setDistanceRelations, + nodeChainAnnotations, + defaultPolylineSegmentLineMode, + defaultDistanceRelationLabelVisibility: + distanceDefaultLabelVisibilityByKind, + defaultDirectLineLabelMode: distanceDefaultDirectLineLabelMode, + }); + + const { requestFinishLabelPlacementDraft, requestCancelLabelPlacementDraft } = + useLabelPlacementDraftActions({ + labelInputPromptPointId, + setLabelInputPromptPointId, + clearAnnotationsByIds, + }); + + const { handleNodeChainPointCreated, insertExistingNodeIntoActiveChain } = + useNodeChainPointCreation({ + annotations, + nodeChainAnnotations, + distanceRelations, + activeNodeChainAnnotationId, + activeToolType, + defaultPolylineSegmentLineMode, + polylineVerticalOffsetMeters, + setNodeChainAnnotations, + setAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + resolveDistanceRelationSourcePointId, + upsertDirectDistanceRelation, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + clearActiveNodeChainDrawingState: clearActiveNodeChainDrawingState, + clearMoveGizmo, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + orientPlaneTowardSceneCamera, + computePolygonGroupDerivedDataWithCamera, + }); + const { + confirmLabelPlacementById, + handlePointQueryPointCreated, + requestModeChange, + requestStartMeasurement, + requestCloseActiveMeasurement, + } = useAnnotationToolLifecycle({ + activeToolType, + annotations, + setAnnotations, + clearAnnotationsByIds, + labelInputPromptPointId, + requestEnterToolType, + requestFinishLabelPlacementDraft, + requestCancelLabelPlacementDraft, + handlePointAnnotationCreated, + handleLabelAnnotationCreated, + activeNodeChainAnnotationId, + doubleClickChainSourcePointId, + selectablePointIds, + selectedAnnotationId, + distanceRelations, + nodeChainAnnotations, + discardActiveMeasurementDraft, + finishDistanceMeasurementSession, + finishActivePolylineAnnotation, + closeActivePolygonAnnotation, + handleDistancePointCreated, + handleNodeChainPointCreated, + clearSharedModeExitState, + setLabelInputPromptPointId, + }); + + const candidateAnnotation = useCandidatePreviewAnnotation( + activeToolType, + activeCandidateNodeECEF + ); + const annotationsManagement = { + annotations, + annotationCandidate: candidateAnnotation, + updateAnnotationNameById, + updatePointLabelAppearanceById, + deleteAnnotationsByIds, + toggleAnnotationsLockByIds, + selectablePointIds, + pointQueryEnabled, + hasCandidateNode, + isActiveDrawMode, + distanceModeStickyToFirstPoint, + activeNodeChainAnnotationId, + nodeChainAnnotations, + selectRepresentativeNodeForMeasurementId, + getOwnerGroupIdsForEdgeRelationId, + focusAnnotationById, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + resolveDistanceRelationSourcePointId, + insertExistingNodeIntoActiveChain, + upsertDirectDistanceRelation, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + finishDistanceMeasurementSession, + setDoubleClickChainSourcePointId, + cyclePointLabelMetricModeByMeasurementId, + labelInputPromptPointId, + setLabelInputPromptPointId, + pointTemporaryMode, + setPointTemporaryMode, + pointVerticalOffsetMeters, + setPointVerticalOffsetMeters, + lastCustomPointAnnotationName, + isPolylineCandidateMode, + polylineVerticalOffsetMeters, + setPolylineVerticalOffsetMeters, + polylineSegmentLineMode, + setPolylineSegmentLineMode, + distanceCreationLineVisibility, + setDistanceCreationLineVisibilityByKind, + setDistanceModeStickyToFirstPoint, + setAnnotations, + setReferencePoint, + setDistanceRelations, + toggleAnnotationsVisibilityByIds, + handlePointQueryPointCreated, + handlePointQueryDoubleClick, + handlePointQueryBeforePointCreate, + handleAnnotationCursorMove, + visibleMeasurementsForRendering, + pointRadius, + setActiveNodeChainAnnotationId, + effectiveDistanceRelationsForRendering, + visiblePolygonAnnotationsForRendering, + cumulativeDistanceByRelationId, + showPoints, + showPointLabels, + effectiveReferenceElevation, + occlusionChecksEnabled, + effectiveDistanceToReferenceByPointId, + pointMarkerBadgeByPointId, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + markerlessPointIds, + collapsedPillPointIds, + moveGizmoOptions, + annotationCursorEnabled, + activeCandidateNodeECEF, + cursorScreenPosition, + activeCandidateNodeSurfaceNormalECEF, + activeCandidateNodeVerticalOffsetAnchorECEF, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + referencePoint, + lockedMeasurementIdSet, + deleteSelectedAnnotations, + clearAllAnnotations, + clearAnnotationsByType, + clearAnnotationsByIds, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + pointMeasureEntries, + activeToolType, + requestModeChange, + requestStartMeasurement, + requestCloseActiveMeasurement, + isInteractionActive, + doubleClickChainSourcePointId, + distanceRelations, + confirmLabelPlacementById, + updateAnnotationVisualizerOptionsById, + setPendingPolylinePromotionRingClosurePointId, + activeMeasurementId, + setNodeChainAnnotations, + }; + + const annotationEditing = useAnnotationsEditing({ + annotationsStore, + scene: cesiumScene, + annotations: annotationEntryState.annotations, + nodeChainAnnotations: annotationEntryState.nodeChainAnnotations, + referencePoint: annotationEntryState.referencePoint, + selectedAnnotationIds: annotationSelectionState.selectedAnnotationIds, + focusedNodeChainAnnotationId: focusedNodeChainAnnotationId, + activeNodeChainAnnotationId: + annotationDraftSessionState.activeNodeChainAnnotationId, + defaultDistanceRelationLabelVisibility: + annotationSettingsState.distanceDefaultLabelVisibilityByKind, + defaultDirectLineLabelMode: + annotationSettingsState.distanceDefaultDirectLineLabelMode, + visibleMeasurementsForRendering: + annotationsManagement.visibleMeasurementsForRendering, + pointRadius: annotationSettingsState.pointRadius, + setAnnotations: annotationEntryState.setAnnotations, + setDistanceRelations: annotationEntryState.setDistanceRelations, + setNodeChainAnnotations: annotationEntryState.setNodeChainAnnotations, + setReferencePoint: annotationEntryState.setReferencePoint, + setActiveNodeChainAnnotationId: + annotationDraftSessionState.setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId: + annotationDraftSessionState.setDoubleClickChainSourcePointId, + selectAnnotationById: annotationSelectionState.selectAnnotationById, + getOwnerGroupIdsForEdgeRelationId: + annotationTopologyIndex.getOwnerGroupIdsForEdgeRelationId, + selectRepresentativeNodeForMeasurementId: + annotationsManagement.selectRepresentativeNodeForMeasurementId, + }); + const annotationCollectionDomain = useAnnotationCollectionDomain({ + scene: cesiumScene, + annotations: annotationEntryState.annotations, + nodeChainAnnotations: annotationEntryState.nodeChainAnnotations, + pointMeasureEntries: pointMeasurementCollections.pointMeasureEntries, + referencePoint: annotationEntryState.referencePoint, + setAnnotations: annotationEntryState.setAnnotations, + setReferencePoint: annotationEntryState.setReferencePoint, + referencePointSyncEpsilonMeters: 0.001, + }); + + const annotationUserInteraction = useAnnotationsUserInteraction( + { + annotations: annotationEntryState.annotations, + activeToolType: annotationsManagement.activeToolType, + selectionModeActive: annotationSelectionState.selectionModeActive, + effectiveSelectModeAdditive: + annotationSelectionState.effectiveSelectModeAdditive, + selectablePointIds: pointMeasurementCollections.selectablePointIds, + moveGizmoPointId: annotationEditing.moveGizmoPointId, + isMoveGizmoDragging: annotationEditing.isMoveGizmoDragging, + pointQueryEnabled: annotationsManagement.pointQueryEnabled, + hasCandidateNode: annotationsManagement.hasCandidateNode, + isActiveDrawMode: annotationsManagement.isActiveDrawMode, + distanceModeStickyToFirstPoint: + annotationsManagement.distanceModeStickyToFirstPoint, + activeNodeChainAnnotationId: + annotationDraftSessionState.activeNodeChainAnnotationId, + nodeChainAnnotations: annotationEntryState.nodeChainAnnotations, + selectAnnotationIds: annotationSelectionState.selectAnnotationIds, + selectAnnotationById: annotationSelectionState.selectAnnotationById, + syncAnnotationCursorToExistingPoint: + annotationsManagement.syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap: + annotationsManagement.releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease: + annotationsManagement.scheduleAnnotationCursorSnapRelease, + resolveDistanceRelationSourcePointId: + annotationsManagement.resolveDistanceRelationSourcePointId, + insertExistingNodeIntoActiveChain: + annotationsManagement.insertExistingNodeIntoActiveChain, + upsertDirectDistanceRelation: + annotationsManagement.upsertDirectDistanceRelation, + closeActivePolygonAnnotation: + annotationsManagement.closeActivePolygonAnnotation, + finishActivePolylineAnnotation: + annotationsManagement.finishActivePolylineAnnotation, + finishDistanceMeasurementSession: + annotationsManagement.finishDistanceMeasurementSession, + setDoubleClickChainSourcePointId: + annotationDraftSessionState.setDoubleClickChainSourcePointId, + selectedAnnotationId: annotationSelectionState.selectedAnnotationId, + cyclePointLabelMetricModeByMeasurementId: + annotationsManagement.cyclePointLabelMetricModeByMeasurementId, + labelInputPromptPointId: annotationsManagement.labelInputPromptPointId, + setLabelInputPromptPointId: + annotationsManagement.setLabelInputPromptPointId, + setReferencePointId: annotationCollectionDomain.setReferencePointId, + pointTemporaryMode: annotationsManagement.pointTemporaryMode, + pointVerticalOffsetMeters: + annotationsManagement.pointVerticalOffsetMeters, + lastCustomPointAnnotationName: + annotationsManagement.lastCustomPointAnnotationName, + isPolylineCandidateMode: annotationsManagement.isPolylineCandidateMode, + polylineVerticalOffsetMeters: + annotationsManagement.polylineVerticalOffsetMeters, + scene: cesiumScene, + setAnnotations: annotationEntryState.setAnnotations, + handlePointQueryPointCreated: + annotationsManagement.handlePointQueryPointCreated, + handlePointQueryDoubleClick: + annotationsManagement.handlePointQueryDoubleClick, + handlePointQueryBeforePointCreate: + annotationsManagement.handlePointQueryBeforePointCreate, + handleAnnotationCursorMove: + annotationsManagement.handleAnnotationCursorMove, + }, + annotationEditing + ); + useAnnotationsInteractionLifecycle({ + annotations: annotationEntryState.annotations, + selectedAnnotationIds: annotationSelectionState.selectedAnnotationIds, + selectablePointIds: pointMeasurementCollections.selectablePointIds, + lockedMeasurementIdSet: annotationsManagement.lockedMeasurementIdSet, + selectedAnnotationId: annotationSelectionState.selectedAnnotationId, + deleteSelectedAnnotations: annotationsManagement.deleteSelectedAnnotations, + clearAnnotationsByIds: annotationsManagement.clearAnnotationsByIds, + pointMeasureEntries: pointMeasurementCollections.pointMeasureEntries, + selectAnnotationById: annotationSelectionState.selectAnnotationById, + setAnnotations: annotationEntryState.setAnnotations, + pointTemporaryMode: annotationsManagement.pointTemporaryMode, + activeToolType: annotationsManagement.activeToolType, + requestStartMeasurement: annotationsManagement.requestStartMeasurement, + setDoubleClickChainSourcePointId: + annotationDraftSessionState.setDoubleClickChainSourcePointId, + isInteractionActive: annotationsManagement.isInteractionActive, + doubleClickChainSourcePointId: + annotationDraftSessionState.doubleClickChainSourcePointId, + distanceRelations: annotationEntryState.distanceRelations, + nodeChainAnnotations: annotationEntryState.nodeChainAnnotations, + setPendingPolylinePromotionRingClosurePointId: + annotationDraftSessionState.setPendingPolylinePromotionRingClosurePointId, + activeNodeChainAnnotationId: + annotationDraftSessionState.activeNodeChainAnnotationId, + setActiveNodeChainAnnotationId: + annotationDraftSessionState.setActiveNodeChainAnnotationId, + setNodeChainAnnotations: annotationEntryState.setNodeChainAnnotations, + isPointMeasureCreateModeActive: + annotationUserInteraction.isPointMeasureCreateModeActive, + }); + useAnnotationsVisualization( + { + scene: cesiumScene, + visibleMeasurementsForRendering: + annotationsManagement.visibleMeasurementsForRendering, + effectiveDistanceRelationsForRendering: + annotationsManagement.effectiveDistanceRelationsForRendering, + visiblePolygonAnnotationsForRendering: + annotationsManagement.visiblePolygonAnnotationsForRendering, + focusedNodeChainAnnotationId: focusedNodeChainAnnotationId, + activeNodeChainAnnotationId: + annotationDraftSessionState.activeNodeChainAnnotationId, + cumulativeDistanceByRelationId: + annotationsManagement.cumulativeDistanceByRelationId, + showPoints: annotationsManagement.showPoints, + showPointLabels: annotationsManagement.showPointLabels, + effectiveReferenceElevation: + annotationsManagement.effectiveReferenceElevation, + occlusionChecksEnabled: annotationsManagement.occlusionChecksEnabled, + options, + effectiveDistanceToReferenceByPointId: + annotationsManagement.effectiveDistanceToReferenceByPointId, + pointMarkerBadgeByPointId: + annotationsManagement.pointMarkerBadgeByPointId, + hiddenPointLabelIds: annotationsManagement.hiddenPointLabelIds, + effectiveFullyHiddenPointIds: + annotationsManagement.effectiveFullyHiddenPointIds, + markerlessPointIds: annotationsManagement.markerlessPointIds, + collapsedPillPointIds: annotationsManagement.collapsedPillPointIds, + labelInputPromptPointId: annotationsManagement.labelInputPromptPointId, + moveGizmoPointId: annotationEditing.moveGizmoPointId, + moveGizmoOptions: annotationsManagement.moveGizmoOptions, + isMoveGizmoDragging: annotationEditing.isMoveGizmoDragging, + annotationCursorEnabled: annotationsManagement.annotationCursorEnabled, + activeCandidateNodeECEF: annotationsManagement.activeCandidateNodeECEF, + cursorScreenPosition: annotationsManagement.cursorScreenPosition, + activeCandidateNodeSurfaceNormalECEF: + annotationsManagement.activeCandidateNodeSurfaceNormalECEF, + activeCandidateNodeVerticalOffsetAnchorECEF: + annotationsManagement.activeCandidateNodeVerticalOffsetAnchorECEF, + activeToolType: annotationsManagement.activeToolType, + candidateConnectionPreview: + annotationsManagement.candidateConnectionPreview, + candidatePreviewDistanceMeters: + annotationsManagement.candidatePreviewDistanceMeters, + referencePoint: annotationEntryState.referencePoint, + pointRadius: annotationSettingsState.pointRadius, + annotationSelection: annotationSelectionState.annotationSelection, + rectangleSelection: annotationSelectionState.rectangleSelection, + }, + annotationUserInteraction, + annotationEditing + ); + const toolsContextValue = useMemo( + () => ({ + activeToolType: annotationsManagement.activeToolType, + requestModeChange: annotationsManagement.requestModeChange, + requestStartMeasurement: annotationsManagement.requestStartMeasurement, + requestCloseActiveMeasurement: + annotationsManagement.requestCloseActiveMeasurement, + }), + [ + annotationsManagement.activeToolType, + annotationsManagement.requestCloseActiveMeasurement, + annotationsManagement.requestModeChange, + annotationsManagement.requestStartMeasurement, + ] + ); + + const selectionContextValue = useMemo( + () => ({ + activeAnnotationId: annotationsManagement.activeMeasurementId, + ids: annotationSelectionState.selectedAnnotationIds, + mode: { + active: annotationSelectionState.selectionModeActive, + additive: annotationSelectionState.selectModeAdditive, + rectangle: annotationSelectionState.selectModeRectangle, + }, + setModeActive: annotationSelectionState.setSelectionModeActive, + setAdditiveMode: annotationSelectionState.setSelectModeAdditive, + setRectangleMode: annotationSelectionState.setSelectModeRectangle, + set: annotationSelectionState.selectAnnotationIds, + clear: annotationSelectionState.clearAnnotationSelection, + }), + [ + annotationsManagement.activeMeasurementId, + annotationSelectionState.clearAnnotationSelection, + annotationSelectionState.selectAnnotationIds, + annotationSelectionState.selectModeAdditive, + annotationSelectionState.selectModeRectangle, + annotationSelectionState.selectedAnnotationIds, + annotationSelectionState.selectionModeActive, + annotationSelectionState.setSelectModeAdditive, + annotationSelectionState.setSelectModeRectangle, + annotationSelectionState.setSelectionModeActive, + ] + ); + + const collectionContextValue = useMemo( + () => ({ + items: annotationEntryState.annotations, + byType: annotationCollectionDomain.annotationsByType, + getNavigationItems: + annotationCollectionDomain.getAnnotationsForNavigation, + getIndexByType: annotationCollectionDomain.getAnnotationIndexByType, + getOrderByType: annotationCollectionDomain.getAnnotationOrderByType, + getNextOrderByType: + annotationCollectionDomain.getNextAnnotationOrderByType, + add: annotationCollectionDomain.addAnnotation, + updateById: annotationCollectionDomain.updateAnnotationById, + updateNameById: annotationsManagement.updateAnnotationNameById, + updateVisualizerOptionsById: + annotationsManagement.updateAnnotationVisualizerOptionsById, + updatePointLabelAppearanceById: + annotationsManagement.updatePointLabelAppearanceById, + removeByIds: annotationsManagement.deleteAnnotationsByIds, + removeSelection: annotationsManagement.deleteSelectedAnnotations, + removeAll: annotationsManagement.clearAllAnnotations, + removeByType: annotationsManagement.clearAnnotationsByType, + toggleLockByIds: annotationsManagement.toggleAnnotationsLockByIds, + toggleVisibilityByIds: + annotationsManagement.toggleAnnotationsVisibilityByIds, + setReferencePointId: annotationCollectionDomain.setReferencePointId, + confirmLabelPlacementById: + annotationsManagement.confirmLabelPlacementById, + flyToById: annotationCollectionDomain.flyToAnnotationById, + focusById: annotationsManagement.focusAnnotationById, + flyToAll: annotationCollectionDomain.flyToAllAnnotations, + }), + [ + annotationEntryState.annotations, + annotationCollectionDomain.addAnnotation, + annotationCollectionDomain.annotationsByType, + annotationCollectionDomain.flyToAllAnnotations, + annotationCollectionDomain.flyToAnnotationById, + annotationCollectionDomain.getAnnotationIndexByType, + annotationCollectionDomain.getAnnotationOrderByType, + annotationCollectionDomain.getAnnotationsForNavigation, + annotationCollectionDomain.getNextAnnotationOrderByType, + annotationCollectionDomain.setReferencePointId, + annotationCollectionDomain.updateAnnotationById, + annotationsManagement.clearAllAnnotations, + annotationsManagement.clearAnnotationsByType, + annotationsManagement.confirmLabelPlacementById, + annotationsManagement.deleteAnnotationsByIds, + annotationsManagement.deleteSelectedAnnotations, + annotationsManagement.focusAnnotationById, + annotationsManagement.toggleAnnotationsLockByIds, + annotationsManagement.toggleAnnotationsVisibilityByIds, + annotationsManagement.updateAnnotationNameById, + annotationsManagement.updateAnnotationVisualizerOptionsById, + annotationsManagement.updatePointLabelAppearanceById, + ] + ); + + const editingContextValue = useMemo( + () => ({ + activeTarget: annotationEditing.activeEditTarget, + requestStart: annotationEditing.requestStartEdit, + requestStop: annotationEditing.requestStopEdit, + requestUpdateTarget: annotationEditing.requestUpdateEditTarget, + }), + [ + annotationEditing.activeEditTarget, + annotationEditing.requestStartEdit, + annotationEditing.requestStopEdit, + annotationEditing.requestUpdateEditTarget, + ] + ); + + const settingsContextValue = useMemo( + () => ({ + point: { + verticalOffsetMeters: annotationsManagement.pointVerticalOffsetMeters, + setVerticalOffsetMeters: + annotationsManagement.setPointVerticalOffsetMeters, + temporaryMode: annotationsManagement.pointTemporaryMode, + setTemporaryMode: annotationsManagement.setPointTemporaryMode, + }, + distance: { + stickyToFirstPoint: + annotationsManagement.distanceModeStickyToFirstPoint, + setStickyToFirstPoint: + annotationsManagement.setDistanceModeStickyToFirstPoint, + creationLineVisibility: + annotationsManagement.distanceCreationLineVisibility, + setCreationLineVisibilityByKind: + annotationsManagement.setDistanceCreationLineVisibilityByKind, + }, + polyline: { + verticalOffsetMeters: + annotationsManagement.polylineVerticalOffsetMeters, + setVerticalOffsetMeters: + annotationsManagement.setPolylineVerticalOffsetMeters, + segmentLineMode: annotationsManagement.polylineSegmentLineMode, + setSegmentLineMode: annotationsManagement.setPolylineSegmentLineMode, + }, + }), + [ + annotationsManagement.distanceCreationLineVisibility, + annotationsManagement.distanceModeStickyToFirstPoint, + annotationsManagement.pointVerticalOffsetMeters, + annotationsManagement.pointTemporaryMode, + annotationsManagement.polylineSegmentLineMode, + annotationsManagement.polylineVerticalOffsetMeters, + annotationsManagement.setDistanceCreationLineVisibilityByKind, + annotationsManagement.setDistanceModeStickyToFirstPoint, + annotationsManagement.setPointVerticalOffsetMeters, + annotationsManagement.setPointTemporaryMode, + annotationsManagement.setPolylineSegmentLineMode, + annotationsManagement.setPolylineVerticalOffsetMeters, + ] + ); + + const annotationsStoreSnapshot = useMemo( + () => ({ + tools: toolsContextValue, + selection: selectionContextValue, + annotations: collectionContextValue, + edit: editingContextValue, + settings: settingsContextValue, + }), + [ + collectionContextValue, + editingContextValue, + selectionContextValue, + settingsContextValue, + toolsContextValue, + ] + ); + + useLayoutEffect(() => { + annotationsStore.setState((currentState) => + Object.is(currentState.tools, annotationsStoreSnapshot.tools) && + Object.is(currentState.selection, annotationsStoreSnapshot.selection) && + Object.is( + currentState.annotations, + annotationsStoreSnapshot.annotations + ) && + Object.is(currentState.edit, annotationsStoreSnapshot.edit) && + Object.is(currentState.settings, annotationsStoreSnapshot.settings) + ? currentState + : { + ...currentState, + ...annotationsStoreSnapshot, + } + ); + }, [annotationsStore, annotationsStoreSnapshot]); + + useLayoutEffect(() => { + annotationsStore.setState((currentState) => + Object.is( + currentState.candidateAnnotation, + annotationsManagement.annotationCandidate + ) + ? currentState + : { + ...currentState, + candidateAnnotation: annotationsManagement.annotationCandidate, + } + ); + }, [annotationsManagement.annotationCandidate, annotationsStore]); + + return ( + + {children} + + ); +}; + +const useAnnotationsStore = (hookName: string): AnnotationsStore => + useRequiredAnnotationsStore(useContext(AnnotationsStoreContext), hookName); + +const REFERENCE_POINT_SYNC_EPSILON_METERS = 0.001; + +export const useAnnotationTools = (): AnnotationToolsContextType => { + const annotationsStore = useAnnotationsStore("useAnnotationTools"); + + return useStoreSelector(annotationsStore, (state) => state.tools); +}; + +export const useAnnotationSelectionState = + (): AnnotationSelectionContextType => { + const annotationsStore = useAnnotationsStore("useAnnotationSelectionState"); + + return useStoreSelector(annotationsStore, (state) => state.selection); + }; + +export const useAnnotationCollection = (): AnnotationCollectionContextType => { + const annotationsStore = useAnnotationsStore("useAnnotationCollection"); + + return useStoreSelector(annotationsStore, (state) => state.annotations); +}; + +export const useAnnotationEditingState = (): AnnotationEditingContextType => { + const annotationsStore = useAnnotationsStore("useAnnotationEditingState"); + + return useStoreSelector(annotationsStore, (state) => state.edit); +}; + +export const useAnnotationSettings = (): AnnotationSettingsContextType => { + const annotationsStore = useAnnotationsStore("useAnnotationSettings"); + + return useStoreSelector(annotationsStore, (state) => state.settings); +}; + +export const useCandidateAnnotation = (): AnnotationEntry | null => { + const annotationsStore = useAnnotationsStore("useCandidateAnnotation"); + + return useStoreSelector( + annotationsStore, + (state) => state.candidateAnnotation + ); +}; + +export const usePendingLabelPlacementTargetId = (): string | null => { + const annotationsStore = useAnnotationsStore( + "usePendingLabelPlacementTargetId" + ); + + return useStoreSelector( + annotationsStore, + (state) => state.pendingLabelPlacementAnnotationId + ); +}; + +export const useReferencePoint = (): Cartesian3 | null => { + const annotationsStore = useAnnotationsStore("useReferencePoint"); + + return useStoreSelector(annotationsStore, (state) => state.referencePoint); +}; + +export type AnnotationDistanceReadModel = { + referencePoint: Cartesian3 | null; + hasPreviewAnchor: boolean; + distanceRelations: PointDistanceRelation[]; +}; + +export const useDistanceAnnotationReadModel = + (): AnnotationDistanceReadModel => { + const annotationsStore = useAnnotationsStore( + "useDistanceAnnotationReadModel" + ); + const referencePoint = useStoreSelector( + annotationsStore, + (state) => state.referencePoint + ); + const distanceRelations = useStoreSelector( + annotationsStore, + (state) => state.distanceRelations + ); + const annotationEntries = useStoreSelector( + annotationsStore, + (state) => state.annotationEntries + ); + const activeToolType = useStoreSelector( + annotationsStore, + (state) => state.tools.activeToolType + ); + const openChainPointId = useStoreSelector( + annotationsStore, + (state) => state.openChainPointId + ); + const distanceModeStickyToFirstPoint = useStoreSelector( + annotationsStore, + (state) => state.settingsState.distance.stickyToFirstPoint + ); + + const pointEntries = useMemo( + () => annotationEntries.filter(isPointAnnotationEntry), + [annotationEntries] + ); + const referencePointMeasurementId = useMemo(() => { + if (!referencePoint) { + return null; + } + + const referenceMeasurement = + pointEntries.find( + (pointEntry) => + Cartesian3.distance(pointEntry.geometryECEF, referencePoint) <= + REFERENCE_POINT_SYNC_EPSILON_METERS + ) ?? null; + + return referenceMeasurement?.id ?? null; + }, [pointEntries, referencePoint]); + const pointIdSet = useMemo( + () => new Set(pointEntries.map((pointEntry) => pointEntry.id)), + [pointEntries] + ); + const hasPreviewAnchor = useMemo(() => { + if (activeToolType !== ANNOTATION_TYPE_DISTANCE) { + return false; + } + + if (distanceModeStickyToFirstPoint && referencePointMeasurementId) { + return true; + } + + return Boolean(openChainPointId && pointIdSet.has(openChainPointId)); + }, [ + activeToolType, + distanceModeStickyToFirstPoint, + openChainPointId, + pointIdSet, + referencePointMeasurementId, + ]); + + return useMemo( + () => ({ + referencePoint, + hasPreviewAnchor, + distanceRelations, + }), + [distanceRelations, hasPreviewAnchor, referencePoint] + ); + }; + +export const useNodeChainAnnotations = (): NodeChainAnnotation[] => { + const annotationsStore = useAnnotationsStore("useNodeChainAnnotations"); + + return useStoreSelector( + annotationsStore, + (state) => state.nodeChainAnnotations + ); +}; + +export type AnnotationNodeChainReadModel = { + measurements: NodeChainAnnotation[]; + polylineMeasurements: NodeChainAnnotation[]; + groundPolygons: NodeChainAnnotation[]; + planarPolygons: NodeChainAnnotation[]; + verticalPolygons: NodeChainAnnotation[]; + polylinePaths: DerivedPolylinePath[]; + focusedMeasurementId: string | null; + activeMeasurementId: string | null; +}; + +export const useNodeChainAnnotationReadModel = + (): AnnotationNodeChainReadModel => { + const annotationsStore = useAnnotationsStore( + "useNodeChainAnnotationReadModel" + ); + const nodeChainAnnotations = useStoreSelector( + annotationsStore, + (state) => state.nodeChainAnnotations + ); + const activeMeasurementId = useStoreSelector( + annotationsStore, + (state) => state.activeNodeChainAnnotationId + ); + const annotationEntries = useStoreSelector( + annotationsStore, + (state) => state.annotationEntries + ); + const selectedAnnotationIds = useStoreSelector( + annotationsStore, + (state) => state.selection.ids + ); + const defaultPolylineVerticalOffsetMeters = useStoreSelector( + annotationsStore, + (state) => state.settingsState.polyline.defaultVerticalOffsetMeters + ); + + const nodeChainAnnotationsByType = useMemo(() => { + const byType = new Map(); + byType.set(ANNOTATION_TYPE_POLYLINE, []); + byType.set(ANNOTATION_TYPE_AREA_GROUND, []); + byType.set(ANNOTATION_TYPE_AREA_PLANAR, []); + byType.set(ANNOTATION_TYPE_AREA_VERTICAL, []); + + nodeChainAnnotations.forEach((measurement) => { + const typedBucket = byType.get(measurement.type); + if (typedBucket) { + typedBucket.push(measurement); + } + }); + + return byType; + }, [nodeChainAnnotations]); + const polylinePaths = useMemo( + () => + buildDerivedPolylinePaths({ + annotations: annotationEntries, + nodeChainAnnotations: nodeChainAnnotations, + defaultVerticalOffsetMeters: defaultPolylineVerticalOffsetMeters, + useOffsetAnchors: true, + }), + [ + annotationEntries, + defaultPolylineVerticalOffsetMeters, + nodeChainAnnotations, + ] + ); + const focusedMeasurementId = useMemo(() => { + for ( + let index = selectedAnnotationIds.length - 1; + index >= 0; + index -= 1 + ) { + const selectedAnnotationId = selectedAnnotationIds[index]; + if (!selectedAnnotationId) { + continue; + } + + const focusedMeasurement = + nodeChainAnnotations.find((measurement) => + measurement.nodeIds.includes(selectedAnnotationId) + ) ?? null; + if (focusedMeasurement) { + return focusedMeasurement.id; + } + } + + return activeMeasurementId; + }, [activeMeasurementId, nodeChainAnnotations, selectedAnnotationIds]); + + return useMemo( + () => ({ + measurements: nodeChainAnnotations, + polylineMeasurements: + nodeChainAnnotationsByType.get(ANNOTATION_TYPE_POLYLINE) ?? [], + groundPolygons: + nodeChainAnnotationsByType.get(ANNOTATION_TYPE_AREA_GROUND) ?? [], + planarPolygons: + nodeChainAnnotationsByType.get(ANNOTATION_TYPE_AREA_PLANAR) ?? [], + verticalPolygons: + nodeChainAnnotationsByType.get(ANNOTATION_TYPE_AREA_VERTICAL) ?? [], + polylinePaths, + focusedMeasurementId, + activeMeasurementId, + }), + [ + activeMeasurementId, + focusedMeasurementId, + nodeChainAnnotationsByType, + nodeChainAnnotations, + polylinePaths, + ] + ); + }; +export type AnnotationsOptions = { + distance?: { + stickyToFirstPoint?: boolean; + creationLineVisibility?: Partial>; + defaultLabelVisibilityByKind?: DistanceRelationLabelVisibilityByKind; + defaultDirectLineLabelMode?: DirectLineLabelMode; + }; + pointQueries?: { + enabled?: boolean; + radius?: number; + verticalOffsetMeters?: number; + heightOffset?: number; + temporaryMode?: boolean; + }; + cartographicCRS?: "string"; + initialToolType?: AnnotationToolType; + initialPersistenceState?: AnnotationPersistenceEnvelopeV2 | null; + onPersistenceStateChange?: (state: AnnotationPersistenceEnvelopeV2) => void; + labels?: PointLabelLayoutConfigOverrides; + moveGizmo?: { + markerSizeScale?: number; + labelDistanceScale?: number; + }; +}; + +const defaultOptions: AnnotationsOptions = { + initialToolType: ANNOTATION_TYPE_POINT, +}; + +const defaultPointQueryOptions: AnnotationsOptions["pointQueries"] = { + enabled: true, + radius: 1, + verticalOffsetMeters: 0, + heightOffset: 1.5, + temporaryMode: false, +}; +const defaultMoveGizmoOptions: NonNullable = { + markerSizeScale: 1, + labelDistanceScale: 1, +}; +const PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS = 0.2; +const PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG = 150; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/annotationCreatePayload.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/annotationCreatePayload.ts new file mode 100644 index 0000000000..6aa0e9722a --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/annotationCreatePayload.ts @@ -0,0 +1,8 @@ +import type { BaseAnnotationEntry } from "@carma-mapping/annotations/core"; + +export type AnnotationCreatePayload< + TMeasurement extends BaseAnnotationEntry = BaseAnnotationEntry +> = Omit & { + id?: string; + timestamp?: number; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/syncNodeChainEdgeDistanceRelations.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/syncNodeChainEdgeDistanceRelations.ts new file mode 100644 index 0000000000..9ff72b58a3 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/syncNodeChainEdgeDistanceRelations.ts @@ -0,0 +1,148 @@ +import { + ANNOTATION_TYPE_POLYLINE, + LINEAR_SEGMENT_LINE_MODE_COMPONENTS, + LINEAR_SEGMENT_LINE_MODE_DIRECT, + areDistanceRelationsEquivalent, + getDistanceRelationId, + getMeasurementEdgeId, + withDistanceRelationEdgeId, + type LinearSegmentLineMode, + type NodeChainAnnotation, + type PointDistanceRelation, + type ReferenceLineLabelKind, + type DirectLineLabelMode, +} from "@carma-mapping/annotations/core"; + +type SyncNodeChainEdgeDistanceRelationsParams = { + previousRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + defaultPolylineSegmentLineMode: LinearSegmentLineMode; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; + defaultDirectLineLabelMode: DirectLineLabelMode; +}; + +export const syncNodeChainEdgeDistanceRelations = ({ + previousRelations, + nodeChainAnnotations, + defaultPolylineSegmentLineMode, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, +}: SyncNodeChainEdgeDistanceRelationsParams): PointDistanceRelation[] => { + const desiredById = new Map< + string, + { + groupId: string; + pointAId: string; + pointBId: string; + showDirectLine: boolean; + showComponentLines: boolean; + } + >(); + + nodeChainAnnotations.forEach((group) => { + if (group.nodeIds.length < 2) return; + const isPolylineGroup = group.type === ANNOTATION_TYPE_POLYLINE; + const segmentLineMode = + group.segmentLineMode ?? + (isPolylineGroup + ? defaultPolylineSegmentLineMode + : LINEAR_SEGMENT_LINE_MODE_DIRECT); + const showDirectLine = isPolylineGroup + ? segmentLineMode === LINEAR_SEGMENT_LINE_MODE_DIRECT + : true; + const showComponentLines = isPolylineGroup + ? segmentLineMode === LINEAR_SEGMENT_LINE_MODE_COMPONENTS + : false; + const orderedVertices = group.nodeIds; + for (let index = 0; index < orderedVertices.length - 1; index += 1) { + const pointAId = orderedVertices[index]; + const pointBId = orderedVertices[index + 1]; + if (!pointAId || !pointBId) continue; + const relationId = getDistanceRelationId(pointAId, pointBId); + desiredById.set(relationId, { + groupId: group.id, + pointAId, + pointBId, + showDirectLine, + showComponentLines, + }); + } + if (group.closed && orderedVertices.length >= 3) { + const first = orderedVertices[0]; + const last = orderedVertices[orderedVertices.length - 1]; + if (first && last) { + const relationId = getDistanceRelationId(last, first); + desiredById.set(relationId, { + groupId: group.id, + pointAId: last, + pointBId: first, + showDirectLine, + showComponentLines, + }); + } + } + }); + + const next: PointDistanceRelation[] = []; + const handledIds = new Set(); + + previousRelations.forEach((relation) => { + const desired = desiredById.get(relation.id); + if (!desired) { + if (!relation.polygonGroupId) { + next.push(relation); + } + return; + } + + handledIds.add(relation.id); + next.push({ + ...withDistanceRelationEdgeId(relation), + edgeId: getMeasurementEdgeId(desired.pointAId, desired.pointBId), + pointAId: desired.pointAId, + pointBId: desired.pointBId, + anchorPointId: desired.pointAId, + polygonGroupId: desired.groupId, + showDirectLine: desired.showDirectLine, + showVerticalLine: desired.showComponentLines, + showHorizontalLine: desired.showComponentLines, + showComponentLines: desired.showComponentLines, + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + }, + directLabelMode: relation.directLabelMode ?? defaultDirectLineLabelMode, + }); + }); + + desiredById.forEach((desired, relationId) => { + if (handledIds.has(relationId)) return; + next.push({ + id: relationId, + edgeId: getMeasurementEdgeId(desired.pointAId, desired.pointBId), + pointAId: desired.pointAId, + pointBId: desired.pointBId, + anchorPointId: desired.pointAId, + polygonGroupId: desired.groupId, + showDirectLine: desired.showDirectLine, + showVerticalLine: desired.showComponentLines, + showHorizontalLine: desired.showComponentLines, + showComponentLines: desired.showComponentLines, + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + }, + directLabelMode: defaultDirectLineLabelMode, + }); + }); + + return areDistanceRelationsEquivalent( + previousRelations, + next, + defaultDistanceRelationLabelVisibility + ) + ? previousRelations + : next; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionDomain.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionDomain.ts new file mode 100644 index 0000000000..61c3687bbe --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionDomain.ts @@ -0,0 +1,88 @@ +import type { Dispatch, SetStateAction } from "react"; +import type { Scene } from "@carma/cesium"; +import type { + AnnotationCollection, + NodeChainAnnotation, + PointMeasurementEntry, +} from "@carma-mapping/annotations/core"; +import { isPointAnnotationEntry } from "@carma-mapping/annotations/core"; + +import { useAnnotationEntryActions } from "./useAnnotationEntryActions"; +import { useAnnotationsCollectionState } from "./useAnnotationsCollectionState"; +import { useAnnotationFlyToActions } from "../interaction/navigation/useAnnotationFlyToActions"; +import { useReferencePointState } from "../interaction/useReferencePointState"; + +type UseAnnotationCollectionDomainParams = { + scene: Scene; + annotations: AnnotationCollection; + nodeChainAnnotations: NodeChainAnnotation[]; + pointMeasureEntries: PointMeasurementEntry[]; + referencePoint: import("@carma/cesium").Cartesian3 | null; + setAnnotations: Dispatch>; + setReferencePoint: Dispatch< + SetStateAction + >; + referencePointSyncEpsilonMeters: number; +}; + +export const useAnnotationCollectionDomain = ({ + scene, + annotations, + nodeChainAnnotations, + pointMeasureEntries, + referencePoint, + setAnnotations, + setReferencePoint, + referencePointSyncEpsilonMeters, +}: UseAnnotationCollectionDomainParams) => { + const pointEntries = annotations.filter( + (annotation): annotation is PointMeasurementEntry => + isPointAnnotationEntry(annotation) + ); + + const { + annotationsByType, + getAnnotationsForNavigation, + getAnnotationIndexByType, + getAnnotationOrderByType, + getNextAnnotationOrderByType, + } = useAnnotationsCollectionState( + annotations, + pointEntries, + pointMeasureEntries, + { + nodeChainAnnotations, + } + ); + + const { addAnnotation, updateAnnotationById } = useAnnotationEntryActions({ + setAnnotations, + }); + + const { flyToAnnotationById, flyToAllAnnotations } = + useAnnotationFlyToActions({ + scene, + annotations, + nodeChainAnnotations, + }); + + const { setReferencePointId } = useReferencePointState({ + pointEntries, + referencePoint, + setReferencePoint, + referencePointSyncEpsilonMeters, + }); + + return { + annotationsByType, + getAnnotationsForNavigation, + getAnnotationIndexByType, + getAnnotationOrderByType, + getNextAnnotationOrderByType, + addAnnotation, + updateAnnotationById, + flyToAnnotationById, + flyToAllAnnotations, + setReferencePointId, + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationCollectionSelectors.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionSelectors.ts similarity index 59% rename from libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationCollectionSelectors.ts rename to libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionSelectors.ts index 436513a640..b9f28fb250 100644 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationCollectionSelectors.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationCollectionSelectors.ts @@ -1,44 +1,47 @@ import { useCallback } from "react"; -import type { - AnnotationListType, - AnnotationsContextType, -} from "../AnnotationsContext"; -import type { BaseAnnotationEntry } from "../../types/annotationEntry"; +import type { BaseAnnotationEntry } from "@carma-mapping/annotations/core"; type AnnotationWithId = BaseAnnotationEntry; type UseMeasurementCollectionSelectorsParams< TMode extends string, - TMeasurement extends AnnotationWithId + TMeasurement extends AnnotationWithId, + TListType extends string > = { - annotationsByType: ( - type: AnnotationListType - ) => ReadonlyArray; - navigationTypes: ReadonlyArray>; + annotationsByType: (type: TListType) => ReadonlyArray; + navigationTypes: ReadonlyArray; }; type AnnotationCollectionSelectors< TMode extends string, - TMeasurement extends AnnotationWithId -> = Pick< - AnnotationsContextType, - | "getAnnotationsForNavigation" - | "getAnnotationIndexByType" - | "getAnnotationOrderByType" - | "getNextAnnotationOrderByType" ->; + TMeasurement extends AnnotationWithId, + TListType extends string +> = { + getAnnotationsForNavigation: () => TMeasurement[]; + getAnnotationIndexByType: ( + type: TListType, + id: string | null | undefined + ) => number; + getAnnotationOrderByType: ( + type: TListType, + id: string | null | undefined + ) => number | null; + getNextAnnotationOrderByType: (type: TListType) => number; +}; export const useAnnotationCollectionSelectors = < TMode extends string, - TMeasurement extends AnnotationWithId + TMeasurement extends AnnotationWithId, + TListType extends string = TMode >({ annotationsByType, navigationTypes, }: UseMeasurementCollectionSelectorsParams< TMode, - TMeasurement ->): AnnotationCollectionSelectors => { + TMeasurement, + TListType +>): AnnotationCollectionSelectors => { const getAnnotationsForNavigation = useCallback(() => { const seenIds = new Set(); const result: TMeasurement[] = []; @@ -55,7 +58,7 @@ export const useAnnotationCollectionSelectors = < }, [annotationsByType, navigationTypes]); const getAnnotationIndexByType = useCallback( - (type: AnnotationListType, id: string | null | undefined) => { + (type: TListType, id: string | null | undefined) => { if (!id) return -1; return annotationsByType(type).findIndex( (measurement) => measurement.id === id @@ -65,7 +68,7 @@ export const useAnnotationCollectionSelectors = < ); const getAnnotationOrderByType = useCallback( - (type: AnnotationListType, id: string | null | undefined) => { + (type: TListType, id: string | null | undefined) => { const index = getAnnotationIndexByType(type, id); return index >= 0 ? index + 1 : null; }, @@ -73,7 +76,7 @@ export const useAnnotationCollectionSelectors = < ); const getNextAnnotationOrderByType = useCallback( - (type: AnnotationListType) => annotationsByType(type).length + 1, + (type: TListType) => annotationsByType(type).length + 1, [annotationsByType] ); diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationDeleteAndCleanupActions.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationDeleteAndCleanupActions.ts new file mode 100644 index 0000000000..33d9eb7272 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationDeleteAndCleanupActions.ts @@ -0,0 +1,301 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import { + ANNOTATION_TYPE_DISTANCE, + buildEdgeRelationIdsForPolygon, + getDistanceRelationId, + getPointPositionMap, + isPointAnnotationEntry, + type AnnotationCollection, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationDeleteAndCleanupActionsParams = { + annotations: AnnotationCollection; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + selectedAnnotationId: string | null; + selectedAnnotationIds: string[]; + selectableAnnotationIds: ReadonlySet; + lockedAnnotationIdSet: ReadonlySet; + moveGizmoPointId: string | null; + setAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + clearMoveGizmo: () => void; + getOwnerGroupIdsForPointId: (pointId: string) => readonly string[]; + computePolygonGroupDerivedDataWithCamera: ( + group: NodeChainAnnotation, + pointById: Map + ) => NodeChainAnnotation; + pruneMeasurementDraftSession: ( + removedPointIds: ReadonlySet, + removedRelationIds: ReadonlySet + ) => void; + pruneSelectionByRemovedIds: (removedIds: ReadonlySet) => void; +}; + +export const useAnnotationDeleteAndCleanupActions = ({ + annotations, + distanceRelations, + nodeChainAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + selectableAnnotationIds, + lockedAnnotationIdSet, + moveGizmoPointId, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + clearMoveGizmo, + getOwnerGroupIdsForPointId, + computePolygonGroupDerivedDataWithCamera, + pruneMeasurementDraftSession, + pruneSelectionByRemovedIds, +}: UseAnnotationDeleteAndCleanupActionsParams) => { + const clearAnnotationsByIds = useCallback( + (ids: string[]) => { + const pointById = new Map( + annotations + .filter(isPointAnnotationEntry) + .map((annotation) => [annotation.id, annotation] as const) + ); + + const requestedIdSet = new Set(ids); + const protectedPolygonNodeIdSet = new Set(); + nodeChainAnnotations.forEach((group) => { + if (!group.closed || group.nodeIds.length > 3) { + return; + } + const nodeIds = group.nodeIds.filter((nodeId): nodeId is string => + Boolean(nodeId) + ); + if (nodeIds.length === 0) { + return; + } + const includesAnyNode = nodeIds.some((nodeId) => + requestedIdSet.has(nodeId) + ); + if (!includesAnyNode) { + return; + } + const includesAllNodes = nodeIds.every((nodeId) => + requestedIdSet.has(nodeId) + ); + if (includesAllNodes) { + return; + } + nodeIds.forEach((nodeId) => { + protectedPolygonNodeIdSet.add(nodeId); + }); + }); + + const idsToDelete = new Set( + ids.filter((id) => !protectedPolygonNodeIdSet.has(id)) + ); + if (idsToDelete.size === 0) { + return; + } + let remainingRelations = [...distanceRelations]; + + let expanded = true; + while (expanded) { + expanded = false; + + const nextRemainingRelations: PointDistanceRelation[] = []; + const removedRelations: PointDistanceRelation[] = []; + remainingRelations.forEach((relation) => { + if ( + idsToDelete.has(relation.pointAId) || + idsToDelete.has(relation.pointBId) + ) { + removedRelations.push(relation); + return; + } + nextRemainingRelations.push(relation); + }); + remainingRelations = nextRemainingRelations; + + removedRelations.forEach((relation) => { + [relation.pointAId, relation.pointBId].forEach((pointId) => { + if (idsToDelete.has(pointId)) return; + const point = pointById.get(pointId); + if (!point) return; + if (point.type !== ANNOTATION_TYPE_DISTANCE) return; + + const stillReferencedByRemainingRelation = remainingRelations.some( + (candidate) => + candidate.pointAId === pointId || candidate.pointBId === pointId + ); + if (stillReferencedByRemainingRelation) return; + const belongsToNodeChainAnnotation = + getOwnerGroupIdsForPointId(pointId).length > 0; + if (belongsToNodeChainAnnotation) return; + + idsToDelete.add(pointId); + expanded = true; + }); + }); + } + + setAnnotations((prev) => + prev.filter((annotation) => !idsToDelete.has(annotation.id)) + ); + setDistanceRelations(remainingRelations); + pruneSelectionByRemovedIds(idsToDelete); + const removedRelationIds = new Set( + distanceRelations + .filter( + (relation) => + !remainingRelations.some( + (remainingRelation) => remainingRelation.id === relation.id + ) + ) + .map((relation) => relation.id) + ); + pruneMeasurementDraftSession(idsToDelete, removedRelationIds); + setDoubleClickChainSourcePointId((prev) => + prev && idsToDelete.has(prev) ? null : prev + ); + if (moveGizmoPointId && idsToDelete.has(moveGizmoPointId)) { + clearMoveGizmo(); + } + + const remainingPointById = getPointPositionMap(annotations); + idsToDelete.forEach((id) => remainingPointById.delete(id)); + setNodeChainAnnotations((prev) => + prev.flatMap((group) => { + const nextNodeIds = group.nodeIds.filter( + (nodeId) => !idsToDelete.has(nodeId) + ); + if (nextNodeIds.length < 3) { + return []; + } + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + group.closed, + getDistanceRelationId + ); + return [ + computePolygonGroupDerivedDataWithCamera( + { + ...group, + nodeIds: nextNodeIds, + edgeRelationIds: nextEdgeRelationIds, + }, + remainingPointById + ), + ]; + }) + ); + setActiveNodeChainAnnotationId((prev) => { + if (!prev) return prev; + const activeGroup = nodeChainAnnotations.find( + (group) => group.id === prev + ); + if (!activeGroup) return null; + return activeGroup.nodeIds.some((id) => idsToDelete.has(id)) + ? null + : prev; + }); + }, + [ + annotations, + clearMoveGizmo, + computePolygonGroupDerivedDataWithCamera, + distanceRelations, + getOwnerGroupIdsForPointId, + moveGizmoPointId, + nodeChainAnnotations, + pruneMeasurementDraftSession, + pruneSelectionByRemovedIds, + setActiveNodeChainAnnotationId, + setAnnotations, + setDistanceRelations, + setDoubleClickChainSourcePointId, + setNodeChainAnnotations, + ] + ); + + const deletePolygonAnnotationById = useCallback( + (id: string) => { + const group = nodeChainAnnotations.find((entry) => entry.id === id); + if (!group) { + return; + } + + const nodeIds = group.nodeIds.filter((nodeId): nodeId is string => + Boolean(nodeId) + ); + if (nodeIds.length === 0) { + return; + } + + clearAnnotationsByIds(nodeIds); + }, + [clearAnnotationsByIds, nodeChainAnnotations] + ); + + const deleteAnnotationsByIds = useCallback( + (ids: string[]) => { + if (ids.length === 0) { + return; + } + + const requestedIdSet = new Set(ids); + const targetedNodeChainAnnotations = nodeChainAnnotations.filter( + (group) => requestedIdSet.has(group.id) + ); + const expandedAnnotationIdSet = new Set( + ids.filter( + (id) => !targetedNodeChainAnnotations.some((group) => group.id === id) + ) + ); + + targetedNodeChainAnnotations.forEach((group) => { + group.nodeIds.forEach((nodeId) => { + expandedAnnotationIdSet.add(nodeId); + }); + }); + + clearAnnotationsByIds([...expandedAnnotationIdSet]); + }, + [clearAnnotationsByIds, nodeChainAnnotations] + ); + + const deleteSelectedAnnotations = useCallback(() => { + const selectedIds = selectedAnnotationIds.filter( + (id) => selectableAnnotationIds.has(id) && !lockedAnnotationIdSet.has(id) + ); + if (selectedIds.length > 0) { + clearAnnotationsByIds(selectedIds); + return; + } + if ( + selectedAnnotationId && + selectableAnnotationIds.has(selectedAnnotationId) && + !lockedAnnotationIdSet.has(selectedAnnotationId) + ) { + clearAnnotationsByIds([selectedAnnotationId]); + } + }, [ + clearAnnotationsByIds, + lockedAnnotationIdSet, + selectableAnnotationIds, + selectedAnnotationId, + selectedAnnotationIds, + ]); + + return { + clearAnnotationsByIds, + deletePolygonAnnotationById, + deleteAnnotationsByIds, + deleteSelectedAnnotations, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntriesDomainActions.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntriesDomainActions.ts new file mode 100644 index 0000000000..e55167289e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntriesDomainActions.ts @@ -0,0 +1,143 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { + applyLabelAppearance, + isPointMeasurementEntry, + normalizeLabelAppearance, + type AnnotationCollection, + type AnnotationEntry, + type AnnotationLabelAppearance, + type AnnotationMode, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import { useAnnotationEntryMutations } from "./useAnnotationEntryMutations"; +import { useAnnotationPresentationActions } from "./useAnnotationPresentationActions"; +import { useAnnotationResetActions } from "./useAnnotationResetActions"; +import { useAnnotationDeleteAndCleanupActions } from "./useAnnotationDeleteAndCleanupActions"; + +type Params = { + annotations: AnnotationCollection; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + selectedAnnotationId: string | null; + selectedAnnotationIds: string[]; + selectablePointIds: ReadonlySet; + lockedMeasurementIdSet: ReadonlySet; + moveGizmoPointId: string | null; + hideMeasurementsOfType: Set; + setHideMeasurementsOfType: Dispatch>>; + setAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + clearAnnotationSelection: () => void; + clearPointSelection: () => void; + clearActiveNodeChainDrawingState: () => void; + clearMoveGizmo: () => void; + getOwnerGroupIdsForPointId: (pointId: string) => readonly string[]; + computePolygonGroupDerivedDataWithCamera: ( + group: NodeChainAnnotation, + pointById: Map + ) => NodeChainAnnotation; + pruneMeasurementDraftSession: ( + removedPointIds: ReadonlySet, + removedRelationIds?: ReadonlySet + ) => void; + pruneSelectionByRemovedIds: (removedIds: ReadonlySet) => void; + updateAnnotationEntryNameById: (id: string, name: string) => void; +}; + +export const useAnnotationEntriesDomainActions = ({ + annotations, + distanceRelations, + nodeChainAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + selectablePointIds, + lockedMeasurementIdSet, + moveGizmoPointId, + hideMeasurementsOfType, + setHideMeasurementsOfType, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + clearAnnotationSelection, + clearPointSelection, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + getOwnerGroupIdsForPointId, + computePolygonGroupDerivedDataWithCamera, + pruneMeasurementDraftSession, + pruneSelectionByRemovedIds, + updateAnnotationEntryNameById, +}: Params) => { + const { updateLabelAppearanceById: updatePointLabelAppearanceById } = + useAnnotationEntryMutations({ + setAnnotations, + isLabelAppearanceTarget: isPointMeasurementEntry, + getLabelAppearance: (measurement) => + isPointMeasurementEntry(measurement) + ? measurement.labelAppearance + : undefined, + applyLabelAppearance: (measurement, appearance) => { + if (!isPointMeasurementEntry(measurement)) { + return measurement; + } + return applyLabelAppearance(measurement, appearance); + }, + normalizeLabelAppearance, + }); + + const presentation = useAnnotationPresentationActions({ + annotations, + nodeChainAnnotations, + setAnnotations, + setNodeChainAnnotations, + updateAnnotationEntryNameById, + }); + + const reset = useAnnotationResetActions({ + hideAnnotationsOfType: hideMeasurementsOfType, + setHideAnnotationsOfType: setHideMeasurementsOfType, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + clearAnnotationSelection, + clearNodeSelection: clearPointSelection, + clearActiveNodeChainDrawingState: clearActiveNodeChainDrawingState, + clearMoveGizmo, + }); + + const cleanup = useAnnotationDeleteAndCleanupActions({ + annotations, + distanceRelations, + nodeChainAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + selectableAnnotationIds: selectablePointIds, + lockedAnnotationIdSet: lockedMeasurementIdSet, + moveGizmoPointId, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + clearMoveGizmo, + getOwnerGroupIdsForPointId, + computePolygonGroupDerivedDataWithCamera, + pruneMeasurementDraftSession, + pruneSelectionByRemovedIds, + }); + + return { + updatePointLabelAppearanceById, + ...presentation, + ...reset, + ...cleanup, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryActions.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryActions.ts new file mode 100644 index 0000000000..7d4b58af58 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryActions.ts @@ -0,0 +1,59 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import type { + AnnotationCollection, + AnnotationEntry, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationCreatePayload } from "./annotationCreatePayload"; + +type UseAnnotationEntryActionsParams = { + setAnnotations: Dispatch>; +}; + +export const useAnnotationEntryActions = ({ + setAnnotations, +}: UseAnnotationEntryActionsParams) => { + const addAnnotation = useCallback( + (payload: AnnotationCreatePayload): string => { + const generatedId = + payload.id?.trim() || + `${payload.type}-${Date.now()}-${Math.random() + .toString(36) + .slice(2, 9)}`; + const nextMeasurement: AnnotationEntry = { + ...payload, + id: generatedId, + timestamp: payload.timestamp ?? Date.now(), + }; + setAnnotations((prev) => [...prev, nextMeasurement]); + return generatedId; + }, + [setAnnotations] + ); + + const updateAnnotationById = useCallback( + (id: string, patch: Partial) => { + if (!id) return; + setAnnotations((prev) => { + let hasChanged = false; + const next = prev.map((measurement) => { + if (measurement.id !== id) return measurement; + hasChanged = true; + return { + ...measurement, + ...patch, + id: measurement.id, + }; + }); + return hasChanged ? next : prev; + }); + }, + [setAnnotations] + ); + + return { + addAnnotation, + updateAnnotationById, + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEntryMutations.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryMutations.ts similarity index 56% rename from libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEntryMutations.ts rename to libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryMutations.ts index 95e32fa522..699be4ff7c 100644 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationEntryMutations.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryMutations.ts @@ -12,24 +12,24 @@ type BaseAnnotationEntry = { locked?: boolean; }; -type UseMeasurementEntryMutationsParams< - TMeasurement extends BaseAnnotationEntry, +type UseAnnotationEntryMutationsParams< + TAnnotationEntry extends BaseAnnotationEntry, TAppearance extends AnnotationLabelAppearanceLike > = { - setAnnotations: Dispatch>; - isLabelAppearanceTarget: (measurement: TMeasurement) => boolean; - getLabelAppearance: (measurement: TMeasurement) => TAppearance | undefined; + setAnnotations: Dispatch>; + isLabelAppearanceTarget: (annotation: TAnnotationEntry) => boolean; + getLabelAppearance: (annotation: TAnnotationEntry) => TAppearance | undefined; applyLabelAppearance: ( - measurement: TMeasurement, + annotation: TAnnotationEntry, appearance: TAppearance | undefined - ) => TMeasurement; + ) => TAnnotationEntry; normalizeLabelAppearance: ( appearance?: TAppearance ) => TAppearance | undefined; }; export const useAnnotationEntryMutations = < - TMeasurement extends BaseAnnotationEntry, + TAnnotationEntry extends BaseAnnotationEntry, TAppearance extends AnnotationLabelAppearanceLike >({ setAnnotations, @@ -37,31 +37,7 @@ export const useAnnotationEntryMutations = < getLabelAppearance, applyLabelAppearance, normalizeLabelAppearance, -}: UseMeasurementEntryMutationsParams) => { - const updateAnnotationNameById = useCallback( - (id: string, name: string) => { - const nextName = name.trim(); - - setAnnotations((prev) => { - const hasChanged = prev.some( - (measurement) => - measurement.id === id && (measurement.name ?? "") !== nextName - ); - - if (!hasChanged) { - return prev; - } - - return prev.map((measurement) => - measurement.id === id - ? { ...measurement, name: nextName } - : measurement - ); - }); - }, - [setAnnotations] - ); - +}: UseAnnotationEntryMutationsParams) => { const updateLabelAppearanceById = useCallback( (id: string, appearance: TAppearance | undefined) => { const normalizedAppearance = normalizeLabelAppearance(appearance); @@ -99,27 +75,7 @@ export const useAnnotationEntryMutations = < ] ); - const toggleAnnotationLockById = useCallback( - (id: string) => { - setAnnotations((prev) => { - let hasChanged = false; - const next = prev.map((measurement) => { - if (measurement.id !== id) return measurement; - hasChanged = true; - return { - ...measurement, - locked: !measurement.locked, - }; - }); - return hasChanged ? next : prev; - }); - }, - [setAnnotations] - ); - return { - updateAnnotationNameById, updateLabelAppearanceById, - toggleAnnotationLockById, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryNameAction.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryNameAction.ts new file mode 100644 index 0000000000..f01ea8a2b4 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryNameAction.ts @@ -0,0 +1,33 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import type { AnnotationCollection } from "@carma-mapping/annotations/core"; + +export const useAnnotationEntryNameAction = ( + setAnnotations: Dispatch> +) => + useCallback( + (id: string, name: string) => { + const trimmedName = name.trim(); + setAnnotations((previousAnnotations) => { + let hasChanges = false; + const nextAnnotations = previousAnnotations.map((annotation) => { + if (annotation.id !== id) { + return annotation; + } + + const currentName = annotation.name ?? ""; + if (currentName === trimmedName) { + return annotation; + } + + hasChanges = true; + return { + ...annotation, + name: trimmedName, + }; + }); + return hasChanges ? nextAnnotations : previousAnnotations; + }); + }, + [setAnnotations] + ); diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryStoreState.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryStoreState.ts new file mode 100644 index 0000000000..677270814d --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationEntryStoreState.ts @@ -0,0 +1,160 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import { useStoreSelector } from "@carma-commons/react-store"; +import type { + AnnotationCollection, + NodeChainAnnotation, + PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationsStore } from "../store"; + +const resolveSetStateAction = ( + action: SetStateAction, + previousValue: TValue +): TValue => + typeof action === "function" + ? (action as (previousValue: TValue) => TValue)(previousValue) + : action; + +export const useAnnotationEntryStoreState = ( + annotationsStore: AnnotationsStore +) => { + const annotations = useStoreSelector( + annotationsStore, + (state) => state.annotationEntries + ); + const distanceRelations = useStoreSelector( + annotationsStore, + (state) => state.distanceRelations + ); + const nodeChainAnnotations = useStoreSelector( + annotationsStore, + (state) => state.nodeChainAnnotations + ); + const referencePoint = useStoreSelector( + annotationsStore, + (state) => state.referencePoint + ); + const annotationToolType = useStoreSelector( + annotationsStore, + (state) => state.annotationToolType + ); + const showLabels = useStoreSelector( + annotationsStore, + (state) => state.showLabels + ); + const occlusionChecksEnabled = useStoreSelector( + annotationsStore, + (state) => state.occlusionChecksEnabled + ); + + const setAnnotations = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousStoreState) => { + const nextAnnotations = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.annotationEntries + ); + + return Object.is(nextAnnotations, previousStoreState.annotationEntries) + ? previousStoreState + : { + ...previousStoreState, + annotationEntries: nextAnnotations, + }; + }); + }, + [annotationsStore] + ); + + const setDistanceRelations = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousStoreState) => { + const nextDistanceRelations = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.distanceRelations + ); + + return Object.is( + nextDistanceRelations, + previousStoreState.distanceRelations + ) + ? previousStoreState + : { + ...previousStoreState, + distanceRelations: nextDistanceRelations, + }; + }); + }, + [annotationsStore] + ); + + const setNodeChainAnnotations = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousStoreState) => { + const nextNodeChainAnnotations = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.nodeChainAnnotations + ); + + return Object.is( + nextNodeChainAnnotations, + previousStoreState.nodeChainAnnotations + ) + ? previousStoreState + : { + ...previousStoreState, + nodeChainAnnotations: nextNodeChainAnnotations, + }; + }); + }, + [annotationsStore] + ); + + const setReferencePoint = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousStoreState) => { + const nextReferencePoint = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.referencePoint + ); + + return Object.is(nextReferencePoint, previousStoreState.referencePoint) + ? previousStoreState + : { + ...previousStoreState, + referencePoint: nextReferencePoint, + }; + }); + }, + [annotationsStore] + ); + + return { + annotations, + distanceRelations, + nodeChainAnnotations, + referencePoint, + annotationToolType, + showLabels, + occlusionChecksEnabled, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setReferencePoint, + }; +}; + +export type AnnotationEntryStoreState = ReturnType< + typeof useAnnotationEntryStoreState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationModelIntegritySync.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationModelIntegritySync.ts new file mode 100644 index 0000000000..e483e348fe --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationModelIntegritySync.ts @@ -0,0 +1,142 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react"; + +import { + LINEAR_SEGMENT_LINE_MODE_DIRECT, + arePolygonAnnotationsEquivalent, + buildEdgeRelationIdsForPolygon, + getDistanceRelationId, + getPointPositionMap, + type AnnotationCollection, + type LinearSegmentLineMode, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; +import { type Cartesian3 } from "@carma/cesium"; + +export const useAnnotationModelIntegritySync = ({ + annotations, + pointEntries, + defaultPolylineSegmentLineMode, + setDistanceRelations, + setNodeChainAnnotations, + computePolygonGroupDerivedDataWithCamera, +}: { + annotations: AnnotationCollection; + pointEntries: AnnotationCollection; + defaultPolylineSegmentLineMode: LinearSegmentLineMode; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + computePolygonGroupDerivedDataWithCamera: ( + group: NodeChainAnnotation, + pointById: Map + ) => NodeChainAnnotation; +}) => { + useEffect( + function effectBackfillMissingSegmentLineModes() { + setNodeChainAnnotations((prev) => { + let hasChanges = false; + const nextGroups = prev.map((group) => { + if (group.segmentLineMode) { + return group; + } + hasChanges = true; + return { + ...group, + segmentLineMode: group.closed + ? LINEAR_SEGMENT_LINE_MODE_DIRECT + : defaultPolylineSegmentLineMode, + }; + }); + return hasChanges ? nextGroups : prev; + }); + }, + [defaultPolylineSegmentLineMode, setNodeChainAnnotations] + ); + + useEffect( + function effectPruneDistanceRelationsForRemovedPoints() { + const pointEntryIdsForRelations = new Set( + pointEntries.map((measurement) => measurement.id) + ); + setDistanceRelations((prev) => { + const next = prev + .filter( + (relation) => + pointEntryIdsForRelations.has(relation.pointAId) && + pointEntryIdsForRelations.has(relation.pointBId) + ) + .map((relation) => { + const fallbackAnchorPointId = relation.pointAId; + const anchorPointId = pointEntryIdsForRelations.has( + relation.anchorPointId + ) + ? relation.anchorPointId + : fallbackAnchorPointId; + return { + ...relation, + anchorPointId, + }; + }); + if (next.length !== prev.length) return next; + for (let index = 0; index < next.length; index += 1) { + if (next[index]?.anchorPointId !== prev[index]?.anchorPointId) { + return next; + } + } + return prev; + }); + }, + [pointEntries, setDistanceRelations] + ); + + useEffect( + function effectPrunePolygonVerticesForRemovedPoints() { + const pointEntryIdsForPolygons = new Set( + pointEntries.map((measurement) => measurement.id) + ); + const pointById = getPointPositionMap(annotations); + setNodeChainAnnotations((prev) => { + let hasChanges = false; + const nextGroups = prev.flatMap((group) => { + const nextNodeIds = group.nodeIds.filter((nodeId) => + pointEntryIdsForPolygons.has(nodeId) + ); + if (nextNodeIds.length === 0) { + hasChanges = true; + return []; + } + const nextClosed = group.closed && nextNodeIds.length >= 3; + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + nextClosed, + getDistanceRelationId + ); + const nextGroup = computePolygonGroupDerivedDataWithCamera( + { + ...group, + nodeIds: nextNodeIds, + edgeRelationIds: nextEdgeRelationIds, + closed: nextClosed, + }, + pointById + ); + const groupChanged = !arePolygonAnnotationsEquivalent( + group, + nextGroup + ); + if (groupChanged) { + hasChanges = true; + } + return [groupChanged ? nextGroup : group]; + }); + return hasChanges ? nextGroups : prev; + }); + }, + [ + annotations, + computePolygonGroupDerivedDataWithCamera, + pointEntries, + setNodeChainAnnotations, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPersistenceSync.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPersistenceSync.ts new file mode 100644 index 0000000000..f7b573fa27 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPersistenceSync.ts @@ -0,0 +1,131 @@ +import { + useEffect, + useMemo, + useRef, + type Dispatch, + type SetStateAction, +} from "react"; + +import { + withDistanceRelationEdgeId, + buildPointGeometryRows, + buildGeometryEdgeTable, + buildPolygonGroupVertexTable, + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationPersistenceEnvelopeV2, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +const PERSISTENCE_RESTORE_DELAY_MS = 250; + +type UseAnnotationPersistenceSyncParams = { + initialPersistenceState?: AnnotationPersistenceEnvelopeV2 | null; + onPersistenceStateChange?: (state: AnnotationPersistenceEnvelopeV2) => void; + annotations: AnnotationCollection; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + setAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; +}; + +export const useAnnotationPersistenceSync = ({ + initialPersistenceState, + onPersistenceStateChange, + annotations, + distanceRelations, + nodeChainAnnotations, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, +}: UseAnnotationPersistenceSyncParams) => { + const geometryPointsTable = useMemo( + () => buildPointGeometryRows(annotations.filter(isPointAnnotationEntry)), + [annotations] + ); + const geometryEdgesTable = useMemo( + () => buildGeometryEdgeTable(distanceRelations, nodeChainAnnotations), + [distanceRelations, nodeChainAnnotations] + ); + const planarPolygonGroupVerticesTable = useMemo( + () => buildPolygonGroupVertexTable(nodeChainAnnotations), + [nodeChainAnnotations] + ); + + const hasAppliedInitialPersistenceStateRef = useRef(false); + const lastSavedPersistenceStateRef = useRef(null); + + useEffect( + function effectApplyInitialPersistenceState() { + if (hasAppliedInitialPersistenceStateRef.current) { + return; + } + + if (initialPersistenceState) { + setTimeout(() => { + setAnnotations(initialPersistenceState.tables.annotations); + setDistanceRelations( + initialPersistenceState.tables.distanceRelations.map( + withDistanceRelationEdgeId + ) + ); + setNodeChainAnnotations( + initialPersistenceState.tables.nodeChainAnnotations + ); + }, PERSISTENCE_RESTORE_DELAY_MS); + } + + hasAppliedInitialPersistenceStateRef.current = true; + }, + [ + initialPersistenceState, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + ] + ); + + useEffect( + function effectEmitPersistenceStateChanges() { + if ( + !onPersistenceStateChange || + !hasAppliedInitialPersistenceStateRef.current + ) { + return; + } + + const persistenceState: AnnotationPersistenceEnvelopeV2 = { + version: 2, + geometry: { + points: geometryPointsTable, + edges: geometryEdgesTable, + }, + tables: { + annotations, + distanceRelations: distanceRelations.map(withDistanceRelationEdgeId), + nodeChainAnnotations, + planarPolygonGroupVertices: planarPolygonGroupVerticesTable, + }, + }; + + const serialized = JSON.stringify(persistenceState); + if (serialized === lastSavedPersistenceStateRef.current) { + return; + } + + onPersistenceStateChange(persistenceState); + lastSavedPersistenceStateRef.current = serialized; + }, + [ + annotations, + distanceRelations, + geometryEdgesTable, + geometryPointsTable, + nodeChainAnnotations, + onPersistenceStateChange, + planarPolygonGroupVerticesTable, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPresentationActions.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPresentationActions.ts new file mode 100644 index 0000000000..6bf950ba37 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationPresentationActions.ts @@ -0,0 +1,353 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { + DEFAULT_POINT_LABEL_METRIC_MODE, + getNextPointLabelMetricMode, + isPointMeasurementEntry, + type AnnotationCollection, + type LinearSegmentLineMode, + type NodeChainAnnotation, + type PointLabelMetricMode, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationPresentationActionsParams = { + annotations: AnnotationCollection; + nodeChainAnnotations: NodeChainAnnotation[]; + setAnnotations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + updateAnnotationEntryNameById: (id: string, name: string) => void; +}; + +export const useAnnotationPresentationActions = ({ + annotations, + nodeChainAnnotations, + setAnnotations, + setNodeChainAnnotations, + updateAnnotationEntryNameById, +}: UseAnnotationPresentationActionsParams) => { + const updateNodeChainAnnotationNameById = useCallback( + (id: string, name: string) => { + const nextName = name.trim(); + setNodeChainAnnotations((prev) => { + let hasChanged = false; + const next = prev.map((group) => { + if (group.id !== id) return group; + if ((group.name ?? "") === nextName) return group; + hasChanged = true; + return { + ...group, + name: nextName.length > 0 ? nextName : undefined, + }; + }); + return hasChanged ? next : prev; + }); + }, + [setNodeChainAnnotations] + ); + + const updateNodeChainAnnotationSegmentLineModeById = useCallback( + (id: string, nextMode: LinearSegmentLineMode) => { + setNodeChainAnnotations((previousGroups) => { + let hasChanged = false; + const nextGroups = previousGroups.map((group) => { + if (group.id !== id || group.segmentLineMode === nextMode) { + return group; + } + + hasChanged = true; + return { + ...group, + segmentLineMode: nextMode, + }; + }); + + return hasChanged ? nextGroups : previousGroups; + }); + }, + [setNodeChainAnnotations] + ); + + const updateAnnotationNameById = useCallback( + (id: string, name: string) => { + const isNodeChainAnnotationId = nodeChainAnnotations.some( + (group) => group.id === id + ); + if (isNodeChainAnnotationId) { + updateNodeChainAnnotationNameById(id, name); + return; + } + + updateAnnotationEntryNameById(id, name); + }, + [ + nodeChainAnnotations, + updateAnnotationEntryNameById, + updateNodeChainAnnotationNameById, + ] + ); + + const updateAnnotationVisualizerOptionsById = useCallback( + ( + id: string, + patch: { + segmentLineMode?: LinearSegmentLineMode; + } + ) => { + if (patch.segmentLineMode) { + updateNodeChainAnnotationSegmentLineModeById(id, patch.segmentLineMode); + } + }, + [updateNodeChainAnnotationSegmentLineModeById] + ); + + const toggleNodeChainAnnotationVisibilityById = useCallback( + (id: string) => { + setNodeChainAnnotations((previousGroups) => { + let hasChanged = false; + const nextGroups = previousGroups.map((group) => { + if (group.id !== id) { + return group; + } + + hasChanged = true; + return { + ...group, + hidden: !group.hidden, + }; + }); + + return hasChanged ? nextGroups : previousGroups; + }); + }, + [setNodeChainAnnotations] + ); + + const toggleAnnotationsVisibilityByIds = useCallback( + (ids: string[]) => { + if (ids.length === 0) { + return; + } + + const requestedIdSet = new Set(ids); + const targetedNodeChainIdSet = new Set( + nodeChainAnnotations + .filter((group) => requestedIdSet.has(group.id)) + .map((group) => group.id) + ); + const targetedAnnotationIdSet = new Set( + ids.filter((id) => !targetedNodeChainIdSet.has(id)) + ); + const shouldHide = + annotations.some( + (annotation) => + targetedAnnotationIdSet.has(annotation.id) && !annotation.hidden + ) || + nodeChainAnnotations.some( + (group) => targetedNodeChainIdSet.has(group.id) && !group.hidden + ); + + if (targetedAnnotationIdSet.size > 0) { + setAnnotations((previousAnnotations) => { + let hasChanges = false; + const nextAnnotations = previousAnnotations.map((annotation) => { + if (!targetedAnnotationIdSet.has(annotation.id)) { + return annotation; + } + + if (Boolean(annotation.hidden) === shouldHide) { + return annotation; + } + + hasChanges = true; + return { + ...annotation, + hidden: shouldHide, + }; + }); + + return hasChanges ? nextAnnotations : previousAnnotations; + }); + } + + if (targetedNodeChainIdSet.size > 0) { + setNodeChainAnnotations((previousGroups) => { + let hasChanges = false; + const nextGroups = previousGroups.map((group) => { + if (!targetedNodeChainIdSet.has(group.id)) { + return group; + } + + if (Boolean(group.hidden) === shouldHide) { + return group; + } + + hasChanges = true; + return { + ...group, + hidden: shouldHide, + }; + }); + + return hasChanges ? nextGroups : previousGroups; + }); + } + }, + [annotations, nodeChainAnnotations, setAnnotations, setNodeChainAnnotations] + ); + + const toggleNodeChainAnnotationLockById = useCallback( + (id: string) => { + const targetGroup = nodeChainAnnotations.find((group) => group.id === id); + if (!targetGroup || targetGroup.nodeIds.length === 0) { + return; + } + + const nodeIdSet = new Set(targetGroup.nodeIds); + const shouldLock = targetGroup.nodeIds.some((nodeId) => { + const vertex = annotations.find((entry) => entry.id === nodeId); + return !vertex?.locked; + }); + + setAnnotations((previousAnnotations) => { + let hasChanged = false; + const nextAnnotations = previousAnnotations.map((annotation) => { + if ( + !nodeIdSet.has(annotation.id) || + annotation.locked === shouldLock + ) { + return annotation; + } + + hasChanged = true; + return { + ...annotation, + locked: shouldLock, + }; + }); + + return hasChanged ? nextAnnotations : previousAnnotations; + }); + }, + [annotations, nodeChainAnnotations, setAnnotations] + ); + + const toggleAnnotationsLockByIds = useCallback( + (ids: string[]) => { + if (ids.length === 0) { + return; + } + + const requestedIdSet = new Set(ids); + const targetedNodeChainAnnotations = nodeChainAnnotations.filter( + (group) => requestedIdSet.has(group.id) + ); + const targetedVertexIdSet = new Set( + targetedNodeChainAnnotations.flatMap((group) => group.nodeIds) + ); + const targetedAnnotationIdSet = new Set( + annotations + .filter( + (annotation) => + requestedIdSet.has(annotation.id) || + targetedVertexIdSet.has(annotation.id) + ) + .map((annotation) => annotation.id) + ); + + if (targetedAnnotationIdSet.size === 0) { + return; + } + + const shouldLock = annotations.some( + (annotation) => + targetedAnnotationIdSet.has(annotation.id) && !annotation.locked + ); + + setAnnotations((previousAnnotations) => { + let hasChanges = false; + const nextAnnotations = previousAnnotations.map((annotation) => { + if ( + !targetedAnnotationIdSet.has(annotation.id) || + annotation.locked === shouldLock + ) { + return annotation; + } + + hasChanges = true; + return { + ...annotation, + locked: shouldLock, + }; + }); + + return hasChanges ? nextAnnotations : previousAnnotations; + }); + }, + [annotations, nodeChainAnnotations, setAnnotations] + ); + + const setPointLabelMetricModeById = useCallback( + (id: string, mode: PointLabelMetricMode) => { + setAnnotations((prev) => { + let hasChanged = false; + const next = prev.map((measurement) => { + if (!isPointMeasurementEntry(measurement) || measurement.id !== id) { + return measurement; + } + const normalizedMode = + mode === DEFAULT_POINT_LABEL_METRIC_MODE ? undefined : mode; + if (measurement.pointLabelMode === normalizedMode) { + return measurement; + } + hasChanged = true; + return { ...measurement, pointLabelMode: normalizedMode }; + }); + return hasChanged ? next : prev; + }); + }, + [setAnnotations] + ); + + const cyclePointLabelMetricModeByMeasurementId = useCallback( + (id: string) => { + setAnnotations((prev) => { + let hasChanged = false; + + const next = prev.map((measurement) => { + if (!isPointMeasurementEntry(measurement) || measurement.id !== id) { + return measurement; + } + + const currentMode = + measurement.pointLabelMode ?? DEFAULT_POINT_LABEL_METRIC_MODE; + const nextMode = getNextPointLabelMetricMode(currentMode); + const normalizedNextMode = + nextMode === DEFAULT_POINT_LABEL_METRIC_MODE ? undefined : nextMode; + + if (measurement.pointLabelMode === normalizedNextMode) { + return measurement; + } + + hasChanged = true; + return { ...measurement, pointLabelMode: normalizedNextMode }; + }); + + return hasChanged ? next : prev; + }); + }, + [setAnnotations] + ); + + return { + updateNodeChainAnnotationNameById, + updateNodeChainAnnotationSegmentLineModeById, + updateAnnotationNameById, + updateAnnotationVisualizerOptionsById, + toggleNodeChainAnnotationVisibilityById, + toggleAnnotationsVisibilityByIds, + toggleNodeChainAnnotationLockById, + toggleAnnotationsLockByIds, + setPointLabelMetricModeById, + cyclePointLabelMetricModeByMeasurementId, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationResetActions.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationResetActions.ts new file mode 100644 index 0000000000..538fc990b7 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationResetActions.ts @@ -0,0 +1,91 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { + ANNOTATION_TYPE_DISTANCE, + type AnnotationCollection, + type AnnotationMode, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationResetActionsParams = { + hideAnnotationsOfType: ReadonlySet; + setHideAnnotationsOfType: Dispatch>>; + setAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + clearAnnotationSelection: () => void; + clearNodeSelection: () => void; + clearActiveNodeChainDrawingState: () => void; + clearMoveGizmo: () => void; +}; + +export const useAnnotationResetActions = ({ + hideAnnotationsOfType, + setHideAnnotationsOfType, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + clearAnnotationSelection, + clearNodeSelection, + clearActiveNodeChainDrawingState, + clearMoveGizmo, +}: UseAnnotationResetActionsParams) => { + const clearAllAnnotations = useCallback(() => { + setAnnotations([]); + setDistanceRelations([]); + setNodeChainAnnotations([]); + clearAnnotationSelection(); + clearActiveNodeChainDrawingState(); + clearMoveGizmo(); + if (hideAnnotationsOfType.size > 0) { + setHideAnnotationsOfType(new Set()); + } + }, [ + clearActiveNodeChainDrawingState, + clearAnnotationSelection, + clearMoveGizmo, + hideAnnotationsOfType.size, + setAnnotations, + setDistanceRelations, + setHideAnnotationsOfType, + setNodeChainAnnotations, + ]); + + const clearAnnotationsByType = useCallback( + (type: AnnotationMode) => { + setAnnotations((prev) => + prev.filter((annotation) => annotation.type !== type) + ); + if (type === ANNOTATION_TYPE_DISTANCE) { + setDistanceRelations([]); + setNodeChainAnnotations([]); + clearActiveNodeChainDrawingState(); + } + clearNodeSelection(); + clearMoveGizmo(); + setHideAnnotationsOfType((prev) => { + if (!prev.has(type)) { + return prev; + } + const next = new Set(prev); + next.delete(type); + return next; + }); + }, + [ + clearActiveNodeChainDrawingState, + clearMoveGizmo, + clearNodeSelection, + setAnnotations, + setDistanceRelations, + setHideAnnotationsOfType, + setNodeChainAnnotations, + ] + ); + + return { + clearAllAnnotations, + clearAnnotationsByType, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationsCollectionState.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationsCollectionState.ts new file mode 100644 index 0000000000..0457dd3c32 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useAnnotationsCollectionState.ts @@ -0,0 +1,113 @@ +import { useCallback, useMemo } from "react"; + +import { + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POINT, + type AnnotationCollection, + type AnnotationEntry, + type AnnotationMode, + type NodeChainAnnotation, + type PointAnnotationEntry, + type PointMeasurementEntry, +} from "@carma-mapping/annotations/core"; + +import { useAnnotationCollectionSelectors } from "./useAnnotationCollectionSelectors"; + +type UseAnnotationsCollectionStateOptions = { + nodeChainAnnotations: NodeChainAnnotation[]; +}; + +const NAVIGATION_ANNOTATION_TYPES: AnnotationMode[] = [ + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_DISTANCE, +]; + +const deriveNodeChainNodeIdSet = ( + nodeChainAnnotations: NodeChainAnnotation[] +): ReadonlySet => { + const ids = new Set(); + nodeChainAnnotations.forEach((group) => { + group.nodeIds.forEach((pointId) => { + if (pointId) { + ids.add(pointId); + } + }); + }); + return ids; +}; + +const createAnnotationsByTypeSelector = ( + annotations: AnnotationCollection, + pointEntries: PointAnnotationEntry[], + pointMeasureEntries: PointMeasurementEntry[], + nodeChainNodeIdSet: ReadonlySet +) => { + return (type: AnnotationMode): AnnotationEntry[] => { + if (type === ANNOTATION_TYPE_POINT) { + return pointMeasureEntries.filter( + (measurement) => !measurement.auxiliaryLabelAnchor + ); + } + + if (type === ANNOTATION_TYPE_DISTANCE) { + return pointEntries.filter((measurement) => { + if (measurement.type !== ANNOTATION_TYPE_DISTANCE) { + return false; + } + if (measurement.auxiliaryLabelAnchor) { + return false; + } + if (nodeChainNodeIdSet.has(measurement.id)) { + return false; + } + return true; + }); + } + + return annotations.filter((measurement) => measurement.type === type); + }; +}; + +export const useAnnotationsCollectionState = ( + annotations: AnnotationCollection, + pointEntries: PointAnnotationEntry[], + pointMeasureEntries: PointMeasurementEntry[], + { nodeChainAnnotations }: UseAnnotationsCollectionStateOptions +) => { + const nodeChainNodeIdSet = useMemo( + () => deriveNodeChainNodeIdSet(nodeChainAnnotations), + [nodeChainAnnotations] + ); + + const annotationsByType = useCallback( + createAnnotationsByTypeSelector( + annotations, + pointEntries, + pointMeasureEntries, + nodeChainNodeIdSet + ), + [annotations, nodeChainNodeIdSet, pointEntries, pointMeasureEntries] + ); + + const { + getAnnotationsForNavigation, + getAnnotationIndexByType, + getAnnotationOrderByType, + getNextAnnotationOrderByType, + } = useAnnotationCollectionSelectors({ + annotationsByType, + navigationTypes: NAVIGATION_ANNOTATION_TYPES, + }); + + return { + annotationsByType, + getAnnotationsForNavigation, + getAnnotationIndexByType, + getAnnotationOrderByType, + getNextAnnotationOrderByType, + }; +}; + +export type AnnotationsCollectionState = ReturnType< + typeof useAnnotationsCollectionState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useSyncNodeChainEdgeRelations.ts b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useSyncNodeChainEdgeRelations.ts new file mode 100644 index 0000000000..e7fb28d4d5 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotation-entries/useSyncNodeChainEdgeRelations.ts @@ -0,0 +1,49 @@ +import { useEffect, type Dispatch, type SetStateAction } from "react"; + +import type { + DirectLineLabelMode, + LinearSegmentLineMode, + NodeChainAnnotation, + PointDistanceRelation, + ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +import { syncNodeChainEdgeDistanceRelations } from "./syncNodeChainEdgeDistanceRelations"; + +export const useSyncNodeChainEdgeRelations = ({ + setDistanceRelations, + nodeChainAnnotations, + defaultPolylineSegmentLineMode, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, +}: { + setDistanceRelations: Dispatch>; + nodeChainAnnotations: NodeChainAnnotation[]; + defaultPolylineSegmentLineMode: LinearSegmentLineMode; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; + defaultDirectLineLabelMode: DirectLineLabelMode; +}) => { + useEffect( + function effectSyncDistanceRelationsWithPolygonEdges() { + setDistanceRelations((prev) => + syncNodeChainEdgeDistanceRelations({ + previousRelations: prev, + nodeChainAnnotations, + defaultPolylineSegmentLineMode, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, + }) + ); + }, + [ + defaultDirectLineLabelMode, + defaultDistanceRelationLabelVisibility, + defaultPolylineSegmentLineMode, + nodeChainAnnotations, + setDistanceRelations, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/annotationsContext.types.ts b/libraries/mapping/annotations/provider/src/lib/context/annotationsContext.types.ts new file mode 100644 index 0000000000..3c04b5230e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/annotationsContext.types.ts @@ -0,0 +1,118 @@ +import type { + AnnotationCollection, + AnnotationEntry, + AnnotationLabelAppearance, + AnnotationMode, + AnnotationToolType, + LinearSegmentLineMode, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationCreatePayload } from "./annotation-entries/annotationCreatePayload"; +import type { + AnnotationEditTarget, + AnnotationEditUpdateTarget, +} from "./interaction/editing/annotationEdit.types"; + +export type AnnotationVisualizerOptionsPatch = { + segmentLineMode?: LinearSegmentLineMode; +}; + +export type AnnotationsContextType = { + tools: { + activeToolType: AnnotationToolType; + requestModeChange: (toolType: AnnotationToolType) => void; + requestStartMeasurement: (toolType?: AnnotationToolType) => void; + requestCloseActiveMeasurement: () => void; + }; + selection: { + activeAnnotationId: string | null; + ids: string[]; + mode: { + active: boolean; + additive: boolean; + rectangle: boolean; + }; + setModeActive: (active: boolean) => void; + setAdditiveMode: (active: boolean) => void; + setRectangleMode: (active: boolean) => void; + set: (ids: string[], additive?: boolean) => void; + clear: () => void; + }; + annotations: { + items: AnnotationCollection; + byType: (type: AnnotationMode) => AnnotationEntry[]; + getNavigationItems: () => AnnotationEntry[]; + getIndexByType: ( + type: AnnotationMode, + id: string | null | undefined + ) => number; + getOrderByType: ( + type: AnnotationMode, + id: string | null | undefined + ) => number | null; + getNextOrderByType: (type: AnnotationMode) => number; + add: (payload: AnnotationCreatePayload) => string; + updateById: (id: string, patch: Partial) => void; + updateNameById: (id: string, name: string) => void; + updateVisualizerOptionsById: ( + id: string, + patch: AnnotationVisualizerOptionsPatch + ) => void; + updatePointLabelAppearanceById: ( + id: string, + appearance: AnnotationLabelAppearance | undefined + ) => void; + removeByIds: (ids: string[]) => void; + removeSelection: () => void; + removeAll: () => void; + removeByType: (type: AnnotationMode) => void; + toggleLockByIds: (ids: string[]) => void; + toggleVisibilityByIds: (ids: string[]) => void; + setReferencePointId: (id: string | null) => void; + confirmLabelPlacementById: (id: string) => void; + flyToById: (id: string) => void; + focusById: (id: string | null) => void; + flyToAll: () => void; + }; + edit: { + activeTarget: AnnotationEditTarget | null; + requestStart: (target: AnnotationEditTarget) => void; + requestStop: () => void; + requestUpdateTarget: (target: AnnotationEditUpdateTarget) => boolean; + }; + settings: { + point: { + verticalOffsetMeters: number; + setVerticalOffsetMeters: (offsetMeters: number) => void; + temporaryMode: boolean; + setTemporaryMode: (temporary: boolean) => void; + }; + distance: { + stickyToFirstPoint: boolean; + setStickyToFirstPoint: (enabled: boolean) => void; + creationLineVisibility: { + direct: boolean; + vertical: boolean; + horizontal: boolean; + }; + setCreationLineVisibilityByKind: ( + kind: "direct" | "vertical" | "horizontal", + visible: boolean + ) => void; + }; + polyline: { + verticalOffsetMeters: number; + setVerticalOffsetMeters: (offsetMeters: number) => void; + segmentLineMode: LinearSegmentLineMode; + setSegmentLineMode: (mode: LinearSegmentLineMode) => void; + }; + }; +}; + +export type AnnotationToolsContextType = AnnotationsContextType["tools"]; +export type AnnotationSelectionContextType = + AnnotationsContextType["selection"]; +export type AnnotationCollectionContextType = + AnnotationsContextType["annotations"]; +export type AnnotationEditingContextType = AnnotationsContextType["edit"]; +export type AnnotationSettingsContextType = AnnotationsContextType["settings"]; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/annotationLivePreview.types.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/annotationLivePreview.types.ts deleted file mode 100644 index ce62231f40..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/annotationLivePreview.types.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const ANNOTATION_LIVE_PREVIEW_TYPE_NONE = "none"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_POINT = "point"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE = "distance"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE = "polyline"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND = "polygon-ground"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR = "polygon-planar"; -export const ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL = "polygon-vertical"; - -export type AnnotationLivePreviewType = - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_NONE - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_POINT - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR - | typeof ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL; - -export type AnnotationLivePreviewDescriptor = { - type: AnnotationLivePreviewType; - verticalOffsetMeters: number; - verticalPolygonContext?: { - groupId: string; - firstVertexPointId: string; - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/livePreviewCapabilities.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/livePreviewCapabilities.ts deleted file mode 100644 index b5cf060ff3..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/livePreviewCapabilities.ts +++ /dev/null @@ -1,59 +0,0 @@ -import { - ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE, - ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - ANNOTATION_LIVE_PREVIEW_TYPE_POINT, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE, - type AnnotationLivePreviewType, -} from "../annotationLivePreview.types"; - -export type LivePreviewCapabilities = { - previewIsPolylineCreateMode: boolean; - hasActivePreviewNode: boolean; - activePreviewSupportsDistanceLine: boolean; - activePreviewUsesPolylineDistanceRules: boolean; - activePreviewForceDirectDistanceLine: boolean; - isVerticalPolygonPreview: boolean; -}; - -export const resolveLivePreviewCapabilities = ( - type: AnnotationLivePreviewType -): LivePreviewCapabilities => { - const previewIsPolylineCreateMode = - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE; - const isVerticalPolygonPreview = - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL; - const hasActivePreviewNode = type !== ANNOTATION_LIVE_PREVIEW_TYPE_NONE; - const activePreviewSupportsDistanceLine = - type === ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL; - const activePreviewUsesPolylineDistanceRules = - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR; - const activePreviewForceDirectDistanceLine = - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR || - type === ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL; - - return { - previewIsPolylineCreateMode, - hasActivePreviewNode, - activePreviewSupportsDistanceLine, - activePreviewUsesPolylineDistanceRules, - activePreviewForceDirectDistanceLine, - isVerticalPolygonPreview, - }; -}; - -export const isPointPreviewWithOffsetStem = ( - type: AnnotationLivePreviewType, - verticalOffsetMeters: number -): boolean => - type === ANNOTATION_LIVE_PREVIEW_TYPE_POINT && - Math.abs(verticalOffsetMeters) > 1e-9; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/usePointLivePreviewState.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/usePointLivePreviewState.ts deleted file mode 100644 index 1abf6570fc..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/usePointLivePreviewState.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { useCallback, useState } from "react"; -import { Cartesian3, type Scene } from "@carma/cesium"; -import type { AnnotationLivePreviewType } from "../annotationLivePreview.types"; -import { isPointPreviewWithOffsetStem } from "./livePreviewCapabilities"; - -type UsePointLivePreviewStateParams = { - scene: Scene | null; - activePreviewType: AnnotationLivePreviewType; - verticalOffsetMeters: number; - hasActivePreviewNode: boolean; - getPositionWithVerticalOffsetFromAnchor: ( - positionECEF: Cartesian3, - verticalOffsetMeters: number - ) => Cartesian3; -}; - -type UsePointLivePreviewStateResult = { - livePreviewPointECEF: Cartesian3 | null; - livePreviewSurfaceNormalECEF: Cartesian3 | null; - livePreviewVerticalOffsetAnchorECEF: Cartesian3 | null; - updatePointPreviewFromPointerMove: ( - positionECEF: Cartesian3 | null, - surfaceNormalECEF?: Cartesian3 | null - ) => void; - clearPointPreview: () => void; -}; - -export const usePointLivePreviewState = ({ - scene, - activePreviewType, - verticalOffsetMeters, - hasActivePreviewNode, - getPositionWithVerticalOffsetFromAnchor, -}: UsePointLivePreviewStateParams): UsePointLivePreviewStateResult => { - const [livePreviewPointECEF, setLivePreviewPointECEF] = - useState(null); - const [livePreviewSurfaceNormalECEF, setLivePreviewSurfaceNormalECEF] = - useState(null); - const [ - livePreviewVerticalOffsetAnchorECEF, - setLivePreviewVerticalOffsetAnchorECEF, - ] = useState(null); - - const clearPointPreview = useCallback(() => { - setLivePreviewPointECEF((prev) => (prev ? null : prev)); - setLivePreviewSurfaceNormalECEF((prev) => (prev ? null : prev)); - setLivePreviewVerticalOffsetAnchorECEF((prev) => (prev ? null : prev)); - }, []); - - const updatePointPreviewFromPointerMove = useCallback( - ( - positionECEF: Cartesian3 | null, - surfaceNormalECEF?: Cartesian3 | null - ) => { - if (!hasActivePreviewNode) { - clearPointPreview(); - return; - } - - const hasVerticalOffsetStem = isPointPreviewWithOffsetStem( - activePreviewType, - verticalOffsetMeters - ); - const previewPosition = positionECEF - ? Math.abs(verticalOffsetMeters) > 1e-9 - ? getPositionWithVerticalOffsetFromAnchor( - positionECEF, - verticalOffsetMeters - ) - : positionECEF - : null; - - setLivePreviewPointECEF((prev) => { - if (!previewPosition) { - return prev ? null : prev; - } - if ( - prev && - prev.x === previewPosition.x && - prev.y === previewPosition.y && - prev.z === previewPosition.z - ) { - return prev; - } - return Cartesian3.clone(previewPosition); - }); - - setLivePreviewSurfaceNormalECEF((prev) => { - if (!previewPosition || !surfaceNormalECEF) { - return prev ? null : prev; - } - - const normalized = Cartesian3.normalize( - surfaceNormalECEF, - new Cartesian3() - ); - if (prev && 1 - Math.abs(Cartesian3.dot(prev, normalized)) <= 1e-5) { - return prev; - } - - return normalized; - }); - - setLivePreviewVerticalOffsetAnchorECEF((prev) => { - if (!hasVerticalOffsetStem || !positionECEF || !previewPosition) { - return prev ? null : prev; - } - if ( - prev && - prev.x === positionECEF.x && - prev.y === positionECEF.y && - prev.z === positionECEF.z - ) { - return prev; - } - return Cartesian3.clone(positionECEF); - }); - - scene?.requestRender(); - }, - [ - activePreviewType, - clearPointPreview, - getPositionWithVerticalOffsetFromAnchor, - hasActivePreviewNode, - scene, - verticalOffsetMeters, - ] - ); - - return { - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - updatePointPreviewFromPointerMove, - clearPointPreview, - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/useVerticalPolygonLivePreview.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/useVerticalPolygonLivePreview.ts deleted file mode 100644 index c0a0e6b439..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/live-preview/useVerticalPolygonLivePreview.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { useCallback, type Dispatch, type SetStateAction } from "react"; -import { Cartesian3, type Scene } from "@carma/cesium"; -import { - ANNOTATION_TYPE_AREA_VERTICAL, - isPointAnnotationEntry, - type AnnotationCollection, - type PlanarPolygonGroup, -} from "@carma-mapping/annotations/core"; -import type { AnnotationLivePreviewDescriptor } from "../annotationLivePreview.types"; - -type UseVerticalPolygonLivePreviewParams = { - scene: Scene | null; - isVerticalPolygonPreview: boolean; - activePreview: AnnotationLivePreviewDescriptor; - annotations: AnnotationCollection; - setPlanarPolygonGroups: Dispatch>; - getFacadeRectanglePreviewAreaSquareMeters: ( - firstVertexECEF: Cartesian3, - oppositeVertexECEF: Cartesian3 - ) => number; -}; - -export const useVerticalPolygonLivePreview = ({ - scene, - isVerticalPolygonPreview, - activePreview, - annotations, - setPlanarPolygonGroups, - getFacadeRectanglePreviewAreaSquareMeters, -}: UseVerticalPolygonLivePreviewParams) => - useCallback( - (positionECEF: Cartesian3 | null) => { - if (!isVerticalPolygonPreview) return; - - const verticalPolygonContext = activePreview.verticalPolygonContext; - if (!verticalPolygonContext) return; - - const firstPoint = annotations.find( - (measurement) => - measurement.id === verticalPolygonContext.firstVertexPointId && - isPointAnnotationEntry(measurement) - ); - if (!firstPoint || !isPointAnnotationEntry(firstPoint)) return; - - const previewAreaSquareMeters = positionECEF - ? getFacadeRectanglePreviewAreaSquareMeters( - firstPoint.geometryECEF, - positionECEF - ) - : 0; - - setPlanarPolygonGroups((prev) => - prev.map((group) => { - if (group.id !== verticalPolygonContext.groupId || group.closed) { - return group; - } - if (group.measurementKind !== ANNOTATION_TYPE_AREA_VERTICAL) { - return group; - } - if (group.vertexPointIds.length !== 1) { - return group; - } - if ( - Math.abs((group.areaSquareMeters ?? 0) - previewAreaSquareMeters) <= - 1e-9 - ) { - return group; - } - return { - ...group, - areaSquareMeters: previewAreaSquareMeters, - }; - }) - ); - - scene?.requestRender(); - }, - [ - activePreview.verticalPolygonContext, - annotations, - getFacadeRectanglePreviewAreaSquareMeters, - isVerticalPolygonPreview, - scene, - setPlanarPolygonGroups, - ] - ); diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationLivePreviewState.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationLivePreviewState.ts deleted file mode 100644 index 09be2db2fc..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationLivePreviewState.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { - useCallback, - useEffect, - type Dispatch, - type SetStateAction, -} from "react"; - -import { Cartesian2, Cartesian3, type Scene } from "@carma/cesium"; - -import type { - AnnotationCollection, - PlanarPolygonGroup, -} from "@carma-mapping/annotations/core"; - -import { resolveLivePreviewCapabilities } from "./live-preview/livePreviewCapabilities"; -import { usePointLivePreviewState } from "./live-preview/usePointLivePreviewState"; -import { useVerticalPolygonLivePreview } from "./live-preview/useVerticalPolygonLivePreview"; -import { - ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE, - ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - ANNOTATION_LIVE_PREVIEW_TYPE_POINT, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE, - type AnnotationLivePreviewDescriptor, -} from "./annotationLivePreview.types"; - -export { - ANNOTATION_LIVE_PREVIEW_TYPE_DISTANCE, - ANNOTATION_LIVE_PREVIEW_TYPE_NONE, - ANNOTATION_LIVE_PREVIEW_TYPE_POINT, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_GROUND, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_PLANAR, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYGON_VERTICAL, - ANNOTATION_LIVE_PREVIEW_TYPE_POLYLINE, - type AnnotationLivePreviewDescriptor, -} from "./annotationLivePreview.types"; - -type UseAnnotationLivePreviewStateParams = { - scene: Scene | null; - activePreview: AnnotationLivePreviewDescriptor; - pointQueryEnabled: boolean; - moveGizmoPointId: string | null; - isMoveGizmoDragging: boolean; - annotations: AnnotationCollection; - setPlanarPolygonGroups: Dispatch>; - getPositionWithVerticalOffsetFromAnchor: ( - positionECEF: Cartesian3, - verticalOffsetMeters: number - ) => Cartesian3; - getFacadeRectanglePreviewAreaSquareMeters: ( - firstVertexECEF: Cartesian3, - oppositeVertexECEF: Cartesian3 - ) => number; -}; - -type UseAnnotationLivePreviewStateResult = { - livePreviewPointECEF: Cartesian3 | null; - livePreviewSurfaceNormalECEF: Cartesian3 | null; - livePreviewVerticalOffsetAnchorECEF: Cartesian3 | null; - handlePointQueryPointerMove: ( - positionECEF: Cartesian3 | null, - screenPosition?: Cartesian2, - surfaceNormalECEF?: Cartesian3 | null - ) => void; - previewIsPolylineCreateMode: boolean; - hasActivePreviewNode: boolean; - activePreviewSupportsDistanceLine: boolean; - activePreviewUsesPolylineDistanceRules: boolean; - activePreviewForceDirectDistanceLine: boolean; - isLivePointPreviewModeActive: boolean; -}; - -export const useAnnotationLivePreviewState = ({ - scene, - activePreview, - pointQueryEnabled, - moveGizmoPointId, - isMoveGizmoDragging, - annotations, - setPlanarPolygonGroups, - getPositionWithVerticalOffsetFromAnchor, - getFacadeRectanglePreviewAreaSquareMeters, -}: UseAnnotationLivePreviewStateParams): UseAnnotationLivePreviewStateResult => { - const capabilities = resolveLivePreviewCapabilities(activePreview.type); - const { - previewIsPolylineCreateMode, - hasActivePreviewNode, - activePreviewSupportsDistanceLine, - activePreviewUsesPolylineDistanceRules, - activePreviewForceDirectDistanceLine, - isVerticalPolygonPreview, - } = capabilities; - - const { - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - updatePointPreviewFromPointerMove, - clearPointPreview, - } = usePointLivePreviewState({ - scene, - activePreviewType: activePreview.type, - verticalOffsetMeters: activePreview.verticalOffsetMeters, - hasActivePreviewNode, - getPositionWithVerticalOffsetFromAnchor, - }); - - const updateVerticalPolygonPreview = useVerticalPolygonLivePreview({ - scene, - isVerticalPolygonPreview, - activePreview, - annotations, - setPlanarPolygonGroups, - getFacadeRectanglePreviewAreaSquareMeters, - }); - - const handlePointQueryPointerMove = useCallback( - ( - positionECEF: Cartesian3 | null, - _screenPosition?: Cartesian2, - surfaceNormalECEF?: Cartesian3 | null - ) => { - updatePointPreviewFromPointerMove(positionECEF, surfaceNormalECEF); - updateVerticalPolygonPreview(positionECEF); - }, - [updatePointPreviewFromPointerMove, updateVerticalPolygonPreview] - ); - - const isLivePointPreviewModeActive = - hasActivePreviewNode && - pointQueryEnabled && - !moveGizmoPointId && - !isMoveGizmoDragging; - - useEffect(() => { - if (isLivePointPreviewModeActive) return; - clearPointPreview(); - }, [clearPointPreview, isLivePointPreviewModeActive]); - - return { - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - handlePointQueryPointerMove, - previewIsPolylineCreateMode, - hasActivePreviewNode, - activePreviewSupportsDistanceLine, - activePreviewUsesPolylineDistanceRules, - activePreviewForceDirectDistanceLine, - isLivePointPreviewModeActive, - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationVisualizerAdapter.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationVisualizerAdapter.ts deleted file mode 100644 index 4e2df75b8e..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationVisualizerAdapter.ts +++ /dev/null @@ -1,219 +0,0 @@ -import { type Cartesian3, type Scene } from "@carma/cesium"; -import { - type AnnotationCollection, - type PlanarPolygonGroup, - type PointDistanceRelation, - type ReferenceLineLabelKind, -} from "@carma-mapping/annotations/core"; - -import { - type CesiumLabelLayoutConfigOverrides, - type PointMarkerBadge, -} from "@carma-mapping/annotations/cesium"; -import { useDistanceVisualizerAdapter } from "./useDistanceVisualizerAdapter"; -import { usePointMeasureVisualizer } from "./usePointMeasureVisualizer"; - -export type AnnotationVisualizerAdapterOptions = { - scene: Scene | null; - annotations: AnnotationCollection; - showPoints: boolean; - showPointLabels: boolean; - pointRadius: number; - referenceElevation: number; - selectedMeasurementId: string | null; - selectedMeasurementIds: string[]; - hiddenPointLabelIds: ReadonlySet; - fullyHiddenPointIds: ReadonlySet; - markerlessPointIds: ReadonlySet; - collapsedPillPointIds: ReadonlySet; - moveGizmoPointId: string | null; - selectionModeActive: boolean; - selectModeRectangle: boolean; - effectiveSelectModeAdditive: boolean; - selectMeasurementIds: (ids: string[], additive?: boolean) => void; - handlePointLabelClick: (pointId: string) => void; - handlePointLabelDoubleClick: (pointId: string) => void; - handlePointLabelLongPress: (pointId: string) => void; - handlePointLabelHoverChange: (pointId: string, hovered: boolean) => void; - handlePointVerticalOffsetStemLongPress: (pointId: string) => void; - pointLongPressDurationMs: number; - occlusionChecksEnabled: boolean; - labelLayoutConfig?: CesiumLabelLayoutConfigOverrides; - effectiveDistanceToReferenceByPointId: Readonly>; - pointMarkerBadgeByPointId: Readonly>; - labelInputPromptPointId: string | null; - markerOnlyOverlayNodeInteractions: boolean; - suppressLivePreviewLabelOverlay: boolean; - livePreviewPointECEF: Cartesian3 | null; - livePreviewSurfaceNormalECEF: Cartesian3 | null; - livePreviewVerticalOffsetAnchorECEF: Cartesian3 | null; - livePreviewDistanceLine: { - anchorPointECEF: Cartesian3; - targetPointECEF: Cartesian3; - showDirectLine: boolean; - showVerticalLine: boolean; - showHorizontalLine: boolean; - } | null; - showDistanceAndPolygonVisuals: boolean; - distanceRelations: PointDistanceRelation[]; - planarPolygonGroups: PlanarPolygonGroup[]; - selectedPlanarPolygonGroupId: string | null; - activePlanarPolygonGroupId: string | null; - cumulativeDistanceByRelationId: Readonly>; - handleDistanceRelationLineLabelToggle: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - handleDistanceRelationLineClick: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - handleDistanceRelationMidpointClick: (relationId: string) => void; - handleDistanceRelationCornerClick: (relationId: string) => void; - referencePoint: Cartesian3 | null; - moveGizmoAxisDirection: Cartesian3 | null; - moveGizmoPreferredAxisId: string | null; - moveGizmoMarkerSizeScale: number; - moveGizmoLabelDistanceScale: number; - moveGizmoSnapPlaneDragToGround: boolean; - moveGizmoShowRotationHandle: boolean; - isMoveGizmoDragging: boolean; - handleMoveGizmoPointPositionChange: ( - pointId: string, - nextPosition: Cartesian3 - ) => void; - setIsMoveGizmoDragging: (isDragging: boolean) => void; - handleMoveGizmoAxisChange: ( - axisDirection: Cartesian3, - axisTitle?: string | null - ) => void; - handleMoveGizmoExit: () => void; -}; - -// Current adapter scope: point/pure-label/selection plus distance family visuals. -export const useAnnotationVisualizerAdapter = ({ - scene, - annotations, - showPoints, - showPointLabels, - pointRadius, - referenceElevation, - selectedMeasurementId, - selectedMeasurementIds, - hiddenPointLabelIds, - fullyHiddenPointIds, - markerlessPointIds, - collapsedPillPointIds, - moveGizmoPointId, - selectionModeActive, - selectModeRectangle, - effectiveSelectModeAdditive, - selectMeasurementIds, - handlePointLabelClick, - handlePointLabelDoubleClick, - handlePointLabelLongPress, - handlePointLabelHoverChange, - handlePointVerticalOffsetStemLongPress, - pointLongPressDurationMs, - occlusionChecksEnabled, - labelLayoutConfig, - effectiveDistanceToReferenceByPointId, - pointMarkerBadgeByPointId, - labelInputPromptPointId, - markerOnlyOverlayNodeInteractions, - suppressLivePreviewLabelOverlay, - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - livePreviewDistanceLine, - showDistanceAndPolygonVisuals, - distanceRelations, - planarPolygonGroups, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - cumulativeDistanceByRelationId, - handleDistanceRelationLineLabelToggle, - handleDistanceRelationLineClick, - handleDistanceRelationMidpointClick, - handleDistanceRelationCornerClick, - referencePoint, - moveGizmoAxisDirection, - moveGizmoPreferredAxisId, - moveGizmoMarkerSizeScale, - moveGizmoLabelDistanceScale, - moveGizmoSnapPlaneDragToGround, - moveGizmoShowRotationHandle, - isMoveGizmoDragging, - handleMoveGizmoPointPositionChange, - setIsMoveGizmoDragging, - handleMoveGizmoAxisChange, - handleMoveGizmoExit, -}: AnnotationVisualizerAdapterOptions) => { - useDistanceVisualizerAdapter({ - scene, - enabled: showDistanceAndPolygonVisuals, - annotations, - distanceRelations, - planarPolygonGroups, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - onDistanceLineLabelToggle: handleDistanceRelationLineLabelToggle, - onDistanceLineClick: handleDistanceRelationLineClick, - onDistanceRelationMidpointClick: handleDistanceRelationMidpointClick, - onDistanceRelationCornerClick: handleDistanceRelationCornerClick, - cumulativeDistanceByRelationId, - pointMarkerBadgeByPointId, - livePreviewDistanceLine, - }); - - usePointMeasureVisualizer({ - scene, - annotations, - showMarkers: showPoints, - showLabels: showPointLabels, - radius: pointRadius, - referenceElevation, - selectedPointId: selectedMeasurementId, - selectedPointIds: selectedMeasurementIds, - hiddenPointLabelIds, - fullyHiddenPointIds, - markerlessPointIds, - pillMarkerPointIds: collapsedPillPointIds, - showSelectedDisc: Boolean(moveGizmoPointId), - onPointClick: handlePointLabelClick, - onPointDoubleClick: handlePointLabelDoubleClick, - onPointLongPress: handlePointLabelLongPress, - onPointHoverChange: handlePointLabelHoverChange, - onPointVerticalOffsetStemLongPress: handlePointVerticalOffsetStemLongPress, - selectionModeEnabled: selectionModeActive, - selectionRectangleModeEnabled: selectModeRectangle, - selectionAdditiveMode: effectiveSelectModeAdditive, - onPointRectangleSelect: selectMeasurementIds, - pointLongPressDurationMs, - occlusionChecksEnabled, - labelLayoutConfig, - distanceToReferenceByPointId: effectiveDistanceToReferenceByPointId, - pointMarkerBadgeByPointId, - labelInputPromptPointId, - markerOnlyOverlayNodeInteractions, - suppressLivePreviewLabelOverlay, - moveGizmoAxisDirection, - moveGizmoPreferredAxisId, - moveGizmoPointId, - moveGizmoMarkerSizeScale, - moveGizmoLabelDistanceScale, - livePreviewPointECEF, - livePreviewSurfaceNormalECEF, - livePreviewVerticalOffsetAnchorECEF, - livePreviewDistanceLine, - livePreviewReferenceElevation: referenceElevation, - livePreviewHasReferenceElevation: Boolean(referencePoint), - moveGizmoSnapPlaneDragToGround, - moveGizmoShowRotationHandle, - moveGizmoIsDragging: isMoveGizmoDragging, - onMoveGizmoPointPositionChange: handleMoveGizmoPointPositionChange, - onMoveGizmoDragStateChange: setIsMoveGizmoDragging, - onMoveGizmoAxisChange: handleMoveGizmoAxisChange, - onMoveGizmoExit: handleMoveGizmoExit, - }); -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizer.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizer.ts deleted file mode 100644 index cce9f6c104..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizer.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { type Scene } from "@carma/cesium"; -import { type PointAnnotationEntry } from "@carma-mapping/annotations/core"; - -import { - useCesiumDistanceVisualizer, - type CesiumDistanceVisualizerOptions, -} from "@carma-mapping/annotations/cesium"; - -export type DistanceVisualizerHookOptions = CesiumDistanceVisualizerOptions & { - scene: Scene | null; - points: PointAnnotationEntry[]; -}; - -export const useDistanceVisualizer = ({ - scene, - points, - ...options -}: DistanceVisualizerHookOptions) => { - const { - renderDomVisuals = true, - renderCesiumCoreVisuals = true, - ...distanceOptions - } = options; - useCesiumDistanceVisualizer(scene, points, { - ...distanceOptions, - renderDomVisuals, - renderCesiumCoreVisuals, - }); -}; - -export default useDistanceVisualizer; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizerAdapter.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizerAdapter.ts deleted file mode 100644 index 73e3fa67eb..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/useDistanceVisualizerAdapter.ts +++ /dev/null @@ -1,262 +0,0 @@ -import { useMemo } from "react"; - -import { Cartesian3, type Scene } from "@carma/cesium"; -import { - ANNOTATION_TYPE_AREA_VERTICAL, - buildGroundPolygonPreviewGroups, - isPointAnnotationEntry, - buildPlanarPolygonPreviewGroups, - buildPolylinePreviewMeasurements, - buildVerticalPolygonPreviewGroups, - type PolygonAreaBadge, - type AnnotationCollection, - type PlanarPolygonGroup, - type PointDistanceRelation, - type PointAnnotationEntry, - type ReferenceLineLabelKind, -} from "@carma-mapping/annotations/core"; - -import { - type PointMarkerBadge, - useCesiumGroundAreaVisualizer, - useCesiumPlanarAreaVisualizer, - useCesiumVerticalAreaVisualizer, - useCesiumPolylineVisualizer, -} from "@carma-mapping/annotations/cesium"; -import { buildDistanceRelationRenderContext } from "./annotationVisualizationContext"; -import { useDistanceVisualizer } from "./useDistanceVisualizer"; - -type DistanceLivePreviewLine = { - anchorPointECEF: PointAnnotationEntry["geometryECEF"]; - targetPointECEF: PointAnnotationEntry["geometryECEF"]; - showDirectLine: boolean; - showVerticalLine: boolean; - showHorizontalLine: boolean; -} | null; - -export type DistanceVisualizerAdapterOptions = { - scene: Scene | null; - enabled: boolean; - annotations: AnnotationCollection; - distanceRelations: PointDistanceRelation[]; - planarPolygonGroups: PlanarPolygonGroup[]; - selectedPlanarPolygonGroupId: string | null; - activePlanarPolygonGroupId: string | null; - onDistanceLineLabelToggle: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - onDistanceLineClick: ( - relationId: string, - kind: ReferenceLineLabelKind - ) => void; - onDistanceRelationMidpointClick: (relationId: string) => void; - onDistanceRelationCornerClick: (relationId: string) => void; - cumulativeDistanceByRelationId: Readonly>; - pointMarkerBadgeByPointId: Readonly>; - livePreviewDistanceLine: DistanceLivePreviewLine; - lineLabelMinDistancePx?: number; -}; - -export const useDistanceVisualizerAdapter = ({ - scene, - enabled, - annotations, - distanceRelations, - planarPolygonGroups, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - onDistanceLineLabelToggle, - onDistanceLineClick, - onDistanceRelationMidpointClick, - onDistanceRelationCornerClick, - cumulativeDistanceByRelationId, - pointMarkerBadgeByPointId, - livePreviewDistanceLine, - lineLabelMinDistancePx, -}: DistanceVisualizerAdapterOptions) => { - const points = useMemo( - () => annotations.filter(isPointAnnotationEntry), - [annotations] - ); - const pointsById = useMemo(() => { - const map = new Map(); - points.forEach((point) => { - map.set(point.id, point); - }); - return map; - }, [points]); - - const facadeRectanglePreviewOppositeByGroupId = useMemo(() => { - if (!enabled || !livePreviewDistanceLine) { - return undefined; - } - const target = livePreviewDistanceLine.targetPointECEF; - if (!target) { - return undefined; - } - const activeGroup = activePlanarPolygonGroupId - ? planarPolygonGroups.find( - (group) => group.id === activePlanarPolygonGroupId - ) - : null; - if (!activeGroup || activeGroup.closed) { - return undefined; - } - if (activeGroup.measurementKind !== ANNOTATION_TYPE_AREA_VERTICAL) { - return undefined; - } - if (activeGroup.vertexPointIds.length !== 1) { - return undefined; - } - return { - [activeGroup.id]: Cartesian3.clone(target), - }; - }, [ - activePlanarPolygonGroupId, - enabled, - livePreviewDistanceLine, - planarPolygonGroups, - ]); - - const distanceRelationRenderContext = useMemo( - () => - buildDistanceRelationRenderContext({ - planarPolygonGroups, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, - pointsById, - }), - [ - activePlanarPolygonGroupId, - planarPolygonGroups, - pointsById, - selectedPlanarPolygonGroupId, - ] - ); - - const polylineMeasurements = useMemo( - () => - buildPolylinePreviewMeasurements({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, - }), - [facadeRectanglePreviewOppositeByGroupId, planarPolygonGroups, pointsById] - ); - - const polygonAreaBadgeByGroupId = useMemo(() => { - const byGroupId: Record = {}; - planarPolygonGroups.forEach((group) => { - const firstVertexPointId = group.vertexPointIds[0] ?? null; - if (!firstVertexPointId) return; - const badge = pointMarkerBadgeByPointId?.[firstVertexPointId]; - const badgeText = badge?.text?.trim(); - if (!badgeText) return; - byGroupId[group.id] = { - text: badgeText, - backgroundColor: badge?.backgroundColor, - textColor: badge?.textColor, - }; - }); - return byGroupId; - }, [planarPolygonGroups, pointMarkerBadgeByPointId]); - - const focusedPolygonGroupId = - selectedPlanarPolygonGroupId ?? activePlanarPolygonGroupId; - - const groundPolygonPreviewGroups = useMemo( - () => - buildGroundPolygonPreviewGroups({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, - activePlanarPolygonGroupId, - livePreviewDistanceLine, - }), - [ - activePlanarPolygonGroupId, - facadeRectanglePreviewOppositeByGroupId, - livePreviewDistanceLine, - planarPolygonGroups, - pointsById, - ] - ); - - const verticalPolygonPreviewGroups = useMemo( - () => - buildVerticalPolygonPreviewGroups({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, - activePlanarPolygonGroupId, - livePreviewDistanceLine, - }), - [ - activePlanarPolygonGroupId, - facadeRectanglePreviewOppositeByGroupId, - livePreviewDistanceLine, - planarPolygonGroups, - pointsById, - ] - ); - - const planarPolygonPreviewGroups = useMemo( - () => - buildPlanarPolygonPreviewGroups({ - planarPolygonGroups, - pointsById, - facadeRectanglePreviewOppositeByGroupId, - activePlanarPolygonGroupId, - livePreviewDistanceLine, - }), - [ - activePlanarPolygonGroupId, - facadeRectanglePreviewOppositeByGroupId, - livePreviewDistanceLine, - planarPolygonGroups, - pointsById, - ] - ); - - useDistanceVisualizer({ - scene, - points, - distanceRelations: enabled ? distanceRelations : [], - onDistanceLineLabelToggle, - onDistanceLineClick, - onDistanceRelationMidpointClick, - onDistanceRelationCornerClick, - lineLabelMinDistancePx, - cumulativeDistanceByRelationId, - pointMarkerBadgeByPointId, - livePreviewDistanceLine: enabled ? livePreviewDistanceLine : null, - distanceRelationRenderContext, - }); - - useCesiumPolylineVisualizer({ - scene, - polylineMeasurements: enabled ? polylineMeasurements : [], - }); - - useCesiumGroundAreaVisualizer({ - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - groundPolygonPreviewGroups: enabled ? groundPolygonPreviewGroups : [], - }); - - useCesiumVerticalAreaVisualizer({ - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - verticalPolygonPreviewGroups: enabled ? verticalPolygonPreviewGroups : [], - }); - - useCesiumPlanarAreaVisualizer({ - scene, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - planarPolygonPreviewGroups: enabled ? planarPolygonPreviewGroups : [], - }); -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointCreateConfigState.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointCreateConfigState.ts deleted file mode 100644 index 1b1d016dc9..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointCreateConfigState.ts +++ /dev/null @@ -1,216 +0,0 @@ -import { type Dispatch, type SetStateAction, useEffect, useMemo } from "react"; - -import { - ANNOTATION_TYPE_DISTANCE, - ANNOTATION_TYPE_POINT, - ANNOTATION_TYPE_POLYLINE, - type AnnotationCollection, - type AnnotationLabelAnchor, - type AnnotationLabelAppearance, - type AnnotationMode, - type PointLabelMetricMode, -} from "@carma-mapping/annotations/core"; - -export const PURE_LABEL_DEFAULT_FONT_SIZE_PX = 12; -export const PURE_LABEL_DEFAULT_BACKGROUND_COLOR = "rgba(200, 200, 200, 0.7)"; -export const PURE_LABEL_DEFAULT_TEXT_COLOR = "#000000"; - -export type ActivePointCreateConfig = { - temporaryMode: boolean; - verticalOffsetMeters: number; - nameOnCreate: string | undefined; - labelOnCreate: PointLabelMetricMode | undefined; - hiddenOnCreate: boolean; - auxiliaryOnCreate: boolean; - labelAnchorOnCreate?: (pointId: string) => AnnotationLabelAnchor; - labelAppearanceOnCreate?: AnnotationLabelAppearance; - useTemporaryForCreatedPoints: boolean; - markCreatedPointsAsDistanceAdhoc: boolean; -}; - -type BuildActivePointCreateConfigParams = { - annotationMode: AnnotationMode; - isPointMeasureCreateModeActive: boolean; - isPointMeasureLabelModeActive: boolean; - temporaryMode: boolean; - pointVerticalOffsetMeters: number; - lastCustomLabelOnCreate?: string; - previewIsPolylineCreateMode: boolean; - polylineVerticalOffsetMeters: number; -}; - -const buildActivePointCreateConfig = ({ - annotationMode, - isPointMeasureCreateModeActive, - isPointMeasureLabelModeActive, - temporaryMode, - pointVerticalOffsetMeters, - lastCustomLabelOnCreate, - previewIsPolylineCreateMode, - polylineVerticalOffsetMeters, -}: BuildActivePointCreateConfigParams): ActivePointCreateConfig | null => { - if (annotationMode === ANNOTATION_TYPE_POINT) { - return { - temporaryMode: isPointMeasureCreateModeActive ? temporaryMode : false, - verticalOffsetMeters: isPointMeasureCreateModeActive - ? pointVerticalOffsetMeters - : 0, - nameOnCreate: isPointMeasureLabelModeActive - ? lastCustomLabelOnCreate - : undefined, - labelOnCreate: isPointMeasureLabelModeActive - ? ("none" as const) - : ("elevation" as const), - hiddenOnCreate: isPointMeasureLabelModeActive, - auxiliaryOnCreate: isPointMeasureLabelModeActive, - labelAnchorOnCreate: (pointId: string): AnnotationLabelAnchor => ({ - anchorPointId: pointId, - collapseToCompact: false, - }), - labelAppearanceOnCreate: isPointMeasureLabelModeActive - ? { - fontSizePx: PURE_LABEL_DEFAULT_FONT_SIZE_PX, - backgroundColor: PURE_LABEL_DEFAULT_BACKGROUND_COLOR, - textColor: PURE_LABEL_DEFAULT_TEXT_COLOR, - } - : undefined, - useTemporaryForCreatedPoints: isPointMeasureCreateModeActive, - markCreatedPointsAsDistanceAdhoc: false, - }; - } - - if (annotationMode === ANNOTATION_TYPE_DISTANCE) { - return { - temporaryMode: false, - verticalOffsetMeters: 0, - nameOnCreate: undefined, - labelOnCreate: undefined, - hiddenOnCreate: false, - auxiliaryOnCreate: false, - labelAnchorOnCreate: undefined, - labelAppearanceOnCreate: undefined, - useTemporaryForCreatedPoints: true, - markCreatedPointsAsDistanceAdhoc: true, - }; - } - - if (annotationMode === ANNOTATION_TYPE_POLYLINE) { - return { - temporaryMode: false, - verticalOffsetMeters: previewIsPolylineCreateMode - ? polylineVerticalOffsetMeters - : 0, - nameOnCreate: undefined, - labelOnCreate: undefined, - hiddenOnCreate: false, - auxiliaryOnCreate: false, - labelAnchorOnCreate: (pointId: string): AnnotationLabelAnchor => ({ - anchorPointId: pointId, - collapseToCompact: false, - }), - labelAppearanceOnCreate: undefined, - useTemporaryForCreatedPoints: true, - markCreatedPointsAsDistanceAdhoc: false, - }; - } - - return null; -}; - -type UsePointCreateConfigStateParams = { - annotationMode: AnnotationMode; - pointLabelOnCreate: boolean; - labelInputPromptPointId: string | null; - setLabelInputPromptPointId: Dispatch>; - annotations: AnnotationCollection; - temporaryMode: boolean; - pointVerticalOffsetMeters: number; - lastCustomLabelOnCreate?: string; - previewIsPolylineCreateMode: boolean; - polylineVerticalOffsetMeters: number; -}; - -type UsePointCreateConfigStateResult = { - isPointMeasureLabelModeActive: boolean; - isPointMeasureLabelInputPending: boolean; - isPointMeasureCreateModeActive: boolean; - pointQueryToolActive: boolean; - activePointCreateConfig: ActivePointCreateConfig | null; -}; - -export const usePointCreateConfigState = ({ - annotationMode, - pointLabelOnCreate, - labelInputPromptPointId, - setLabelInputPromptPointId, - annotations, - temporaryMode, - pointVerticalOffsetMeters, - lastCustomLabelOnCreate, - previewIsPolylineCreateMode, - polylineVerticalOffsetMeters, -}: UsePointCreateConfigStateParams): UsePointCreateConfigStateResult => { - const isPointMeasureLabelModeActive = - pointLabelOnCreate && annotationMode === ANNOTATION_TYPE_POINT; - const isPointMeasureLabelInputPending = - isPointMeasureLabelModeActive && labelInputPromptPointId !== null; - const isPointMeasureCreateModeActive = - !pointLabelOnCreate && annotationMode === ANNOTATION_TYPE_POINT; - const pointQueryToolActive = - !isPointMeasureLabelInputPending && - (annotationMode === ANNOTATION_TYPE_DISTANCE || - annotationMode === ANNOTATION_TYPE_POLYLINE || - annotationMode === ANNOTATION_TYPE_POINT); - - const activePointCreateConfig = useMemo( - () => - buildActivePointCreateConfig({ - annotationMode, - isPointMeasureCreateModeActive, - isPointMeasureLabelModeActive, - temporaryMode, - pointVerticalOffsetMeters, - lastCustomLabelOnCreate, - previewIsPolylineCreateMode, - polylineVerticalOffsetMeters, - }), - [ - annotationMode, - isPointMeasureCreateModeActive, - isPointMeasureLabelModeActive, - temporaryMode, - pointVerticalOffsetMeters, - lastCustomLabelOnCreate, - previewIsPolylineCreateMode, - polylineVerticalOffsetMeters, - ] - ); - - useEffect(() => { - if (annotationMode !== ANNOTATION_TYPE_POINT) { - setLabelInputPromptPointId(null); - return; - } - if (!pointLabelOnCreate) { - setLabelInputPromptPointId(null); - } - }, [annotationMode, pointLabelOnCreate, setLabelInputPromptPointId]); - - useEffect(() => { - if (!labelInputPromptPointId) return; - const hasPromptMeasurement = annotations.some( - (measurement) => measurement.id === labelInputPromptPointId - ); - if (!hasPromptMeasurement) { - setLabelInputPromptPointId(null); - } - }, [labelInputPromptPointId, annotations, setLabelInputPromptPointId]); - - return { - isPointMeasureLabelModeActive, - isPointMeasureLabelInputPending, - isPointMeasureCreateModeActive, - pointQueryToolActive, - activePointCreateConfig, - }; -}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointMeasureVisualizer.ts b/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointMeasureVisualizer.ts deleted file mode 100644 index 8f3fee0db2..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointMeasureVisualizer.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { type Scene } from "@carma/cesium"; -import { type AnnotationCollection } from "@carma-mapping/annotations/core"; - -import { - useCesiumPointVisualizer, - type CesiumPointVisualizerOptions, - useCesiumPointDomVisualizer, -} from "@carma-mapping/annotations/cesium"; - -export type PointMeasureVisualizerHookOptions = CesiumPointVisualizerOptions & { - scene: Scene | null; - annotations?: AnnotationCollection; -}; - -export const usePointMeasureVisualizer = ({ - scene, - annotations = [], - ...options -}: PointMeasureVisualizerHookOptions) => { - const { - renderDomVisuals = true, - renderCesiumCoreVisuals = true, - ...pointOptions - } = options; - useCesiumPointVisualizer(scene, annotations, { - ...pointOptions, - renderCesiumCoreVisuals, - }); - - useCesiumPointDomVisualizer(scene, annotations, { - ...pointOptions, - renderDomVisuals, - }); -}; - -export default usePointMeasureVisualizer; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useAnnotationCursorCandidateState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useAnnotationCursorCandidateState.ts new file mode 100644 index 0000000000..90e0cf047f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useAnnotationCursorCandidateState.ts @@ -0,0 +1,174 @@ +import { useMemo, type Dispatch, type SetStateAction } from "react"; +import { + getPositionWithVerticalOffsetFromAnchor, + type Scene, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + type AnnotationCollection, + type AnnotationToolType, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +import { + ANNOTATION_CANDIDATE_KIND_DISTANCE, + ANNOTATION_CANDIDATE_KIND_NONE, + ANNOTATION_CANDIDATE_KIND_POINT, + ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND, + ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR, + ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + ANNOTATION_CANDIDATE_KIND_POLYLINE, + type AnnotationCandidateDescriptor, + useAnnotationCandidateState, +} from "../useAnnotationCandidateState"; + +type UseAnnotationCursorCandidateStateParams = { + scene: Scene; + annotations: AnnotationCollection; + activeToolType: AnnotationToolType; + activeNodeChainAnnotationId: string | null; + labelInputPromptPointId: string | null; + nodeChainAnnotations: NodeChainAnnotation[]; + pointVerticalOffsetMeters: number; + polylineVerticalOffsetMeters: number; + pointQueryEnabled: boolean; + moveGizmoPointId: string | null; + isMoveGizmoDragging: boolean; + setNodeChainAnnotations: Dispatch>; +}; + +export const useAnnotationCursorCandidateState = ({ + scene, + annotations, + activeToolType, + activeNodeChainAnnotationId, + labelInputPromptPointId, + nodeChainAnnotations, + pointVerticalOffsetMeters, + polylineVerticalOffsetMeters, + pointQueryEnabled, + moveGizmoPointId, + isMoveGizmoDragging, + setNodeChainAnnotations, +}: UseAnnotationCursorCandidateStateParams) => { + const annotationCandidateDescriptor = + useMemo(() => { + if (activeToolType === ANNOTATION_TYPE_POINT) { + return { + kind: ANNOTATION_CANDIDATE_KIND_POINT, + verticalOffsetMeters: pointVerticalOffsetMeters, + }; + } + + if (activeToolType === ANNOTATION_TYPE_LABEL) { + if (labelInputPromptPointId) { + return { + kind: ANNOTATION_CANDIDATE_KIND_NONE, + verticalOffsetMeters: 0, + }; + } + return { + kind: ANNOTATION_CANDIDATE_KIND_POINT, + verticalOffsetMeters: 0, + }; + } + + if (activeToolType === ANNOTATION_TYPE_DISTANCE) { + return { + kind: ANNOTATION_CANDIDATE_KIND_DISTANCE, + verticalOffsetMeters: 0, + }; + } + + if (activeToolType === ANNOTATION_TYPE_POLYLINE) { + return { + kind: ANNOTATION_CANDIDATE_KIND_POLYLINE, + verticalOffsetMeters: polylineVerticalOffsetMeters, + }; + } + + if ( + activeToolType !== ANNOTATION_TYPE_AREA_GROUND && + activeToolType !== ANNOTATION_TYPE_AREA_VERTICAL && + activeToolType !== ANNOTATION_TYPE_AREA_PLANAR + ) { + return { + kind: ANNOTATION_CANDIDATE_KIND_NONE, + verticalOffsetMeters: 0, + }; + } + + const activeOpenPolygonGroup = activeNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId && !group.closed + ) ?? null + : null; + const effectiveType = activeOpenPolygonGroup?.type ?? activeToolType; + + if (effectiveType === ANNOTATION_TYPE_AREA_VERTICAL) { + const firstNodeId = + activeOpenPolygonGroup?.nodeIds.length === 1 + ? activeOpenPolygonGroup.nodeIds[0] + : null; + if (firstNodeId && activeOpenPolygonGroup) { + return { + kind: ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + verticalOffsetMeters: 0, + verticalPolygonContext: { + groupId: activeOpenPolygonGroup.id, + firstNodeId, + }, + }; + } + return { + kind: ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + verticalOffsetMeters: 0, + }; + } + + if (effectiveType === ANNOTATION_TYPE_AREA_GROUND) { + return { + kind: ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND, + verticalOffsetMeters: 0, + }; + } + + if (effectiveType === ANNOTATION_TYPE_AREA_PLANAR) { + return { + kind: ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR, + verticalOffsetMeters: 0, + }; + } + + return { + kind: ANNOTATION_CANDIDATE_KIND_NONE, + verticalOffsetMeters: 0, + }; + }, [ + activeToolType, + activeNodeChainAnnotationId, + labelInputPromptPointId, + nodeChainAnnotations, + pointVerticalOffsetMeters, + polylineVerticalOffsetMeters, + ]); + + return useAnnotationCandidateState( + scene, + annotations, + annotationCandidateDescriptor, + { + pointQueryEnabled, + moveGizmoPointId, + isMoveGizmoDragging, + setNodeChainAnnotations, + getPositionWithVerticalOffsetFromAnchor, + } + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewAnnotation.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewAnnotation.ts new file mode 100644 index 0000000000..8b6bac5974 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewAnnotation.ts @@ -0,0 +1,54 @@ +import { useMemo } from "react"; + +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POINT, + type AnnotationEntry, + type AnnotationToolType, +} from "@carma-mapping/annotations/core"; + +export const useCandidatePreviewAnnotation = ( + activeToolType: AnnotationToolType, + activeCandidateNodeECEF: Cartesian3 | null +): AnnotationEntry | null => + useMemo(() => { + const isPointCandidateMode = activeToolType === ANNOTATION_TYPE_POINT; + const isDistanceCandidateMode = activeToolType === ANNOTATION_TYPE_DISTANCE; + + if (!isPointCandidateMode && !isDistanceCandidateMode) { + return null; + } + if (!activeCandidateNodeECEF) { + return null; + } + + const previewPoint = getDegreesFromCartesian(activeCandidateNodeECEF); + if ( + !Number.isFinite(previewPoint.latitude) || + !Number.isFinite(previewPoint.longitude) + ) { + return null; + } + + const previewMeasurementType = isDistanceCandidateMode + ? ANNOTATION_TYPE_DISTANCE + : ANNOTATION_TYPE_POINT; + + return { + id: "__candidate-measurement__", + type: previewMeasurementType, + timestamp: -1, + isCandidate: true, + geometryECEF: Cartesian3.clone(activeCandidateNodeECEF), + geometryWGS84: { + latitude: previewPoint.latitude, + longitude: previewPoint.longitude, + altitude: getEllipsoidalAltitudeOrZero(previewPoint.altitude), + }, + }; + }, [activeCandidateNodeECEF, activeToolType]); diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewState.ts new file mode 100644 index 0000000000..e8627f5645 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useCandidatePreviewState.ts @@ -0,0 +1,198 @@ +import { useMemo } from "react"; +import { Cartesian3 } from "@carma/cesium"; +import { + ANNOTATION_TYPE_DISTANCE, + LINEAR_SEGMENT_LINE_MODE_COMPONENTS, + LINEAR_SEGMENT_LINE_MODE_DIRECT, + getPointById, + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationToolType, + type CandidateConnectionPreview, + type LinearSegmentLineMode, +} from "@carma-mapping/annotations/core"; + +type UseCandidatePreviewStateParams = { + activeToolType: AnnotationToolType; + distanceModeStickyToFirstPoint: boolean; + referencePointMeasurementId: string | null; + doubleClickChainSourcePointId: string | null; + selectablePointIds: ReadonlySet; + moveGizmoPointId: string | null; + selectedAnnotationId: string | null; + candidateSupportsEdgeLine: boolean; + resolveDistanceRelationSourcePointId: ( + targetPointId: string + ) => string | null; + activeCandidateNodeECEF: Cartesian3 | null; + annotations: AnnotationCollection; + candidateForcesDirectEdgeLine: boolean; + candidateUsesPolylineEdgeRules: boolean; + polylineSegmentLineMode: LinearSegmentLineMode; + distanceCreationLineVisibility: { + direct: boolean; + vertical: boolean; + horizontal: boolean; + }; + isPolylineCandidateMode: boolean; + focusedPolylineDistanceToStartByPointId: Readonly>; +}; + +export const useCandidatePreviewState = ({ + activeToolType, + distanceModeStickyToFirstPoint, + referencePointMeasurementId, + doubleClickChainSourcePointId, + selectablePointIds, + moveGizmoPointId, + selectedAnnotationId, + candidateSupportsEdgeLine, + resolveDistanceRelationSourcePointId, + activeCandidateNodeECEF, + annotations, + candidateForcesDirectEdgeLine, + candidateUsesPolylineEdgeRules, + polylineSegmentLineMode, + distanceCreationLineVisibility, + isPolylineCandidateMode, + focusedPolylineDistanceToStartByPointId, +}: UseCandidatePreviewStateParams) => { + const candidateAnchorPointId = useMemo(() => { + if (!candidateSupportsEdgeLine) return null; + return resolveDistanceRelationSourcePointId("__candidate-target__"); + }, [candidateSupportsEdgeLine, resolveDistanceRelationSourcePointId]); + + const hasDistancePreviewAnchor = useMemo(() => { + if (activeToolType !== ANNOTATION_TYPE_DISTANCE) { + return false; + } + + if (distanceModeStickyToFirstPoint && referencePointMeasurementId) { + return true; + } + + return Boolean( + doubleClickChainSourcePointId && + selectablePointIds.has(doubleClickChainSourcePointId) + ); + }, [ + activeToolType, + distanceModeStickyToFirstPoint, + doubleClickChainSourcePointId, + selectablePointIds, + referencePointMeasurementId, + ]); + + const activeMeasurementId = useMemo(() => { + if (moveGizmoPointId && selectablePointIds.has(moveGizmoPointId)) { + return moveGizmoPointId; + } + + if (candidateAnchorPointId) { + return candidateAnchorPointId; + } + + if ( + doubleClickChainSourcePointId && + selectablePointIds.has(doubleClickChainSourcePointId) + ) { + return doubleClickChainSourcePointId; + } + + if (selectedAnnotationId && selectablePointIds.has(selectedAnnotationId)) { + return selectedAnnotationId; + } + + return null; + }, [ + candidateAnchorPointId, + doubleClickChainSourcePointId, + moveGizmoPointId, + selectablePointIds, + selectedAnnotationId, + ]); + + const { candidateConnectionPreview, candidatePreviewDistanceMeters } = + useMemo<{ + candidateConnectionPreview: CandidateConnectionPreview | null; + candidatePreviewDistanceMeters: number | undefined; + }>(() => { + if (!activeCandidateNodeECEF || !candidateAnchorPointId) { + return { + candidateConnectionPreview: null, + candidatePreviewDistanceMeters: undefined, + }; + } + + const sourcePoint = getPointById(annotations, candidateAnchorPointId); + if (!sourcePoint || !isPointAnnotationEntry(sourcePoint)) { + return { + candidateConnectionPreview: null, + candidatePreviewDistanceMeters: undefined, + }; + } + + const showDirectLine = candidateForcesDirectEdgeLine + ? true + : candidateUsesPolylineEdgeRules + ? polylineSegmentLineMode === LINEAR_SEGMENT_LINE_MODE_DIRECT + : distanceCreationLineVisibility.direct; + const showComponentLines = candidateForcesDirectEdgeLine + ? false + : candidateUsesPolylineEdgeRules + ? polylineSegmentLineMode === LINEAR_SEGMENT_LINE_MODE_COMPONENTS + : distanceCreationLineVisibility.vertical || + distanceCreationLineVisibility.horizontal; + const showVerticalLine = candidateUsesPolylineEdgeRules + ? showComponentLines + : distanceCreationLineVisibility.vertical; + const showHorizontalLine = candidateUsesPolylineEdgeRules + ? showComponentLines + : distanceCreationLineVisibility.horizontal; + + if (!showDirectLine && !showVerticalLine && !showHorizontalLine) { + return { + candidateConnectionPreview: null, + candidatePreviewDistanceMeters: undefined, + }; + } + + return { + candidateConnectionPreview: { + anchorPointECEF: Cartesian3.clone(sourcePoint.geometryECEF), + targetPointECEF: Cartesian3.clone(activeCandidateNodeECEF), + showDirectLine, + showVerticalLine, + showHorizontalLine, + }, + candidatePreviewDistanceMeters: isPolylineCandidateMode + ? (focusedPolylineDistanceToStartByPointId[candidateAnchorPointId] ?? + 0) + + Cartesian3.distance( + sourcePoint.geometryECEF, + activeCandidateNodeECEF + ) + : undefined, + }; + }, [ + activeCandidateNodeECEF, + candidateAnchorPointId, + candidateForcesDirectEdgeLine, + candidateUsesPolylineEdgeRules, + annotations, + distanceCreationLineVisibility.direct, + distanceCreationLineVisibility.horizontal, + distanceCreationLineVisibility.vertical, + focusedPolylineDistanceToStartByPointId, + isPolylineCandidateMode, + polylineSegmentLineMode, + ]); + + return { + candidateAnchorPointId, + hasDistancePreviewAnchor, + activeMeasurementId, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateDomOverlay.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateDomOverlay.ts new file mode 100644 index 0000000000..0ab51b42d8 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateDomOverlay.ts @@ -0,0 +1,290 @@ +import { useEffect, useMemo, useRef, useState } from "react"; + +import { + Cartesian3, + SceneTransforms, + defined, + type Scene, +} from "@carma/cesium"; +import { formatNumber } from "@carma-mapping/annotations/core"; +import { + createPlacement, + getPerspectiveStemAngleMagnitude, + type PointLabelData, + resolvePointLabelLayoutConfig, + type LineVisualizerData, + type PointLabelLayoutConfigOverrides, + useLineVisualizers, + usePointLabels, +} from "@carma-providers/label-overlay"; +import type { CssPixelPosition } from "@carma/units/types"; + +const CANDIDATE_HEIGHT_LABEL_ID = "measurement-candidate-height"; +const CANDIDATE_VERTICAL_OFFSET_STEM_ID = + "measurement-candidate-vertical-offset-stem"; + +const ELEVATION_NEUTRAL_THRESHOLD_METERS = 0.03; +const ELEVATION_GLYPH_UP = "↥"; +const ELEVATION_GLYPH_DOWN = "↧"; + +const CANDIDATE_HEIGHT_LABEL_ANCHOR_DISTANCE_PX = 24; +const CANDIDATE_HEIGHT_LABEL_STEM_START_DISTANCE_PX = 8; +const CANDIDATE_HEIGHT_LABEL_STEM_DISTANCE_PX = Math.max( + 0, + CANDIDATE_HEIGHT_LABEL_ANCHOR_DISTANCE_PX - + CANDIDATE_HEIGHT_LABEL_STEM_START_DISTANCE_PX +); +const CANDIDATE_PILL_STEM_EXTRA_DISTANCE_PX = 4; + +const formatMeters = (value: number): string => `${formatNumber(value)}m`; + +const formatCandidateElevationText = ( + pointHeightMeters: number, + referenceElevation: number, + hasReferenceElevation: boolean +): string => { + if (!hasReferenceElevation) { + return formatMeters(pointHeightMeters); + } + + const elevationDelta = pointHeightMeters - referenceElevation; + const elevationText = formatMeters(elevationDelta); + + if (Math.abs(elevationDelta) < ELEVATION_NEUTRAL_THRESHOLD_METERS) { + return elevationText; + } + + return `${elevationText} ${ + elevationDelta > 0 ? ELEVATION_GLYPH_UP : ELEVATION_GLYPH_DOWN + }`; +}; + +export type PointCandidateDomOverlayOptions = { + labelLayoutConfig?: PointLabelLayoutConfigOverrides; + renderDomVisuals?: boolean; +}; + +export type PointCandidateDomOverlayCandidate = { + pointECEF?: Cartesian3 | null; + verticalOffsetAnchorECEF?: Cartesian3 | null; + previewDistanceMeters?: number; + referenceElevation?: number; + hasReferenceElevation?: boolean; + suppressLabelOverlay?: boolean; +} | null; + +export const usePointCandidateDomOverlay = ( + scene: Scene | null, + candidate: PointCandidateDomOverlayCandidate = null, + { + labelLayoutConfig, + renderDomVisuals = true, + }: PointCandidateDomOverlayOptions = {} +) => { + const candidatePointECEF = candidate?.pointECEF ?? null; + const candidateVerticalOffsetAnchorECEF = + candidate?.verticalOffsetAnchorECEF ?? null; + const previewDistanceMeters = candidate?.previewDistanceMeters; + const candidateReferenceElevation = candidate?.referenceElevation ?? 0; + const candidateHasReferenceElevation = + candidate?.hasReferenceElevation ?? false; + const suppressCandidateLabelOverlay = + candidate?.suppressLabelOverlay ?? false; + const candidateElevatedPointRef = useRef(null); + const candidateAuxAnchorRef = useRef(null); + + const hasCandidatePoint = Boolean(candidatePointECEF); + const hasCandidateAuxAnchor = Boolean(candidateVerticalOffsetAnchorECEF); + const [cameraPitch, setCameraPitch] = useState(-Math.PI / 4); + + candidateElevatedPointRef.current = candidatePointECEF; + candidateAuxAnchorRef.current = candidateVerticalOffsetAnchorECEF; + + const candidateLabelLayoutConfig = useMemo( + () => resolvePointLabelLayoutConfig(labelLayoutConfig), + [labelLayoutConfig] + ); + + const candidateHeightLabelPlacement = useMemo( + () => + createPlacement( + "left", + CANDIDATE_HEIGHT_LABEL_STEM_DISTANCE_PX, + getPerspectiveStemAngleMagnitude( + cameraPitch, + candidateLabelLayoutConfig + ) + ), + [cameraPitch, candidateLabelLayoutConfig] + ); + + useEffect(() => { + if ( + !renderDomVisuals || + !scene || + scene.isDestroyed() || + !hasCandidatePoint + ) { + return; + } + + const camera = scene.camera; + const updatePitch = () => { + const currentPitch = camera.pitch; + setCameraPitch((previousPitch) => + Math.abs(currentPitch - previousPitch) > 0.001 + ? currentPitch + : previousPitch + ); + }; + + updatePitch(); + const removeChangedListener = camera.changed.addEventListener(updatePitch); + const removeMoveEndListener = camera.moveEnd.addEventListener(updatePitch); + + return () => { + removeChangedListener?.(); + removeMoveEndListener?.(); + }; + }, [scene, hasCandidatePoint, renderDomVisuals]); + + const candidateHeightLabelData = useMemo(() => { + if ( + !renderDomVisuals || + suppressCandidateLabelOverlay || + !scene || + scene.isDestroyed() || + !candidatePointECEF + ) { + return []; + } + + const cartographic = + scene.globe.ellipsoid.cartesianToCartographic(candidatePointECEF); + if (!cartographic) { + return []; + } + + const pointHeightMeters = cartographic.height ?? 0; + const showsDistancePreview = previewDistanceMeters !== undefined; + const text = showsDistancePreview + ? formatMeters(previewDistanceMeters) + : formatCandidateElevationText( + pointHeightMeters, + candidateReferenceElevation, + candidateHasReferenceElevation + ); + + return [ + { + id: CANDIDATE_HEIGHT_LABEL_ID, + getCanvasPosition: () => { + if (!scene || scene.isDestroyed()) { + return null; + } + const elevatedPoint = candidateElevatedPointRef.current; + if (!elevatedPoint) { + return null; + } + const canvasPosition = SceneTransforms.worldToWindowCoordinates( + scene, + elevatedPoint + ); + if (!defined(canvasPosition)) { + return null; + } + return { + x: canvasPosition.x, + y: canvasPosition.y, + } as CssPixelPosition; + }, + content: text, + collapse: true, + fullBorder: showsDistancePreview, + resizeMode: "fast-grow-slow-shrink", + pitch: cameraPitch, + labelAngleRad: candidateHeightLabelPlacement.angleRad, + labelAttach: candidateHeightLabelPlacement.attach, + hideMarker: true, + labelDistance: + candidateHeightLabelPlacement.distance + + CANDIDATE_PILL_STEM_EXTRA_DISTANCE_PX, + stemStartDistance: CANDIDATE_HEIGHT_LABEL_STEM_START_DISTANCE_PX, + }, + ]; + }, [ + cameraPitch, + candidateHeightLabelPlacement, + renderDomVisuals, + suppressCandidateLabelOverlay, + scene, + candidatePointECEF, + previewDistanceMeters, + candidateHasReferenceElevation, + candidateReferenceElevation, + ]); + + const candidateVerticalOffsetStemLines = useMemo(() => { + if ( + !renderDomVisuals || + !scene || + scene.isDestroyed() || + !hasCandidatePoint || + !hasCandidateAuxAnchor + ) { + return []; + } + + return [ + { + id: CANDIDATE_VERTICAL_OFFSET_STEM_ID, + stroke: "rgba(255, 255, 255, 1)", + strokeWidth: 2, + strokeDasharray: "0 3", + strokeDashoffset: 0, + opacity: 0.9, + visible: true, + getCanvasLine: () => { + if (!scene || scene.isDestroyed()) { + return null; + } + const elevatedPoint = candidateElevatedPointRef.current; + const auxAnchorPoint = candidateAuxAnchorRef.current; + if (!elevatedPoint || !auxAnchorPoint) { + return null; + } + const start = SceneTransforms.worldToWindowCoordinates( + scene, + elevatedPoint + ); + const end = SceneTransforms.worldToWindowCoordinates( + scene, + auxAnchorPoint + ); + if (!defined(start) || !defined(end)) { + return null; + } + return { + start: { x: start.x, y: start.y } as CssPixelPosition, + end: { x: end.x, y: end.y } as CssPixelPosition, + }; + }, + } satisfies LineVisualizerData, + ]; + }, [renderDomVisuals, scene, hasCandidatePoint, hasCandidateAuxAnchor]); + + useLineVisualizers( + candidateVerticalOffsetStemLines, + renderDomVisuals && candidateVerticalOffsetStemLines.length > 0 + ); + + usePointLabels( + candidateHeightLabelData, + renderDomVisuals && hasCandidatePoint && !suppressCandidateLabelOverlay, + undefined, + undefined, + { + transitionDurationMs: 0, + } + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateRingIndicator.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateRingIndicator.ts new file mode 100644 index 0000000000..a037393bec --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/usePointCandidateRingIndicator.ts @@ -0,0 +1,225 @@ +/* @refresh reset */ +import { useEffect, useMemo, useRef } from "react"; + +import { + Cartesian3, + Color, + Primitive, + GUIDE_NORMAL_EPSILON_SQUARED, + createOrientedDiscModelMatrix, + getDiscWorldRadius, + isValidScene, + resolveDiscNormal, + safeCall, + safeRemovePrimitive, + type Scene, +} from "@carma/cesium"; +import { createRing } from "@carma-mapping/engines/cesium/primitives"; +import { + type CandidateRingSample, + getAveragedCandidateRingNormal, + pushCandidateRingSample, +} from "@carma-mapping/annotations/core"; + +const CANDIDATE_RING_RADIUS_SCALE = 1.4; +const CANDIDATE_RING_ALPHA = 0.66; +const CANDIDATE_RING_SCREEN_RADIUS_PX = 48; +const CANDIDATE_RING_SMOOTHING_SAMPLE_COUNT = 10; +const CANDIDATE_RING_SMOOTHING_WINDOW_MS = 300; + +type CandidateRingQueuedInput = { + pointRef: Cartesian3 | null; + surfaceNormalRef: Cartesian3 | null; +}; + +export type PointCandidateGuide = { + pointECEF?: Cartesian3 | null; + surfaceNormalECEF?: Cartesian3 | null; + verticalOffsetAnchorECEF?: Cartesian3 | null; +}; + +export type PointCandidateRingIndicatorOptions = { + radius: number; + enabled?: boolean; +}; + +export const usePointCandidateRingIndicator = ( + scene: Scene | null, + candidate: PointCandidateGuide | null = null, + { radius, enabled = true }: PointCandidateRingIndicatorOptions +) => { + const candidatePointECEF = candidate?.pointECEF ?? null; + const candidateSurfaceNormalECEF = candidate?.surfaceNormalECEF ?? null; + const candidateVerticalOffsetAnchorECEF = + candidate?.verticalOffsetAnchorECEF ?? null; + const candidateRingRef = useRef(null); + const removeCandidateRingPostRenderListenerRef = useRef<(() => void) | null>( + null + ); + const candidatePointRef = useRef(null); + const candidateSurfaceNormalRef = useRef(null); + const candidateRingSamplesRef = useRef([]); + const candidateRingLastQueuedInputRef = + useRef(null); + const candidateRingColor = useMemo( + () => Color.WHITE.withAlpha(CANDIDATE_RING_ALPHA), + [] + ); + + candidatePointRef.current = + candidateVerticalOffsetAnchorECEF ?? candidatePointECEF; + candidateSurfaceNormalRef.current = candidateSurfaceNormalECEF; + + useEffect(() => { + if (!isValidScene(scene)) return; + + safeCall(removeCandidateRingPostRenderListenerRef.current); + removeCandidateRingPostRenderListenerRef.current = null; + + const candidateRingRadius = Math.max( + radius * CANDIDATE_RING_RADIUS_SCALE, + 0.1 + ); + const averagedNormal = new Cartesian3(); + + const clearCandidateRing = () => { + if (candidateRingRef.current) { + safeRemovePrimitive(scene, candidateRingRef.current); + } + candidateRingRef.current = null; + candidateRingSamplesRef.current = []; + candidateRingLastQueuedInputRef.current = null; + }; + + const ensureCandidateRing = () => { + const center = candidatePointRef.current; + if (!center) { + clearCandidateRing(); + return null; + } + + let ring = candidateRingRef.current; + if (!ring) { + const nextRing = createRing("measurement-candidate-point-ring", { + radius: 1, + innerRadius: 0.5, + color: candidateRingColor, + segments: 20, + }); + scene.primitives.add(nextRing); + candidateRingRef.current = nextRing; + ring = nextRing; + } + return ring; + }; + + const shouldQueueCurrentCandidateSample = () => { + const currentInput: CandidateRingQueuedInput = { + pointRef: candidatePointRef.current, + surfaceNormalRef: candidateSurfaceNormalRef.current, + }; + const previousInput = candidateRingLastQueuedInputRef.current; + const hasInputChanged = + !previousInput || + previousInput.pointRef !== currentInput.pointRef || + previousInput.surfaceNormalRef !== currentInput.surfaceNormalRef; + if (!hasInputChanged) { + return false; + } + candidateRingLastQueuedInputRef.current = currentInput; + return true; + }; + + const queueCandidateSample = (normal: Cartesian3) => { + pushCandidateRingSample({ + samples: candidateRingSamplesRef.current, + normal, + maxSampleCount: CANDIDATE_RING_SMOOTHING_SAMPLE_COUNT, + timestampMs: performance.now(), + }); + }; + + const getAveragedCandidateNormal = (fallbackNormal: Cartesian3) => { + return getAveragedCandidateRingNormal({ + samples: candidateRingSamplesRef.current, + fallbackNormal, + result: averagedNormal, + epsilonSquared: GUIDE_NORMAL_EPSILON_SQUARED, + maxSampleAgeMs: CANDIDATE_RING_SMOOTHING_WINDOW_MS, + nowMs: performance.now(), + }); + }; + + if (!enabled) { + clearCandidateRing(); + scene.requestRender(); + return; + } + + ensureCandidateRing(); + + const updateCandidateRing = () => { + if (!isValidScene(scene)) { + return; + } + + const center = candidatePointRef.current; + if (!center) { + clearCandidateRing(); + return; + } + + const discNormal = resolveDiscNormal( + center, + candidateSurfaceNormalRef.current + ); + const sampledRadius = getDiscWorldRadius( + scene, + center, + discNormal, + candidateRingRadius, + CANDIDATE_RING_SCREEN_RADIUS_PX + ); + const activeRing = candidateRingRef.current ?? ensureCandidateRing(); + if (!activeRing) { + return; + } + + if (shouldQueueCurrentCandidateSample()) { + queueCandidateSample(discNormal); + } + const averagedCandidateNormal = getAveragedCandidateNormal(discNormal); + activeRing.modelMatrix = createOrientedDiscModelMatrix( + center, + averagedCandidateNormal, + sampledRadius, + activeRing.modelMatrix + ); + }; + + updateCandidateRing(); + + removeCandidateRingPostRenderListenerRef.current = + scene.postRender.addEventListener(updateCandidateRing); + scene.requestRender(); + }, [enabled, scene, radius, candidateRingColor]); + + useEffect(() => { + if (!isValidScene(scene)) return; + scene.requestRender(); + }, [scene, candidatePointECEF, candidateVerticalOffsetAnchorECEF]); + + useEffect(() => { + return () => { + safeCall(removeCandidateRingPostRenderListenerRef.current); + removeCandidateRingPostRenderListenerRef.current = null; + if (candidateRingRef.current) { + safeRemovePrimitive(scene, candidateRingRef.current); + candidateRingRef.current = null; + } + candidateRingSamplesRef.current = []; + }; + }, [scene]); +}; + +export default usePointCandidateRingIndicator; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useVerticalPolygonCandidate.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useVerticalPolygonCandidate.ts new file mode 100644 index 0000000000..3504f4c8c9 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/candidate/useVerticalPolygonCandidate.ts @@ -0,0 +1,78 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { Cartesian3, type Scene } from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + getVerticalRectanglePreviewAreaSquareMeters, + isPointAnnotationEntry, + type AnnotationCandidateDescriptor, + type AnnotationCollection, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +export const useVerticalPolygonCandidate = ( + scene: Scene | null, + annotations: AnnotationCollection, + candidate: AnnotationCandidateDescriptor, + setNodeChainAnnotations: Dispatch> +) => { + const isVerticalPolygonCandidate = + candidate.kind === ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL; + + return useCallback( + (positionECEF: Cartesian3 | null) => { + if (!isVerticalPolygonCandidate) return; + + const verticalPolygonContext = candidate.verticalPolygonContext; + if (!verticalPolygonContext) return; + + const firstPoint = annotations.find( + (measurement) => + measurement.id === verticalPolygonContext.firstNodeId && + isPointAnnotationEntry(measurement) + ); + if (!firstPoint || !isPointAnnotationEntry(firstPoint)) return; + + const previewAreaSquareMeters = positionECEF + ? getVerticalRectanglePreviewAreaSquareMeters( + firstPoint.geometryECEF, + positionECEF + ) + : 0; + + setNodeChainAnnotations((prev) => + prev.map((group) => { + if (group.id !== verticalPolygonContext.groupId || group.closed) { + return group; + } + if (group.type !== ANNOTATION_TYPE_AREA_VERTICAL) { + return group; + } + if (group.nodeIds.length !== 1) { + return group; + } + if ( + Math.abs((group.areaSquareMeters ?? 0) - previewAreaSquareMeters) <= + 1e-9 + ) { + return group; + } + return { + ...group, + areaSquareMeters: previewAreaSquareMeters, + }; + }) + ); + + scene?.requestRender(); + }, + [ + candidate.verticalPolygonContext, + annotations, + getVerticalRectanglePreviewAreaSquareMeters, + isVerticalPolygonCandidate, + scene, + setNodeChainAnnotations, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/create/index.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/index.ts new file mode 100644 index 0000000000..af5f8886b3 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/index.ts @@ -0,0 +1 @@ +export * from "./useAnnotationCreateDefaults"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationCreateDefaults.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationCreateDefaults.ts new file mode 100644 index 0000000000..0bfaa8edf4 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationCreateDefaults.ts @@ -0,0 +1,64 @@ +import { useEffect, useState } from "react"; + +import { + ANNOTATION_TYPE_POINT, + getLastCustomPointAnnotationName, + type AnnotationCollection, + type AnnotationMode, +} from "@carma-mapping/annotations/core"; + +type AnnotationCreateDefaultsState = Partial>; + +const buildInitialAnnotationCreateDefaults = ( + annotations: AnnotationCollection +): AnnotationCreateDefaultsState => { + const lastCustomPointAnnotationName = + getLastCustomPointAnnotationName(annotations); + + return lastCustomPointAnnotationName + ? { + [ANNOTATION_TYPE_POINT]: lastCustomPointAnnotationName, + } + : {}; +}; + +export const useAnnotationCreateDefaults = ( + annotations: AnnotationCollection +) => { + const [lastCustomAnnotationNameByType, setLastCustomAnnotationNameByType] = + useState(() => + buildInitialAnnotationCreateDefaults(annotations) + ); + + const latestCustomPointAnnotationName = + getLastCustomPointAnnotationName(annotations); + + useEffect( + function effectStoreLastCustomPointAnnotationName() { + if (!latestCustomPointAnnotationName) { + return; + } + + setLastCustomAnnotationNameByType((previousDefaults) => + previousDefaults[ANNOTATION_TYPE_POINT] === + latestCustomPointAnnotationName + ? previousDefaults + : { + ...previousDefaults, + [ANNOTATION_TYPE_POINT]: latestCustomPointAnnotationName, + } + ); + }, + [latestCustomPointAnnotationName] + ); + + return { + lastCustomAnnotationNameByType, + lastCustomPointAnnotationName: + lastCustomAnnotationNameByType[ANNOTATION_TYPE_POINT], + } as const; +}; + +export type AnnotationCreateDefaults = ReturnType< + typeof useAnnotationCreateDefaults +>; diff --git a/libraries/mapping/annotations/core/src/lib/hooks/useAnnotationPointCreation.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationPointCreation.ts similarity index 98% rename from libraries/mapping/annotations/core/src/lib/hooks/useAnnotationPointCreation.ts rename to libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationPointCreation.ts index 364e717129..98c886515f 100644 --- a/libraries/mapping/annotations/core/src/lib/hooks/useAnnotationPointCreation.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useAnnotationPointCreation.ts @@ -9,7 +9,7 @@ import { import { makeTemporaryEntriesPermanent, upsertCollectionEntry, -} from "../utils/temporaryCollection"; +} from "@carma-mapping/annotations/core"; type AnnotationCollectionEntry = { id: string; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useNodeChainPointCreation.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useNodeChainPointCreation.ts new file mode 100644 index 0000000000..8b9917e0ba --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/useNodeChainPointCreation.ts @@ -0,0 +1,805 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, + projectPointToHorizontalPlaneAtAnchor, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POLYLINE, + LINEAR_SEGMENT_LINE_MODE_DIRECT, + buildEdgeRelationIdsForPolygon, + buildVerticalAutoCloseRectangle, + computePolylinePlanarAngleSumDeg, + createPlaneFromThreePoints, + distancePointToPlane, + getDistanceRelationId, + getPointById, + getPointPositionMap, + isAreaToolType, + isPointAnnotationEntry, + projectPointOntoPlane, + type AnnotationCollection, + type AnnotationEntry, + type AnnotationToolType, + type LinearSegmentLineMode, + type NodeChainAnnotation, + type PolygonAreaType, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseNodeChainPointCreationParams = { + annotations: AnnotationCollection; + nodeChainAnnotations: NodeChainAnnotation[]; + distanceRelations: PointDistanceRelation[]; + activeNodeChainAnnotationId: string | null; + activeToolType: AnnotationToolType; + defaultPolylineSegmentLineMode: LinearSegmentLineMode; + polylineVerticalOffsetMeters: number; + setNodeChainAnnotations: Dispatch>; + setAnnotations: Dispatch>; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + resolveDistanceRelationSourcePointId: (pointId: string) => string | null; + upsertDirectDistanceRelation: ( + sourcePointId: string, + pointId: string + ) => void; + trackMeasurementDraftPointIds: (pointIds: readonly string[]) => void; + trackMeasurementDraftRelationId: (relationId: string | null) => void; + clearActiveNodeChainDrawingState: () => void; + clearMoveGizmo: () => void; + selectAnnotationById: (id: string | null) => void; + selectRepresentativeNodeForMeasurementId: (id: string | null) => void; + orientPlaneTowardSceneCamera: ( + plane: NonNullable + ) => NonNullable; + computePolygonGroupDerivedDataWithCamera: ( + group: NodeChainAnnotation, + pointById: Map + ) => NodeChainAnnotation; +}; + +const PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS = 0.2; +const PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG = 150; + +export const useNodeChainPointCreation = ({ + annotations, + nodeChainAnnotations, + distanceRelations, + activeNodeChainAnnotationId, + activeToolType, + defaultPolylineSegmentLineMode, + polylineVerticalOffsetMeters, + setNodeChainAnnotations, + setAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + resolveDistanceRelationSourcePointId, + upsertDirectDistanceRelation, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + orientPlaneTowardSceneCamera, + computePolygonGroupDerivedDataWithCamera, +}: UseNodeChainPointCreationParams) => { + const handleNodeChainPointCreated = useCallback( + (newPointId: string, newPointPositionECEF: Cartesian3) => { + const sourcePointId = resolveDistanceRelationSourcePointId(newPointId); + const directRelationId = sourcePointId + ? getDistanceRelationId(sourcePointId, newPointId) + : null; + + let projectedPointPosition: Cartesian3 | null = null; + const activeGroupSnapshot = + (activeNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId + ) + : null) ?? null; + const creatingNewGroup = + !activeGroupSnapshot || Boolean(activeGroupSnapshot.closed); + const nextActiveGroupId = creatingNewGroup + ? `node-chain-annotation-${Date.now()}-${newPointId}` + : activeGroupSnapshot.id; + const pointByIdSnapshot = getPointPositionMap(annotations, { + [newPointId]: newPointPositionECEF, + }); + const isAreaCreation = isAreaToolType(activeToolType); + const seedTypeForCreation: NodeChainAnnotation["type"] = isAreaCreation + ? (activeToolType as PolygonAreaType) + : ANNOTATION_TYPE_POLYLINE; + const seedSegmentLineMode = isAreaCreation + ? LINEAR_SEGMENT_LINE_MODE_DIRECT + : defaultPolylineSegmentLineMode; + const verticalAutoCloseFromNewPoint = (() => { + if (!isAreaCreation) return null; + + const candidateNodeIds = creatingNewGroup + ? sourcePointId && + sourcePointId !== newPointId && + pointByIdSnapshot.has(sourcePointId) + ? [sourcePointId, newPointId] + : [newPointId] + : [...(activeGroupSnapshot?.nodeIds ?? []), newPointId]; + + const candidateType = creatingNewGroup + ? seedTypeForCreation + : activeGroupSnapshot?.type ?? ANNOTATION_TYPE_AREA_PLANAR; + + if (candidateType !== ANNOTATION_TYPE_AREA_VERTICAL) return null; + if (candidateNodeIds.length !== 2) return null; + + return buildVerticalAutoCloseRectangle( + pointByIdSnapshot, + candidateNodeIds[0] ?? null, + candidateNodeIds[1] ?? null + ); + })(); + const createdVerticalAutoCorners = + verticalAutoCloseFromNewPoint?.autoCorners; + const autoClosedAsVerticalRectangle = Boolean( + verticalAutoCloseFromNewPoint + ); + + trackMeasurementDraftPointIds([ + newPointId, + ...(createdVerticalAutoCorners?.map(({ id }) => id) ?? []), + ]); + + if (sourcePointId && !autoClosedAsVerticalRectangle) { + const relationAlreadyExists = directRelationId + ? distanceRelations.some( + (relation) => relation.id === directRelationId + ) + : false; + upsertDirectDistanceRelation(sourcePointId, newPointId); + if (!relationAlreadyExists) { + trackMeasurementDraftRelationId(directRelationId); + } + } + + setNodeChainAnnotations((prev) => { + const activeGroup = + (activeNodeChainAnnotationId + ? prev.find((group) => group.id === activeNodeChainAnnotationId) + : null) ?? null; + + const pointById = getPointPositionMap(annotations, { + [newPointId]: newPointPositionECEF, + }); + + if (!activeGroup || activeGroup.closed) { + const seedNodeIds = + sourcePointId && + sourcePointId !== newPointId && + pointById.has(sourcePointId) + ? [sourcePointId, newPointId] + : [newPointId]; + const seedType = seedTypeForCreation; + + if ( + isAreaCreation && + seedType === ANNOTATION_TYPE_AREA_VERTICAL && + seedNodeIds.length === 2 && + verticalAutoCloseFromNewPoint + ) { + verticalAutoCloseFromNewPoint.autoCorners.forEach( + ({ id, position }) => { + pointById.set(id, position); + } + ); + const closedNodeIds = [ + ...verticalAutoCloseFromNewPoint.closedNodeIds, + ]; + const closedEdgeRelationIds = buildEdgeRelationIdsForPolygon( + closedNodeIds, + true, + getDistanceRelationId + ); + return [ + ...prev, + computePolygonGroupDerivedDataWithCamera( + { + id: nextActiveGroupId, + type: seedTypeForCreation, + segmentLineMode: seedSegmentLineMode, + verticalOffsetMeters: polylineVerticalOffsetMeters, + nodeIds: closedNodeIds, + edgeRelationIds: closedEdgeRelationIds, + distanceMeasurementStartPointId: + closedNodeIds[0] ?? undefined, + closed: true, + planeLocked: true, + areaSquareMeters: 0, + verticalityDeg: 0, + }, + pointById + ), + ]; + } + + const seedEdgeRelationIds = buildEdgeRelationIdsForPolygon( + seedNodeIds, + false, + getDistanceRelationId + ); + return [ + ...prev, + { + id: nextActiveGroupId, + type: seedType, + segmentLineMode: seedSegmentLineMode, + verticalOffsetMeters: polylineVerticalOffsetMeters, + nodeIds: seedNodeIds, + edgeRelationIds: seedEdgeRelationIds, + distanceMeasurementStartPointId: seedNodeIds[0] ?? undefined, + closed: false, + planeLocked: false, + areaSquareMeters: 0, + verticalityDeg: 0, + }, + ]; + } + + let nextNodeIds = [...activeGroup.nodeIds, newPointId]; + let shouldCloseGroup = activeGroup.closed; + let nextPlane = activeGroup.plane; + let nextPlaneLocked = activeGroup.planeLocked; + let nextPointPosition = newPointPositionECEF; + const shouldKeepSurfaceSampledVertices = + isAreaCreation && activeGroup.type === ANNOTATION_TYPE_AREA_GROUND; + const isPlanarSurface = + isAreaCreation && activeGroup.type === ANNOTATION_TYPE_AREA_PLANAR; + + if ( + isPlanarSurface && + !nextPlaneLocked && + activeGroup.nodeIds.length === 1 + ) { + const firstNodeId = activeGroup.nodeIds[0] ?? null; + const firstNodePosition = firstNodeId + ? pointById.get(firstNodeId) ?? null + : null; + if (firstNodePosition) { + nextPointPosition = projectPointToHorizontalPlaneAtAnchor( + nextPointPosition, + firstNodePosition + ); + projectedPointPosition = nextPointPosition; + pointById.set(newPointId, nextPointPosition); + } + } + + if (!shouldKeepSurfaceSampledVertices && nextPlaneLocked && nextPlane) { + nextPointPosition = projectPointOntoPlane( + nextPointPosition, + nextPlane + ); + projectedPointPosition = nextPointPosition; + pointById.set(newPointId, nextPointPosition); + } else if ( + !shouldKeepSurfaceSampledVertices && + isPlanarSurface && + !nextPlaneLocked && + nextNodeIds.length >= 3 + ) { + const first = pointById.get(nextNodeIds[0] ?? ""); + const second = pointById.get(nextNodeIds[1] ?? ""); + if (first && second) { + const candidatePlane = createPlaneFromThreePoints( + first, + second, + nextPointPosition + ); + if (candidatePlane) { + nextPlane = orientPlaneTowardSceneCamera(candidatePlane); + nextPlaneLocked = true; + nextPointPosition = projectPointOntoPlane( + nextPointPosition, + nextPlane + ); + projectedPointPosition = nextPointPosition; + pointById.set(newPointId, nextPointPosition); + } + } + } else if ( + !shouldKeepSurfaceSampledVertices && + !isPlanarSurface && + nextNodeIds.length >= 4 + ) { + const first = pointById.get(nextNodeIds[0] ?? ""); + const second = pointById.get(nextNodeIds[1] ?? ""); + const third = pointById.get(nextNodeIds[2] ?? ""); + if (first && second && third) { + const candidatePlane = createPlaneFromThreePoints( + first, + second, + third + ); + if (candidatePlane) { + const orientedCandidatePlane = + orientPlaneTowardSceneCamera(candidatePlane); + const planeDistance = distancePointToPlane( + nextPointPosition, + orientedCandidatePlane + ); + const firstFourPoints = nextNodeIds + .slice(0, 4) + .map((pointId) => pointById.get(pointId)) + .filter((point): point is Cartesian3 => Boolean(point)); + const planarAngleSum = computePolylinePlanarAngleSumDeg( + firstFourPoints, + orientedCandidatePlane + ); + + if ( + planeDistance <= PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS && + planarAngleSum < PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG + ) { + nextPlane = orientedCandidatePlane; + nextPlaneLocked = true; + nextPointPosition = projectPointOntoPlane( + nextPointPosition, + orientedCandidatePlane + ); + projectedPointPosition = nextPointPosition; + pointById.set(newPointId, nextPointPosition); + } + } + } + } + + if ( + isAreaCreation && + activeGroup.type === ANNOTATION_TYPE_AREA_VERTICAL && + nextNodeIds.length === 2 && + verticalAutoCloseFromNewPoint + ) { + verticalAutoCloseFromNewPoint.autoCorners.forEach( + ({ id, position }) => { + pointById.set(id, position); + } + ); + nextNodeIds = [...verticalAutoCloseFromNewPoint.closedNodeIds]; + shouldCloseGroup = true; + nextPlaneLocked = true; + } + + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + shouldCloseGroup, + getDistanceRelationId + ); + const updatedGroup = computePolygonGroupDerivedDataWithCamera( + { + ...activeGroup, + type: activeGroup.type, + nodeIds: nextNodeIds, + edgeRelationIds: nextEdgeRelationIds, + closed: shouldCloseGroup, + planeLocked: shouldKeepSurfaceSampledVertices + ? false + : nextPlaneLocked, + plane: shouldKeepSurfaceSampledVertices ? undefined : nextPlane, + }, + pointById + ); + return prev.map((group) => + group.id === activeGroup.id ? updatedGroup : group + ); + }); + + setActiveNodeChainAnnotationId(nextActiveGroupId); + + if (projectedPointPosition) { + const geometryWGS84 = getDegreesFromCartesian(projectedPointPosition); + setAnnotations((prev) => + prev.map((measurement) => { + if ( + !isPointAnnotationEntry(measurement) || + measurement.id !== newPointId + ) { + return measurement; + } + return { + ...measurement, + geometryECEF: projectedPointPosition as Cartesian3, + geometryWGS84: { + longitude: geometryWGS84.longitude, + latitude: geometryWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(geometryWGS84.altitude), + }, + }; + }) + ); + } + + if (createdVerticalAutoCorners && createdVerticalAutoCorners.length > 0) { + setAnnotations((prev) => { + const pointEntries = prev.filter(isPointAnnotationEntry); + const maxPointIndex = pointEntries.reduce( + (maxIndex, measurement) => + Math.max(maxIndex, measurement.index ?? 0), + 0 + ); + const autoCornerEntries: AnnotationEntry[] = + createdVerticalAutoCorners.map(({ id, position }, index) => { + const cornerWGS84 = getDegreesFromCartesian(position); + return { + type: ANNOTATION_TYPE_DISTANCE, + id, + index: maxPointIndex + index + 1, + geometryECEF: position, + geometryWGS84: { + longitude: cornerWGS84.longitude, + latitude: cornerWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(cornerWGS84.altitude), + }, + timestamp: Date.now() + index, + }; + }); + return [...prev, ...autoCornerEntries]; + }); + } + + if (autoClosedAsVerticalRectangle) { + clearActiveNodeChainDrawingState(); + selectRepresentativeNodeForMeasurementId(nextActiveGroupId); + clearMoveGizmo(); + } else { + setDoubleClickChainSourcePointId(newPointId); + if (!sourcePointId) { + selectAnnotationById(newPointId); + } + } + }, + [ + activeNodeChainAnnotationId, + activeToolType, + annotations, + nodeChainAnnotations, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + computePolygonGroupDerivedDataWithCamera, + defaultPolylineSegmentLineMode, + distanceRelations, + orientPlaneTowardSceneCamera, + polylineVerticalOffsetMeters, + resolveDistanceRelationSourcePointId, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + setActiveNodeChainAnnotationId, + setAnnotations, + setDoubleClickChainSourcePointId, + setNodeChainAnnotations, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + upsertDirectDistanceRelation, + ] + ); + + const insertExistingNodeIntoActiveChain = useCallback( + (existingPointId: string, sourcePointId?: string | null) => { + const isNodeChainTool = + activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType); + if (!isNodeChainTool) return false; + + const existingPoint = getPointById(annotations, existingPointId); + if (!existingPoint || !isPointAnnotationEntry(existingPoint)) + return false; + const existingPointPosition = existingPoint.geometryECEF; + const activeGroupSnapshot = + (activeNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId + ) + : null) ?? null; + const creatingNewGroup = + !activeGroupSnapshot || Boolean(activeGroupSnapshot.closed); + const nextActiveGroupId = creatingNewGroup + ? `node-chain-annotation-${Date.now()}-${existingPointId}` + : activeGroupSnapshot.id; + const pointByIdSnapshot = getPointPositionMap(annotations); + const isAreaCreation = isAreaToolType(activeToolType); + const seedTypeForCreation: NodeChainAnnotation["type"] = isAreaCreation + ? (activeToolType as PolygonAreaType) + : ANNOTATION_TYPE_POLYLINE; + const seedSegmentLineMode = isAreaCreation + ? LINEAR_SEGMENT_LINE_MODE_DIRECT + : defaultPolylineSegmentLineMode; + const verticalAutoCloseFromExistingPoint = (() => { + if (!isAreaCreation) return null; + + const candidateNodeIds = creatingNewGroup + ? sourcePointId && + sourcePointId !== existingPointId && + pointByIdSnapshot.has(sourcePointId) + ? [sourcePointId, existingPointId] + : [existingPointId] + : [...(activeGroupSnapshot?.nodeIds ?? []), existingPointId]; + + const candidateType = creatingNewGroup + ? seedTypeForCreation + : activeGroupSnapshot?.type ?? ANNOTATION_TYPE_AREA_PLANAR; + + if (candidateType !== ANNOTATION_TYPE_AREA_VERTICAL) return null; + if (candidateNodeIds.length !== 2) return null; + + return buildVerticalAutoCloseRectangle( + pointByIdSnapshot, + candidateNodeIds[0] ?? null, + candidateNodeIds[1] ?? null + ); + })(); + const createdVerticalAutoCorners = + verticalAutoCloseFromExistingPoint?.autoCorners; + const autoClosedAsVerticalRectangle = Boolean( + verticalAutoCloseFromExistingPoint + ); + + setNodeChainAnnotations((prev) => { + const activeGroup = + (activeNodeChainAnnotationId + ? prev.find((group) => group.id === activeNodeChainAnnotationId) + : null) ?? null; + const pointById = getPointPositionMap(annotations); + + if (!activeGroup || activeGroup.closed) { + const seedNodeIds = + sourcePointId && + sourcePointId !== existingPointId && + pointById.has(sourcePointId) + ? [sourcePointId, existingPointId] + : [existingPointId]; + const seedType = seedTypeForCreation; + + if ( + isAreaCreation && + seedType === ANNOTATION_TYPE_AREA_VERTICAL && + seedNodeIds.length === 2 && + verticalAutoCloseFromExistingPoint + ) { + verticalAutoCloseFromExistingPoint.autoCorners.forEach( + ({ id, position }) => { + pointById.set(id, position); + } + ); + const closedNodeIds = [ + ...verticalAutoCloseFromExistingPoint.closedNodeIds, + ]; + const closedEdgeRelationIds = buildEdgeRelationIdsForPolygon( + closedNodeIds, + true, + getDistanceRelationId + ); + return [ + ...prev, + computePolygonGroupDerivedDataWithCamera( + { + id: nextActiveGroupId, + type: seedTypeForCreation, + segmentLineMode: seedSegmentLineMode, + verticalOffsetMeters: polylineVerticalOffsetMeters, + nodeIds: closedNodeIds, + edgeRelationIds: closedEdgeRelationIds, + distanceMeasurementStartPointId: + closedNodeIds[0] ?? undefined, + closed: true, + planeLocked: true, + areaSquareMeters: 0, + verticalityDeg: 0, + }, + pointById + ), + ]; + } + + const seedEdgeRelationIds = buildEdgeRelationIdsForPolygon( + seedNodeIds, + false, + getDistanceRelationId + ); + return [ + ...prev, + { + id: nextActiveGroupId, + type: seedType, + segmentLineMode: seedSegmentLineMode, + verticalOffsetMeters: polylineVerticalOffsetMeters, + nodeIds: seedNodeIds, + edgeRelationIds: seedEdgeRelationIds, + distanceMeasurementStartPointId: seedNodeIds[0] ?? undefined, + closed: false, + planeLocked: false, + areaSquareMeters: 0, + verticalityDeg: 0, + }, + ]; + } + + const lastNodeId = + activeGroup.nodeIds[activeGroup.nodeIds.length - 1] ?? null; + if (lastNodeId === existingPointId) { + return prev; + } + + let nextNodeIds = [...activeGroup.nodeIds, existingPointId]; + let shouldCloseGroup = activeGroup.closed; + let nextPlane = activeGroup.plane; + let nextPlaneLocked = activeGroup.planeLocked; + const shouldKeepSurfaceSampledVertices = + isAreaCreation && activeGroup.type === ANNOTATION_TYPE_AREA_GROUND; + const isPlanarSurface = + isAreaCreation && activeGroup.type === ANNOTATION_TYPE_AREA_PLANAR; + + if ( + !shouldKeepSurfaceSampledVertices && + isPlanarSurface && + !nextPlaneLocked && + nextNodeIds.length >= 3 + ) { + const first = pointById.get(nextNodeIds[0] ?? ""); + const second = pointById.get(nextNodeIds[1] ?? ""); + if (first && second) { + const candidatePlane = createPlaneFromThreePoints( + first, + second, + existingPointPosition + ); + if (candidatePlane) { + nextPlane = orientPlaneTowardSceneCamera(candidatePlane); + nextPlaneLocked = true; + } + } + } else if ( + !shouldKeepSurfaceSampledVertices && + !isPlanarSurface && + !nextPlaneLocked && + nextNodeIds.length >= 4 + ) { + const first = pointById.get(nextNodeIds[0] ?? ""); + const second = pointById.get(nextNodeIds[1] ?? ""); + const third = pointById.get(nextNodeIds[2] ?? ""); + if (first && second && third) { + const candidatePlane = createPlaneFromThreePoints( + first, + second, + third + ); + if (candidatePlane) { + const orientedCandidatePlane = + orientPlaneTowardSceneCamera(candidatePlane); + const planeDistance = distancePointToPlane( + existingPointPosition, + orientedCandidatePlane + ); + const firstFourPoints = nextNodeIds + .slice(0, 4) + .map((pointId) => pointById.get(pointId)) + .filter((point): point is Cartesian3 => Boolean(point)); + const planarAngleSum = computePolylinePlanarAngleSumDeg( + firstFourPoints, + orientedCandidatePlane + ); + + if ( + planeDistance <= PLANAR_PROMOTION_DISTANCE_THRESHOLD_METERS && + planarAngleSum < PLANAR_PROMOTION_ANGLE_SUM_THRESHOLD_DEG + ) { + nextPlane = orientedCandidatePlane; + nextPlaneLocked = true; + } + } + } + } + + if ( + isAreaCreation && + activeGroup.type === ANNOTATION_TYPE_AREA_VERTICAL && + nextNodeIds.length === 2 && + verticalAutoCloseFromExistingPoint + ) { + verticalAutoCloseFromExistingPoint.autoCorners.forEach( + ({ id, position }) => { + pointById.set(id, position); + } + ); + nextNodeIds = [...verticalAutoCloseFromExistingPoint.closedNodeIds]; + shouldCloseGroup = true; + nextPlaneLocked = true; + } + + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + shouldCloseGroup, + getDistanceRelationId + ); + const updatedGroup = computePolygonGroupDerivedDataWithCamera( + { + ...activeGroup, + type: activeGroup.type, + nodeIds: nextNodeIds, + edgeRelationIds: nextEdgeRelationIds, + closed: shouldCloseGroup, + planeLocked: shouldKeepSurfaceSampledVertices + ? false + : nextPlaneLocked, + plane: shouldKeepSurfaceSampledVertices ? undefined : nextPlane, + }, + pointById + ); + return prev.map((group) => + group.id === activeGroup.id ? updatedGroup : group + ); + }); + + if (createdVerticalAutoCorners && createdVerticalAutoCorners.length > 0) { + setAnnotations((prev) => { + const pointEntries = prev.filter(isPointAnnotationEntry); + const maxPointIndex = pointEntries.reduce( + (maxIndex, measurement) => + Math.max(maxIndex, measurement.index ?? 0), + 0 + ); + const autoCornerEntries: AnnotationEntry[] = + createdVerticalAutoCorners.map(({ id, position }, index) => { + const cornerWGS84 = getDegreesFromCartesian(position); + return { + type: ANNOTATION_TYPE_DISTANCE, + id, + index: maxPointIndex + index + 1, + geometryECEF: position, + geometryWGS84: { + longitude: cornerWGS84.longitude, + latitude: cornerWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(cornerWGS84.altitude), + }, + timestamp: Date.now() + index, + }; + }); + return [...prev, ...autoCornerEntries]; + }); + } + + if (autoClosedAsVerticalRectangle) { + clearActiveNodeChainDrawingState(); + selectRepresentativeNodeForMeasurementId(nextActiveGroupId); + clearMoveGizmo(); + return true; + } + + setActiveNodeChainAnnotationId(nextActiveGroupId); + return true; + }, + [ + activeNodeChainAnnotationId, + activeToolType, + annotations, + nodeChainAnnotations, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + computePolygonGroupDerivedDataWithCamera, + defaultPolylineSegmentLineMode, + orientPlaneTowardSceneCamera, + polylineVerticalOffsetMeters, + selectRepresentativeNodeForMeasurementId, + setActiveNodeChainAnnotationId, + setAnnotations, + setNodeChainAnnotations, + ] + ); + + return { + handleNodeChainPointCreated, + insertExistingNodeIntoActiveChain, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/create/usePointAnnotationCreatedHandlers.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/usePointAnnotationCreatedHandlers.ts new file mode 100644 index 0000000000..269072f0cf --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/create/usePointAnnotationCreatedHandlers.ts @@ -0,0 +1,48 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +type UsePointAnnotationCreatedHandlersParams = { + selectAnnotationByIdImmediate: (id: string | null) => void; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + setLabelInputPromptPointId: Dispatch>; +}; + +export const usePointAnnotationCreatedHandlers = ({ + selectAnnotationByIdImmediate, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setLabelInputPromptPointId, +}: UsePointAnnotationCreatedHandlersParams) => { + const handlePointAnnotationCreated = useCallback( + (newPointId: string) => { + setDoubleClickChainSourcePointId(null); + setActiveNodeChainAnnotationId(null); + selectAnnotationByIdImmediate(newPointId); + }, + [ + selectAnnotationByIdImmediate, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + ] + ); + + const handleLabelAnnotationCreated = useCallback( + (newPointId: string) => { + setDoubleClickChainSourcePointId(null); + setActiveNodeChainAnnotationId(null); + setLabelInputPromptPointId(newPointId); + selectAnnotationByIdImmediate(newPointId); + }, + [ + selectAnnotationByIdImmediate, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setLabelInputPromptPointId, + ] + ); + + return { + handlePointAnnotationCreated, + handleLabelAnnotationCreated, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/annotationEdit.types.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/annotationEdit.types.ts new file mode 100644 index 0000000000..f4df0d5213 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/annotationEdit.types.ts @@ -0,0 +1,47 @@ +import type { Cartesian3 } from "@carma/cesium"; +import { + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, +} from "@carma-mapping/annotations/core"; + +export type AnnotationEditTarget = + | { kind: "point"; pointId: string } + | { kind: "point-label"; pointId: string } + | { kind: "point-vertical-offset-stem"; pointId: string }; + +export type AnnotationEditUpdateTarget = { + kind: "point-elevation-reference"; + pointId: string; +}; + +export type MoveGizmoAxisCandidate = { + id: string; + direction: Cartesian3; + color?: string; + title?: string | null; +}; + +export type MoveGizmoVerticalOffsetEditMode = + | typeof ANNOTATION_TYPE_POINT + | typeof ANNOTATION_TYPE_POLYLINE + | null; + +export type MoveGizmoSession = { + pointId: string | null; + axisDirection: Cartesian3 | null; + axisTitle: string | null; + axisCandidates: MoveGizmoAxisCandidate[] | null; + preferredAxisId: string | null; + verticalOffsetEditMode: MoveGizmoVerticalOffsetEditMode; + verticalOffsetNodeChainAnnotationId: string | null; + isDragging: boolean; +}; + +export type MoveGizmoStartOptions = { + axisDirection?: Cartesian3 | null; + axisTitle?: string | null; + preferredAxisId?: string | null; + axisCandidates?: MoveGizmoAxisCandidate[] | null; + verticalOffsetEditMode?: MoveGizmoVerticalOffsetEditMode; + verticalOffsetNodeChainAnnotationId?: string | null; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/index.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/index.ts new file mode 100644 index 0000000000..a818e308d2 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/index.ts @@ -0,0 +1,7 @@ +export * from "./annotationEdit.types"; +export * from "./useAnnotationEditState"; +export * from "./useAnnotationPointEditingController"; +export * from "./useAnnotationsEditing"; +export * from "./useDistanceRelationEditing"; +export * from "./usePointEditingGizmo"; +export * from "./usePointEditingState"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationEditState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationEditState.ts new file mode 100644 index 0000000000..c422ea511a --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationEditState.ts @@ -0,0 +1,247 @@ +import { useCallback, useEffect, type SetStateAction } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import { useStoreSelector } from "@carma-commons/react-store"; +import { + isPointAnnotationEntry, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationEditStoreState, AnnotationsStore } from "../../store"; +import type { + AnnotationEditTarget, + MoveGizmoAxisCandidate, + MoveGizmoSession, + MoveGizmoStartOptions, +} from "./annotationEdit.types"; + +const resolveSetStateAction = ( + action: SetStateAction, + previousValue: TValue +): TValue => + typeof action === "function" + ? (action as (previousValue: TValue) => TValue)(previousValue) + : action; + +const cloneAxisCandidates = ( + axisCandidates: MoveGizmoAxisCandidate[] | null | undefined +): MoveGizmoAxisCandidate[] | null => + axisCandidates + ? axisCandidates.map((candidate) => ({ + ...candidate, + direction: Cartesian3.clone(candidate.direction), + })) + : null; + +const createEmptyMoveGizmoSession = (): MoveGizmoSession => ({ + pointId: null, + axisDirection: null, + axisTitle: null, + axisCandidates: null, + preferredAxisId: null, + verticalOffsetEditMode: null, + verticalOffsetNodeChainAnnotationId: null, + isDragging: false, +}); + +const isEmptyMoveGizmoSession = (moveGizmo: MoveGizmoSession): boolean => + moveGizmo.pointId === null && + moveGizmo.axisDirection === null && + moveGizmo.axisTitle === null && + moveGizmo.axisCandidates === null && + moveGizmo.preferredAxisId === null && + moveGizmo.verticalOffsetEditMode === null && + moveGizmo.verticalOffsetNodeChainAnnotationId === null && + moveGizmo.isDragging === false; + +const hasDetachedMoveGizmoConfig = (moveGizmo: MoveGizmoSession): boolean => + moveGizmo.preferredAxisId !== null || + moveGizmo.verticalOffsetEditMode !== null || + moveGizmo.verticalOffsetNodeChainAnnotationId !== null; + +export const useAnnotationEditState = ( + annotationsStore: AnnotationsStore, + annotations: AnnotationCollection +) => { + const editState = useStoreSelector( + annotationsStore, + (state) => state.editState + ); + + const setEditState = useCallback( + (nextValueOrUpdater: SetStateAction) => { + annotationsStore.setState((previousStoreState) => { + const nextEditState = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.editState + ); + + return Object.is(nextEditState, previousStoreState.editState) + ? previousStoreState + : { + ...previousStoreState, + editState: nextEditState, + }; + }); + }, + [annotationsStore] + ); + + const setActiveEditTarget = useCallback( + (nextValueOrUpdater: SetStateAction) => { + setEditState((previousState) => { + const nextActiveTarget = resolveSetStateAction( + nextValueOrUpdater, + previousState.activeTarget + ); + + return nextActiveTarget === previousState.activeTarget + ? previousState + : { + ...previousState, + activeTarget: nextActiveTarget, + }; + }); + }, + [setEditState] + ); + + const clearActiveEditTarget = useCallback(() => { + setActiveEditTarget((previousTarget) => + previousTarget === null ? previousTarget : null + ); + }, [setActiveEditTarget]); + + const setIsMoveGizmoDragging = useCallback( + (nextValueOrUpdater: SetStateAction) => { + setEditState((previousState) => { + const nextIsMoveGizmoDragging = resolveSetStateAction( + nextValueOrUpdater, + previousState.moveGizmo.isDragging + ); + + return nextIsMoveGizmoDragging === previousState.moveGizmo.isDragging + ? previousState + : { + ...previousState, + moveGizmo: { + ...previousState.moveGizmo, + isDragging: nextIsMoveGizmoDragging, + }, + }; + }); + }, + [setEditState] + ); + + const clearMoveGizmo = useCallback(() => { + setEditState((previousState) => + isEmptyMoveGizmoSession(previousState.moveGizmo) + ? previousState + : { + ...previousState, + moveGizmo: createEmptyMoveGizmoSession(), + } + ); + }, [setEditState]); + + useEffect( + function effectResetDetachedMoveGizmoState() { + if (editState.moveGizmo.pointId) { + return; + } + + setEditState((previousState) => + !hasDetachedMoveGizmoConfig(previousState.moveGizmo) + ? previousState + : { + ...previousState, + moveGizmo: { + ...previousState.moveGizmo, + preferredAxisId: null, + verticalOffsetEditMode: null, + verticalOffsetNodeChainAnnotationId: null, + }, + } + ); + }, + [editState.moveGizmo.pointId, setEditState] + ); + + useEffect( + function effectClearRemovedMoveGizmoMeasurement() { + if (!editState.moveGizmo.pointId) { + return; + } + + const hasMoveGizmoPoint = annotations.some( + (annotation) => annotation.id === editState.moveGizmo.pointId + ); + if (!hasMoveGizmoPoint) { + clearMoveGizmo(); + } + }, + [annotations, clearMoveGizmo, editState.moveGizmo.pointId] + ); + + const startMoveGizmoForAnnotationId = useCallback( + (id: string, options?: MoveGizmoStartOptions) => { + const measurement = annotations.find( + (annotation) => + isPointAnnotationEntry(annotation) && annotation.id === id + ); + if (!measurement || !isPointAnnotationEntry(measurement)) { + return; + } + + if (measurement.locked) { + return; + } + + setEditState((previousState) => ({ + ...previousState, + moveGizmo: { + pointId: id, + axisDirection: options?.axisDirection ?? null, + axisTitle: options?.axisTitle ?? null, + axisCandidates: cloneAxisCandidates(options?.axisCandidates ?? null), + preferredAxisId: options?.preferredAxisId ?? null, + verticalOffsetEditMode: options?.verticalOffsetEditMode ?? null, + verticalOffsetNodeChainAnnotationId: + options?.verticalOffsetNodeChainAnnotationId ?? null, + isDragging: false, + }, + })); + }, + [annotations, setEditState] + ); + + const setMoveGizmoAxis = useCallback( + (axisDirection: Cartesian3, axisTitle?: string | null) => { + setEditState((previousState) => ({ + ...previousState, + moveGizmo: { + ...previousState.moveGizmo, + axisDirection: Cartesian3.clone(axisDirection), + axisTitle: axisTitle ?? null, + }, + })); + }, + [setEditState] + ); + + const moveGizmo: MoveGizmoSession = editState.moveGizmo; + + return { + activeEditTarget: editState.activeTarget, + setActiveEditTarget, + clearActiveEditTarget, + moveGizmo, + setMoveGizmoDragging: setIsMoveGizmoDragging, + startMoveGizmoForAnnotationId, + clearMoveGizmo, + setMoveGizmoAxis, + }; +}; + +export type AnnotationEditState = ReturnType; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationPointEditingController.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationPointEditingController.ts similarity index 84% rename from libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationPointEditingController.ts rename to libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationPointEditingController.ts index 045c1b9ff7..07093e1cb7 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/useAnnotationPointEditingController.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationPointEditingController.ts @@ -10,31 +10,31 @@ import { } from "@carma-mapping/annotations/core"; type UseAnnotationPointEditingControllerParams = { - annotations: AnnotationCollection; - referencePoint: Cartesian3 | null; moveGizmoPointId: string | null; setAnnotations: Dispatch>; setReferencePoint: Dispatch>; referencePointSyncEpsilonMeters: number; }; -export type UpdatePointMeasurementPositionOptions = { +export type UpdatePointAnnotationPositionOptions = { treatNextPositionAsOffsetAnchor?: boolean; }; -export const useAnnotationPointEditingController = ({ - annotations, - referencePoint, - moveGizmoPointId, - setAnnotations, - setReferencePoint, - referencePointSyncEpsilonMeters, -}: UseAnnotationPointEditingControllerParams) => { - const updatePointMeasurementPositionById = useCallback( +export const useAnnotationPointEditingController = ( + annotations: AnnotationCollection, + referencePoint: Cartesian3 | null, + { + moveGizmoPointId, + setAnnotations, + setReferencePoint, + referencePointSyncEpsilonMeters, + }: UseAnnotationPointEditingControllerParams +) => { + const updatePointAnnotationPositionById = useCallback( ( id: string, nextPosition: Cartesian3, - options?: UpdatePointMeasurementPositionOptions + options?: UpdatePointAnnotationPositionOptions ) => { const measurementToUpdate = annotations.find( (measurement) => @@ -137,9 +137,9 @@ export const useAnnotationPointEditingController = ({ measurement.geometryWGS84.latitude, elevationMeters ); - updatePointMeasurementPositionById(id, nextPosition); + updatePointAnnotationPositionById(id, nextPosition); }, - [annotations, updatePointMeasurementPositionById] + [annotations, updatePointAnnotationPositionById] ); const setPointAnnotationCoordinatesById = useCallback( @@ -163,12 +163,12 @@ export const useAnnotationPointEditingController = ({ latitude, nextElevation ); - updatePointMeasurementPositionById(id, nextPosition); + updatePointAnnotationPositionById(id, nextPosition); }, - [annotations, updatePointMeasurementPositionById] + [annotations, updatePointAnnotationPositionById] ); - const setMoveGizmoPointElevationFromMeasurementById = useCallback( + const setMoveGizmoPointElevationFromAnnotationId = useCallback( (sourcePointId: string) => { if (!moveGizmoPointId || sourcePointId === moveGizmoPointId) return; @@ -197,15 +197,15 @@ export const useAnnotationPointEditingController = ({ moveMeasurement.geometryWGS84.latitude, sourceMeasurement.geometryWGS84.altitude ); - updatePointMeasurementPositionById(moveGizmoPointId, nextPosition); + updatePointAnnotationPositionById(moveGizmoPointId, nextPosition); }, - [moveGizmoPointId, annotations, updatePointMeasurementPositionById] + [moveGizmoPointId, annotations, updatePointAnnotationPositionById] ); return { - updatePointMeasurementPositionById, + updatePointAnnotationPositionById, setPointAnnotationElevationById, setPointAnnotationCoordinatesById, - setMoveGizmoPointElevationFromMeasurementById, + setMoveGizmoPointElevationFromAnnotationId, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationsEditing.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationsEditing.ts new file mode 100644 index 0000000000..3adb37c114 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useAnnotationsEditing.ts @@ -0,0 +1,731 @@ +import { + useCallback, + useEffect, + type Dispatch, + type SetStateAction, +} from "react"; + +import { + Cartesian3, + Cartesian4, + Matrix4, + Transforms, + cartesian3FromJson, + getLocalUpDirectionAtAnchor, + getSignedAngleDegAroundAxis, + normalizeDirection, + resolveLocalFrameVectors, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + createPlaneFromThreePoints, + type DirectLineLabelMode, + getPointPositionMap, + getVerticalPolygonAxisRotationSuffix, + isPointAnnotationEntry, + orientPlaneNormalTowardPosition, + type PlanarPolygonPlane, + type ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; +import { usePointEditingGizmo } from "./usePointEditingGizmo"; +import { useDistanceRelationEditing } from "./useDistanceRelationEditing"; +import { useDistanceRelationInteractions } from "../../render/edge/useDistanceRelationInteractions"; +import { useAnnotationEditState } from "./useAnnotationEditState"; +import { usePointEditingState } from "./usePointEditingState"; +import type { + AnnotationEditTarget, + AnnotationEditUpdateTarget, +} from "./annotationEdit.types"; +import type { + AnnotationCollection, + NodeChainAnnotation, + PointDistanceRelation, +} from "@carma-mapping/annotations/core"; +import type { Scene } from "@carma/cesium"; +import type { AnnotationsStore } from "../../store"; + +const VERTICAL_POLYGON_AXIS_ID_ENU_UP = "enu-up"; +const VERTICAL_POLYGON_AXIS_ID_ENU_EAST = "enu-east"; +const VERTICAL_POLYGON_AXIS_ID_ENU_NORTH = "enu-north"; +const PLANAR_POLYGON_AXIS_ID_NORMAL = "planar-normal"; +const PLANAR_POLYGON_AXIS_ID_IN_PLANE_PRIMARY = "planar-in-plane-primary"; +const PLANAR_POLYGON_AXIS_ID_IN_PLANE_SECONDARY = "planar-in-plane-secondary"; + +export type AnnotationsEditingState = { + activeEditTarget: AnnotationEditTarget | null; + moveGizmoPointId: string | null; + isMoveGizmoDragging: boolean; + handleDistanceRelationLineClick: ( + relationId: string, + kind: ReferenceLineLabelKind + ) => void; + handleDistanceRelationLineLabelToggle: ( + relationId: string, + kind: ReferenceLineLabelKind + ) => void; + handleDistanceRelationCornerClick: (relationId: string) => void; + handleDistanceRelationMidpointClick: (relationId: string) => void; + requestStartEdit: (target: AnnotationEditTarget) => void; + requestStopEdit: () => void; + requestUpdateEditTarget: (target: AnnotationEditUpdateTarget) => boolean; +}; + +type UseAnnotationsEditingInput = { + annotationsStore: AnnotationsStore; + scene: Scene; + annotations: AnnotationCollection; + nodeChainAnnotations: NodeChainAnnotation[]; + referencePoint: Cartesian3 | null; + selectedAnnotationIds: string[]; + focusedNodeChainAnnotationId: string | null; + activeNodeChainAnnotationId: string | null; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; + defaultDirectLineLabelMode: DirectLineLabelMode; + visibleMeasurementsForRendering: AnnotationCollection; + pointRadius: number; + setAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + setReferencePoint: Dispatch>; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + selectAnnotationById: (id: string | null) => void; + getOwnerGroupIdsForEdgeRelationId: ( + relationId: string | null | undefined + ) => readonly string[]; + selectRepresentativeNodeForMeasurementId: (id: string | null) => void; +}; + +export const useAnnotationsEditing = ( + managedAnnotations: UseAnnotationsEditingInput +): AnnotationsEditingState => { + const { + annotationsStore, + scene, + annotations, + nodeChainAnnotations, + referencePoint, + selectedAnnotationIds, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, + visibleMeasurementsForRendering, + pointRadius, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setReferencePoint, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + selectAnnotationById, + getOwnerGroupIdsForEdgeRelationId, + selectRepresentativeNodeForMeasurementId, + } = managedAnnotations; + + const { + activeEditTarget, + setActiveEditTarget, + clearActiveEditTarget, + moveGizmo, + setMoveGizmoDragging, + startMoveGizmoForAnnotationId, + setMoveGizmoAxis, + clearMoveGizmo, + } = useAnnotationEditState(annotationsStore, annotations); + + const { + setMoveGizmoPointElevationFromAnnotationId, + handleMoveGizmoPointPositionChange, + } = usePointEditingState( + annotations, + nodeChainAnnotations, + referencePoint, + selectedAnnotationIds, + { + setAnnotations, + setNodeChainAnnotations, + setReferencePoint, + moveGizmo, + } + ); + + const useGroundSnappedPointEditDragZone = + !moveGizmo.verticalOffsetEditMode && !moveGizmo.axisCandidates; + + usePointEditingGizmo(scene, visibleMeasurementsForRendering, moveGizmo, { + pointRadius, + snapPlaneDragToGround: useGroundSnappedPointEditDragZone, + onPointPositionChange: handleMoveGizmoPointPositionChange, + onDragStateChange: setMoveGizmoDragging, + onAxisChange: setMoveGizmoAxis, + onExit: clearMoveGizmo, + }); + + const getPreferredPlaneFacingPosition = useCallback((): Cartesian3 | null => { + if (!scene || scene.isDestroyed()) { + return null; + } + + return scene.camera.positionWC; + }, [scene]); + + const orientPlaneTowardSceneCamera = useCallback( + (plane: PlanarPolygonPlane) => + orientPlaneNormalTowardPosition(plane, getPreferredPlaneFacingPosition()), + [getPreferredPlaneFacingPosition] + ); + + const { + handleDistanceRelationLineClick, + handleDistanceRelationLineLabelToggle, + } = useDistanceRelationInteractions({ + activeNodeChainAnnotationId, + focusedNodeChainAnnotationId, + nodeChainAnnotations: nodeChainAnnotations, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, + setDistanceRelations, + selectRepresentativeNodeForMeasurementId, + getOwnerGroupIdsForEdgeRelationId, + }); + + const { + handleDistanceRelationCornerClick, + handleDistanceRelationMidpointClick, + } = useDistanceRelationEditing({ + scene, + annotations, + nodeChainAnnotations: nodeChainAnnotations, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + selectAnnotationById, + }); + + useEffect( + function effectClearEditTargetWithoutActiveMoveGizmo() { + if (!moveGizmo.pointId) { + setActiveEditTarget((previousTarget) => + previousTarget === null ? previousTarget : null + ); + } + }, + [moveGizmo.pointId, setActiveEditTarget] + ); + + const handlePointVerticalOffsetStemLongPress = useCallback( + (pointId: string) => { + const pointMeasurement = annotations.find( + (annotation) => + isPointAnnotationEntry(annotation) && annotation.id === pointId + ); + if (!pointMeasurement || !isPointAnnotationEntry(pointMeasurement)) { + return; + } + + const anchorECEF = pointMeasurement.verticalOffsetAnchorECEF + ? new Cartesian3( + pointMeasurement.verticalOffsetAnchorECEF.x, + pointMeasurement.verticalOffsetAnchorECEF.y, + pointMeasurement.verticalOffsetAnchorECEF.z + ) + : pointMeasurement.geometryECEF; + const upDirection = getLocalUpDirectionAtAnchor(anchorECEF); + const targetPolylineGroup = + nodeChainAnnotations.find( + (group) => !group.closed && group.nodeIds.includes(pointId) + ) ?? null; + + selectAnnotationById(pointId); + startMoveGizmoForAnnotationId(pointId, { + axisDirection: upDirection, + axisTitle: "Vertikalversatz", + verticalOffsetEditMode: targetPolylineGroup + ? ANNOTATION_TYPE_POLYLINE + : ANNOTATION_TYPE_POINT, + verticalOffsetNodeChainAnnotationId: targetPolylineGroup?.id ?? null, + }); + }, + [ + annotations, + nodeChainAnnotations, + selectAnnotationById, + startMoveGizmoForAnnotationId, + ] + ); + + const handlePointLabelLongPress = useCallback( + (pointId: string) => { + const targetVerticalPolygonGroup = + (focusedNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => + group.id === focusedNodeChainAnnotationId && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + group.nodeIds.includes(pointId) + ) + : null) ?? + nodeChainAnnotations.find( + (group) => + group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + group.nodeIds.includes(pointId) + ) ?? + null; + + if (targetVerticalPolygonGroup) { + const pointById = getPointPositionMap(annotations); + const pointPosition = pointById.get(pointId); + if (pointPosition) { + const persistedVerticalPolygonFrame = resolveLocalFrameVectors( + targetVerticalPolygonGroup.planarPolygonLocalFrame + ); + if (persistedVerticalPolygonFrame) { + const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); + const enuEastAxis4 = Matrix4.getColumn( + enuMatrix, + 0, + new Cartesian4() + ); + const enuEastDirection = normalizeDirection( + new Cartesian3(enuEastAxis4.x, enuEastAxis4.y, enuEastAxis4.z) + ); + const eastRotationDegVsEnuEast = + enuEastDirection && + getSignedAngleDegAroundAxis( + enuEastDirection, + persistedVerticalPolygonFrame.east, + persistedVerticalPolygonFrame.north + ); + const axisRotationSuffix = getVerticalPolygonAxisRotationSuffix( + eastRotationDegVsEnuEast + ); + const upAxisTitle = `Punkt entlang der ENU-U-Achse${axisRotationSuffix} verschieben`; + const verticalPolygonAxisCandidates = [ + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_UP, + direction: persistedVerticalPolygonFrame.up, + color: "rgba(59, 130, 246, 0.98)", + title: upAxisTitle, + }, + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_EAST, + direction: persistedVerticalPolygonFrame.east, + color: "rgba(239, 68, 68, 0.98)", + title: `Punkt entlang der ENU-E-Achse${axisRotationSuffix} verschieben`, + }, + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_NORTH, + direction: persistedVerticalPolygonFrame.north, + color: "rgba(34, 197, 94, 0.98)", + title: + "Punkt entlang der ENU-N-Achse (Flächennormale) verschieben", + }, + ] as const; + + selectAnnotationById(pointId); + startMoveGizmoForAnnotationId(pointId, { + axisDirection: persistedVerticalPolygonFrame.up, + axisTitle: upAxisTitle, + preferredAxisId: VERTICAL_POLYGON_AXIS_ID_ENU_UP, + axisCandidates: verticalPolygonAxisCandidates.map( + (axisCandidate) => ({ + ...axisCandidate, + direction: Cartesian3.clone(axisCandidate.direction), + }) + ), + }); + return; + } + + const pointIndex = targetVerticalPolygonGroup.nodeIds.findIndex( + (nodeId) => nodeId === pointId + ); + const oppositePointId = + pointIndex >= 0 && targetVerticalPolygonGroup.nodeIds.length === 4 + ? targetVerticalPolygonGroup.nodeIds[(pointIndex + 2) % 4] ?? null + : null; + const oppositePointPosition = oppositePointId + ? pointById.get(oppositePointId) ?? null + : null; + + const planeNormalFromGroup = targetVerticalPolygonGroup.plane + ? normalizeDirection( + cartesian3FromJson(targetVerticalPolygonGroup.plane.normalECEF) + ) + : null; + let planeNormal = planeNormalFromGroup; + if (!planeNormal) { + const vertices = targetVerticalPolygonGroup.nodeIds + .map((nodeId) => pointById.get(nodeId)) + .filter((vertex): vertex is Cartesian3 => Boolean(vertex)); + if (vertices.length >= 3) { + const derivedPlane = createPlaneFromThreePoints( + vertices[0], + vertices[1], + vertices[2] + ); + if (derivedPlane) { + const orientedDerivedPlane = + orientPlaneTowardSceneCamera(derivedPlane); + planeNormal = normalizeDirection( + cartesian3FromJson(orientedDerivedPlane.normalECEF) + ); + } + } + } + + if (planeNormal) { + const upDirection = getLocalUpDirectionAtAnchor(pointPosition); + const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); + const eastAxis4 = Matrix4.getColumn(enuMatrix, 0, new Cartesian4()); + const fallbackEastDirection = normalizeDirection( + new Cartesian3(eastAxis4.x, eastAxis4.y, eastAxis4.z) + ); + + let eastDirection: Cartesian3 | null = null; + if (oppositePointPosition) { + const oppositeDelta = Cartesian3.subtract( + oppositePointPosition, + pointPosition, + new Cartesian3() + ); + const horizontalHint = Cartesian3.subtract( + oppositeDelta, + Cartesian3.multiplyByScalar( + upDirection, + Cartesian3.dot(oppositeDelta, upDirection), + new Cartesian3() + ), + new Cartesian3() + ); + const hintOnPlane = Cartesian3.subtract( + horizontalHint, + Cartesian3.multiplyByScalar( + planeNormal, + Cartesian3.dot(horizontalHint, planeNormal), + new Cartesian3() + ), + new Cartesian3() + ); + eastDirection = normalizeDirection(hintOnPlane); + } + + if (!eastDirection) { + const inPlaneHorizontal = Cartesian3.cross( + planeNormal, + upDirection, + new Cartesian3() + ); + eastDirection = normalizeDirection(inPlaneHorizontal); + } + + if (!eastDirection && fallbackEastDirection) { + const fallbackOnPlane = Cartesian3.subtract( + fallbackEastDirection, + Cartesian3.multiplyByScalar( + planeNormal, + Cartesian3.dot(fallbackEastDirection, planeNormal), + new Cartesian3() + ), + new Cartesian3() + ); + eastDirection = normalizeDirection(fallbackOnPlane); + } + + if (eastDirection) { + let northDirection = normalizeDirection( + Cartesian3.cross(upDirection, eastDirection, new Cartesian3()) + ); + if (!northDirection) { + northDirection = planeNormal; + } + + if (Cartesian3.dot(northDirection, planeNormal) < 0) { + northDirection = Cartesian3.multiplyByScalar( + northDirection, + -1, + new Cartesian3() + ); + eastDirection = Cartesian3.multiplyByScalar( + eastDirection, + -1, + new Cartesian3() + ); + } + + const eastRotationDegVsEnuEast = + fallbackEastDirection && + getSignedAngleDegAroundAxis( + fallbackEastDirection, + eastDirection, + northDirection + ); + const axisRotationSuffix = getVerticalPolygonAxisRotationSuffix( + eastRotationDegVsEnuEast + ); + const upAxisTitle = `Punkt entlang der ENU-U-Achse${axisRotationSuffix} verschieben`; + + const verticalPolygonAxisCandidates = [ + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_UP, + direction: upDirection, + color: "rgba(59, 130, 246, 0.98)", + title: upAxisTitle, + }, + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_EAST, + direction: eastDirection, + color: "rgba(239, 68, 68, 0.98)", + title: `Punkt entlang der ENU-E-Achse${axisRotationSuffix} verschieben`, + }, + { + id: VERTICAL_POLYGON_AXIS_ID_ENU_NORTH, + direction: northDirection, + color: "rgba(34, 197, 94, 0.98)", + title: + "Punkt entlang der ENU-N-Achse (Flächennormale) verschieben", + }, + ] as const; + + selectAnnotationById(pointId); + startMoveGizmoForAnnotationId(pointId, { + axisDirection: upDirection, + axisTitle: upAxisTitle, + preferredAxisId: VERTICAL_POLYGON_AXIS_ID_ENU_UP, + axisCandidates: verticalPolygonAxisCandidates.map( + (axisCandidate) => ({ + ...axisCandidate, + direction: Cartesian3.clone(axisCandidate.direction), + }) + ), + }); + return; + } + } + } + } + + const targetPolygonAnnotation = + (focusedNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => + group.id === focusedNodeChainAnnotationId && + group.type === ANNOTATION_TYPE_AREA_PLANAR && + group.planeLocked && + group.nodeIds.includes(pointId) + ) + : null) ?? + nodeChainAnnotations.find( + (group) => + group.type === ANNOTATION_TYPE_AREA_PLANAR && + group.planeLocked && + group.nodeIds.includes(pointId) + ) ?? + null; + + if (targetPolygonAnnotation) { + const pointById = getPointPositionMap(annotations); + const pointPosition = pointById.get(pointId); + if (pointPosition) { + const planeNormalFromGroup = targetPolygonAnnotation.plane + ? normalizeDirection( + cartesian3FromJson(targetPolygonAnnotation.plane.normalECEF) + ) + : null; + let planeNormal = planeNormalFromGroup; + if (!planeNormal) { + const vertices = targetPolygonAnnotation.nodeIds + .map((nodeId) => pointById.get(nodeId)) + .filter((vertex): vertex is Cartesian3 => Boolean(vertex)); + if (vertices.length >= 3) { + const derivedPlane = createPlaneFromThreePoints( + vertices[0], + vertices[1], + vertices[2] + ); + if (derivedPlane) { + const orientedDerivedPlane = + orientPlaneTowardSceneCamera(derivedPlane); + planeNormal = normalizeDirection( + cartesian3FromJson(orientedDerivedPlane.normalECEF) + ); + } + } + } + + if (planeNormal) { + const upDirection = getLocalUpDirectionAtAnchor(pointPosition); + const orientedPlaneNormal = + Cartesian3.dot(planeNormal, upDirection) < 0 + ? Cartesian3.multiplyByScalar(planeNormal, -1, new Cartesian3()) + : Cartesian3.clone(planeNormal); + const enuMatrix = Transforms.eastNorthUpToFixedFrame(pointPosition); + const enuEastAxis4 = Matrix4.getColumn( + enuMatrix, + 0, + new Cartesian4() + ); + const enuNorthAxis4 = Matrix4.getColumn( + enuMatrix, + 1, + new Cartesian4() + ); + const enuEastDirection = normalizeDirection( + new Cartesian3(enuEastAxis4.x, enuEastAxis4.y, enuEastAxis4.z) + ); + const enuNorthDirection = normalizeDirection( + new Cartesian3(enuNorthAxis4.x, enuNorthAxis4.y, enuNorthAxis4.z) + ); + + const projectDirectionOntoPlanarPlane = ( + direction: Cartesian3 | null + ) => { + if (!direction) return null; + return normalizeDirection( + Cartesian3.subtract( + direction, + Cartesian3.multiplyByScalar( + orientedPlaneNormal, + Cartesian3.dot(direction, orientedPlaneNormal), + new Cartesian3() + ), + new Cartesian3() + ) + ); + }; + + const inPlanePrimaryDirection = + projectDirectionOntoPlanarPlane(enuNorthDirection) ?? + projectDirectionOntoPlanarPlane(upDirection); + + const inPlaneSecondaryDirection = inPlanePrimaryDirection + ? normalizeDirection( + Cartesian3.cross( + inPlanePrimaryDirection, + orientedPlaneNormal, + new Cartesian3() + ) + ) ?? projectDirectionOntoPlanarPlane(enuEastDirection) + : null; + + if (inPlanePrimaryDirection && inPlaneSecondaryDirection) { + const planarNormalAxisTitle = + "Punkt entlang der Dachflächennormale verschieben"; + const planarAxisCandidates = [ + { + id: PLANAR_POLYGON_AXIS_ID_NORMAL, + direction: orientedPlaneNormal, + color: "rgba(59, 130, 246, 0.98)", + title: planarNormalAxisTitle, + }, + { + id: PLANAR_POLYGON_AXIS_ID_IN_PLANE_PRIMARY, + direction: inPlanePrimaryDirection, + color: "rgba(34, 197, 94, 0.98)", + title: + "Punkt entlang der ENU-N-Projektion in der Dachebene verschieben", + }, + { + id: PLANAR_POLYGON_AXIS_ID_IN_PLANE_SECONDARY, + direction: inPlaneSecondaryDirection, + color: "rgba(239, 68, 68, 0.98)", + title: + "Punkt orthogonal zur ENU-N-Projektion in der Dachebene verschieben", + }, + ] as const; + + selectAnnotationById(pointId); + startMoveGizmoForAnnotationId(pointId, { + axisDirection: orientedPlaneNormal, + axisTitle: planarNormalAxisTitle, + preferredAxisId: PLANAR_POLYGON_AXIS_ID_NORMAL, + axisCandidates: planarAxisCandidates.map((axisCandidate) => ({ + ...axisCandidate, + direction: Cartesian3.clone(axisCandidate.direction), + })), + }); + return; + } + } + } + } + + selectAnnotationById(pointId); + startMoveGizmoForAnnotationId(pointId); + }, + [ + annotations, + focusedNodeChainAnnotationId, + orientPlaneTowardSceneCamera, + nodeChainAnnotations, + selectAnnotationById, + startMoveGizmoForAnnotationId, + ] + ); + + const requestStartEdit = useCallback( + (target: AnnotationEditTarget) => { + switch (target.kind) { + case "point-vertical-offset-stem": + handlePointVerticalOffsetStemLongPress(target.pointId); + setActiveEditTarget(target); + return; + case "point-label": + handlePointLabelLongPress(target.pointId); + setActiveEditTarget(target); + return; + case "point": + selectAnnotationById(target.pointId); + startMoveGizmoForAnnotationId(target.pointId); + setActiveEditTarget(target); + return; + } + }, + [ + handlePointLabelLongPress, + handlePointVerticalOffsetStemLongPress, + selectAnnotationById, + startMoveGizmoForAnnotationId, + ] + ); + + const requestUpdateEditTarget = useCallback( + (target: AnnotationEditUpdateTarget) => { + if (target.kind !== "point-elevation-reference" || !moveGizmo.pointId) { + return false; + } + + setMoveGizmoPointElevationFromAnnotationId(target.pointId); + return true; + }, + [moveGizmo.pointId, setMoveGizmoPointElevationFromAnnotationId] + ); + + const requestStopEdit = useCallback(() => { + clearActiveEditTarget(); + clearMoveGizmo(); + }, [clearActiveEditTarget, clearMoveGizmo]); + + return { + activeEditTarget, + moveGizmoPointId: moveGizmo.pointId, + isMoveGizmoDragging: moveGizmo.isDragging, + handleDistanceRelationLineClick, + handleDistanceRelationLineLabelToggle, + handleDistanceRelationCornerClick, + handleDistanceRelationMidpointClick, + requestStartEdit, + requestStopEdit, + requestUpdateEditTarget, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useDistanceRelationEditing.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useDistanceRelationEditing.ts new file mode 100644 index 0000000000..27a202ac52 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/useDistanceRelationEditing.ts @@ -0,0 +1,302 @@ +import { useCallback } from "react"; + +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, + getPositionFromLocalFrame, + getPositionInLocalFrame, + resolveLocalFrameVectors, + type Scene, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_DISTANCE, + buildEdgeRelationIdsForPolygon, + computePolygonGroupDerivedData, + getDistanceRelationId, + getPointPositionMap, + isPointAnnotationEntry, + projectPointOntoPlane, + type AnnotationCollection, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseDistanceRelationEditingParams = { + scene: Scene | null | undefined; + annotations: AnnotationCollection; + nodeChainAnnotations: readonly NodeChainAnnotation[]; + setAnnotations: React.Dispatch>; + setDistanceRelations: React.Dispatch< + React.SetStateAction + >; + setNodeChainAnnotations: React.Dispatch< + React.SetStateAction + >; + setActiveNodeChainAnnotationId: (id: string | null) => void; + setDoubleClickChainSourcePointId: (id: string | null) => void; + selectAnnotationById: (id: string | null) => void; +}; + +const getOwnerMeasurementForEdgeRelationId = ( + nodeChainAnnotations: readonly NodeChainAnnotation[], + relationId: string +) => + nodeChainAnnotations.find((measurement) => + measurement.edgeRelationIds.includes(relationId) + ) ?? null; + +export const useDistanceRelationEditing = ({ + scene, + annotations, + nodeChainAnnotations, + setAnnotations, + setDistanceRelations, + setNodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + selectAnnotationById, +}: UseDistanceRelationEditingParams) => { + const getPreferredPlaneFacingPosition = useCallback((): Cartesian3 | null => { + if (!scene || scene.isDestroyed()) { + return null; + } + + return scene.camera.positionWC; + }, [scene]); + + const handleDistanceRelationCornerClick = useCallback( + (relationId: string) => { + if (!relationId) { + return; + } + + setDistanceRelations((previousRelations) => + previousRelations.map((relation) => { + if (relation.id !== relationId) { + return relation; + } + + const nextAnchorPointId = + relation.anchorPointId === relation.pointAId + ? relation.pointBId + : relation.pointAId; + + return { + ...relation, + anchorPointId: nextAnchorPointId, + }; + }) + ); + }, + [setDistanceRelations] + ); + + const handleDistanceRelationMidpointClick = useCallback( + (relationId: string) => { + if (!relationId) { + return; + } + + const targetMeasurement = getOwnerMeasurementForEdgeRelationId( + nodeChainAnnotations, + relationId + ); + if (!targetMeasurement) { + return; + } + + const nodeIds = targetMeasurement.nodeIds; + if (nodeIds.length < 2) { + return; + } + + let edgeStartId: string | null = null; + let edgeEndId: string | null = null; + let insertIndex = -1; + + for (let index = 0; index < nodeIds.length - 1; index += 1) { + const startId = nodeIds[index]; + const endId = nodeIds[index + 1]; + if (!startId || !endId) { + continue; + } + + if (getDistanceRelationId(startId, endId) === relationId) { + edgeStartId = startId; + edgeEndId = endId; + insertIndex = index + 1; + break; + } + } + + if (!edgeStartId || !edgeEndId) { + if (targetMeasurement.closed && nodeIds.length >= 3) { + const startId = nodeIds[nodeIds.length - 1] ?? null; + const endId = nodeIds[0] ?? null; + if ( + startId && + endId && + getDistanceRelationId(startId, endId) === relationId + ) { + edgeStartId = startId; + edgeEndId = endId; + insertIndex = nodeIds.length; + } + } + } + + if (!edgeStartId || !edgeEndId || insertIndex < 0) { + return; + } + + const pointById = getPointPositionMap(annotations); + const startPoint = pointById.get(edgeStartId); + const endPoint = pointById.get(edgeEndId); + if (!startPoint || !endPoint) { + return; + } + + let midpointPosition = Cartesian3.midpoint( + startPoint, + endPoint, + new Cartesian3() + ); + const targetMeasurementVerticalFrame = + targetMeasurement.type === ANNOTATION_TYPE_AREA_VERTICAL + ? resolveLocalFrameVectors(targetMeasurement.planarPolygonLocalFrame) + : null; + + if (targetMeasurementVerticalFrame) { + const startLocal = getPositionInLocalFrame( + startPoint, + targetMeasurementVerticalFrame + ); + const endLocal = getPositionInLocalFrame( + endPoint, + targetMeasurementVerticalFrame + ); + midpointPosition = getPositionFromLocalFrame( + targetMeasurementVerticalFrame, + (startLocal.eastMeters + endLocal.eastMeters) / 2, + (startLocal.northMeters + endLocal.northMeters) / 2, + (startLocal.upMeters + endLocal.upMeters) / 2 + ); + } + + if ( + targetMeasurement.type !== ANNOTATION_TYPE_AREA_GROUND && + targetMeasurement.planeLocked && + targetMeasurement.plane + ) { + midpointPosition = projectPointOntoPlane( + midpointPosition, + targetMeasurement.plane + ); + } + + const nextPointId = `point-${Date.now()}-split`; + const midpointWGS84 = getDegreesFromCartesian(midpointPosition); + + setAnnotations((previousAnnotations) => { + const insertionBaseIndex = + previousAnnotations.find( + (annotation) => + isPointAnnotationEntry(annotation) && + annotation.id === edgeStartId + )?.index ?? previousAnnotations.filter(isPointAnnotationEntry).length; + const insertionIndex = insertionBaseIndex + 1; + + const nextAnnotations = previousAnnotations.map((annotation) => { + if ( + isPointAnnotationEntry(annotation) && + annotation.index >= insertionIndex + ) { + return { + ...annotation, + index: annotation.index + 1, + }; + } + + return annotation; + }); + + return [ + ...nextAnnotations, + { + type: ANNOTATION_TYPE_DISTANCE, + id: nextPointId, + index: insertionIndex, + geometryECEF: midpointPosition, + geometryWGS84: { + longitude: midpointWGS84.longitude, + latitude: midpointWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(midpointWGS84.altitude), + }, + timestamp: Date.now(), + }, + ]; + }); + + const updatedPointById = getPointPositionMap(annotations, { + [nextPointId]: midpointPosition, + }); + + setNodeChainAnnotations((previousMeasurements) => + previousMeasurements.map((measurement) => { + if (measurement.id !== targetMeasurement.id) { + return measurement; + } + + const nextNodeIds = [ + ...measurement.nodeIds.slice(0, insertIndex), + nextPointId, + ...measurement.nodeIds.slice(insertIndex), + ]; + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + measurement.closed, + getDistanceRelationId + ); + + return computePolygonGroupDerivedData( + { + ...measurement, + nodeIds: nextNodeIds, + edgeRelationIds: nextEdgeRelationIds, + }, + updatedPointById, + { + preferredFacingPositionECEF: getPreferredPlaneFacingPosition(), + } + ); + }) + ); + + setActiveNodeChainAnnotationId(targetMeasurement.id); + setDoubleClickChainSourcePointId(nextPointId); + selectAnnotationById(nextPointId); + }, + [ + annotations, + getPreferredPlaneFacingPosition, + nodeChainAnnotations, + selectAnnotationById, + setActiveNodeChainAnnotationId, + setAnnotations, + setDoubleClickChainSourcePointId, + setNodeChainAnnotations, + ] + ); + + return { + handleDistanceRelationCornerClick, + handleDistanceRelationMidpointClick, + }; +}; + +export type DistanceRelationEditingState = ReturnType< + typeof useDistanceRelationEditing +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingGizmo.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingGizmo.ts new file mode 100644 index 0000000000..65f3494298 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingGizmo.ts @@ -0,0 +1,78 @@ +import { useMemo } from "react"; + +import { Cartesian3, type Scene } from "@carma/cesium"; +import { + isPointAnnotationEntry, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; +import { useCesiumPointMoveGizmo } from "@carma-mapping/gizmo/cesium"; +import type { MoveGizmoSession } from "./annotationEdit.types"; + +export type PointEditingGizmoOptions = { + pointRadius: number; + snapPlaneDragToGround: boolean; + onPointPositionChange: (pointId: string, nextPosition: Cartesian3) => void; + onDragStateChange: (isDragging: boolean) => void; + onAxisChange: (axisDirection: Cartesian3, axisTitle?: string | null) => void; + onExit: () => void; +}; + +export const usePointEditingGizmo = ( + scene: Scene | null, + annotations: AnnotationCollection, + moveGizmo: Pick< + MoveGizmoSession, + | "pointId" + | "axisDirection" + | "preferredAxisId" + | "axisTitle" + | "axisCandidates" + >, + { + pointRadius, + snapPlaneDragToGround, + onPointPositionChange, + onDragStateChange, + onAxisChange, + onExit, + }: PointEditingGizmoOptions +) => { + const points = useMemo( + () => annotations.filter(isPointAnnotationEntry), + [annotations] + ); + + const gizmoPoints = useMemo( + () => + points.map((point) => { + if (!point.verticalOffsetAnchorECEF) { + return point; + } + return { + ...point, + geometryECEF: new Cartesian3( + point.verticalOffsetAnchorECEF.x, + point.verticalOffsetAnchorECEF.y, + point.verticalOffsetAnchorECEF.z + ), + }; + }), + [points] + ); + + useCesiumPointMoveGizmo(scene, { + points: gizmoPoints, + movePointId: moveGizmo.pointId, + axisDirection: moveGizmo.axisDirection, + axisTitle: moveGizmo.axisTitle, + preferredAxisId: moveGizmo.preferredAxisId, + axisCandidates: moveGizmo.axisCandidates, + snapPlaneDragToGround, + showRotationHandle: false, + radius: pointRadius, + onPointPositionChange, + onDragStateChange, + onAxisDirectionChange: onAxisChange, + onExit, + }); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingState.ts new file mode 100644 index 0000000000..a2307b61f5 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/editing/usePointEditingState.ts @@ -0,0 +1,398 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; + +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, + getLocalUpDirectionAtAnchor, + getPositionWithVerticalOffsetFromAnchor, + normalizeDirection, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_POLYLINE, + applyDeltaToSelectedPoints, + computeMoveDelta, + getPointPositionMap, + getSelectedPointIds, + hasReferencePointInSelection, + isPointAnnotationEntry, + shouldMoveSelectionAsGroup, + type AnnotationCollection, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; +import { useAnnotationPointEditingController } from "./useAnnotationPointEditingController"; +import type { MoveGizmoSession } from "./annotationEdit.types"; + +const REFERENCE_POINT_SYNC_EPSILON_METERS = 0.001; +const VERTICAL_POLYGON_AXIS_ALIGNMENT_DOT_EPSILON = 0.999; +const VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS = 0.05; +const VERTICAL_POLYGON_AXIS_ID_ENU_EAST = "enu-east"; +const VERTICAL_POLYGON_AXIS_ID_ENU_NORTH = "enu-north"; + +type PointEditingStateOptions = { + setAnnotations: Dispatch>; + setNodeChainAnnotations: Dispatch>; + setReferencePoint: Dispatch>; + moveGizmo: MoveGizmoSession; +}; + +export const usePointEditingState = ( + annotations: AnnotationCollection, + nodeChainAnnotations: readonly NodeChainAnnotation[], + referencePoint: Cartesian3 | null, + selectedAnnotationIds: readonly string[], + { + setAnnotations, + setNodeChainAnnotations, + setReferencePoint, + moveGizmo, + }: PointEditingStateOptions +) => { + const selectablePointIds = useMemo( + () => + new Set( + annotations + .filter(isPointAnnotationEntry) + .map((measurement) => measurement.id) + ), + [annotations] + ); + const lockedMeasurementIdSet = useMemo(() => { + const ids = new Set(); + annotations.forEach((measurement) => { + if (measurement.locked) { + ids.add(measurement.id); + } + }); + return ids; + }, [annotations]); + + const { + updatePointAnnotationPositionById, + setPointAnnotationElevationById, + setPointAnnotationCoordinatesById, + setMoveGizmoPointElevationFromAnnotationId, + } = useAnnotationPointEditingController(annotations, referencePoint, { + moveGizmoPointId: moveGizmo.pointId, + setAnnotations, + setReferencePoint, + referencePointSyncEpsilonMeters: REFERENCE_POINT_SYNC_EPSILON_METERS, + }); + + const handleMoveGizmoPointPositionChange = useCallback( + (pointId: string, nextPosition: Cartesian3) => { + const movedPointMeasurement = annotations.find( + (measurement) => + isPointAnnotationEntry(measurement) && measurement.id === pointId + ); + if ( + !movedPointMeasurement || + !isPointAnnotationEntry(movedPointMeasurement) || + lockedMeasurementIdSet.has(pointId) + ) { + return; + } + + const selectedPointIds = getSelectedPointIds( + [...selectedAnnotationIds], + selectablePointIds + ).filter((id) => !lockedMeasurementIdSet.has(id)); + const moveSelectionAsGroup = shouldMoveSelectionAsGroup( + pointId, + moveGizmo.pointId, + selectedPointIds + ); + + const movedPointAnchor = movedPointMeasurement.verticalOffsetAnchorECEF + ? new Cartesian3( + movedPointMeasurement.verticalOffsetAnchorECEF.x, + movedPointMeasurement.verticalOffsetAnchorECEF.y, + movedPointMeasurement.verticalOffsetAnchorECEF.z + ) + : null; + const currentMoveOrigin = + movedPointAnchor ?? movedPointMeasurement.geometryECEF; + const delta = computeMoveDelta(nextPosition, currentMoveOrigin); + + const targetVerticalPolygonGroup = + nodeChainAnnotations.find( + (group) => + group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + group.nodeIds.includes(pointId) + ) ?? null; + const moveNorthAxisCandidate = + moveGizmo.axisCandidates?.find( + (candidate) => candidate.id === VERTICAL_POLYGON_AXIS_ID_ENU_NORTH + ) ?? + moveGizmo.axisCandidates?.find( + (candidate) => candidate.id === "horizontal-north" + ) ?? + null; + const moveEastAxisCandidate = + moveGizmo.axisCandidates?.find( + (candidate) => candidate.id === VERTICAL_POLYGON_AXIS_ID_ENU_EAST + ) ?? + moveGizmo.axisCandidates?.find( + (candidate) => candidate.id === "horizontal-east" + ) ?? + null; + const normalizedActiveAxisDirection = moveGizmo.axisDirection + ? normalizeDirection(moveGizmo.axisDirection) + : null; + const normalizedNorthAxisDirection = moveNorthAxisCandidate + ? normalizeDirection(moveNorthAxisCandidate.direction) + : null; + const normalizedEastAxisDirection = moveEastAxisCandidate + ? normalizeDirection(moveEastAxisCandidate.direction) + : null; + const isVerticalPolygonNorthAxisActive = Boolean( + targetVerticalPolygonGroup && + normalizedActiveAxisDirection && + normalizedNorthAxisDirection && + Math.abs( + Cartesian3.dot( + normalizedActiveAxisDirection, + normalizedNorthAxisDirection + ) + ) >= VERTICAL_POLYGON_AXIS_ALIGNMENT_DOT_EPSILON + ); + + const verticalPolygonCoupledPointIdSet = new Set(); + if ( + targetVerticalPolygonGroup && + isVerticalPolygonNorthAxisActive && + normalizedNorthAxisDirection && + normalizedEastAxisDirection + ) { + const pointById = getPointPositionMap(annotations); + targetVerticalPolygonGroup.nodeIds.forEach((candidatePointId) => { + if (!candidatePointId || candidatePointId === pointId) { + return; + } + if (lockedMeasurementIdSet.has(candidatePointId)) { + return; + } + + const candidatePosition = pointById.get(candidatePointId); + if (!candidatePosition) { + return; + } + + const candidateDelta = Cartesian3.subtract( + candidatePosition, + movedPointMeasurement.geometryECEF, + new Cartesian3() + ); + const deltaE = Cartesian3.dot( + candidateDelta, + normalizedEastAxisDirection + ); + const deltaN = Cartesian3.dot( + candidateDelta, + normalizedNorthAxisDirection + ); + if ( + Math.abs(deltaE) <= VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS && + Math.abs(deltaN) <= VERTICAL_POLYGON_EN_MATCH_EPSILON_METERS + ) { + verticalPolygonCoupledPointIdSet.add(candidatePointId); + } + }); + } + + if (movedPointAnchor && moveGizmo.verticalOffsetEditMode) { + const deltaFromAnchor = Cartesian3.subtract( + nextPosition, + movedPointAnchor, + new Cartesian3() + ); + const nextOffsetMeters = Cartesian3.dot( + deltaFromAnchor, + getLocalUpDirectionAtAnchor(movedPointAnchor) + ); + + if (moveGizmo.verticalOffsetEditMode === ANNOTATION_TYPE_POLYLINE) { + const targetNodeChainAnnotationId = + moveGizmo.verticalOffsetNodeChainAnnotationId ?? + nodeChainAnnotations.find( + (group) => !group.closed && group.nodeIds.includes(pointId) + )?.id ?? + null; + + if (targetNodeChainAnnotationId) { + const targetGroup = nodeChainAnnotations.find( + (group) => group.id === targetNodeChainAnnotationId + ); + if (targetGroup) { + const targetVertexIdSet = new Set(targetGroup.nodeIds); + setNodeChainAnnotations((previousGroups) => + previousGroups.map((group) => + group.id === targetNodeChainAnnotationId + ? { + ...group, + verticalOffsetMeters: nextOffsetMeters, + } + : group + ) + ); + setAnnotations((previousAnnotations) => + previousAnnotations.map((measurement) => { + if ( + !isPointAnnotationEntry(measurement) || + !targetVertexIdSet.has(measurement.id) || + !measurement.verticalOffsetAnchorECEF + ) { + return measurement; + } + + const anchorECEF = new Cartesian3( + measurement.verticalOffsetAnchorECEF.x, + measurement.verticalOffsetAnchorECEF.y, + measurement.verticalOffsetAnchorECEF.z + ); + const nextGeometry = getPositionWithVerticalOffsetFromAnchor( + anchorECEF, + nextOffsetMeters + ); + const nextWGS84 = getDegreesFromCartesian(nextGeometry); + + return { + ...measurement, + geometryECEF: nextGeometry, + geometryWGS84: { + longitude: nextWGS84.longitude, + latitude: nextWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero( + nextWGS84.altitude + ), + }, + }; + }) + ); + return; + } + } + } + + const nextGeometry = getPositionWithVerticalOffsetFromAnchor( + movedPointAnchor, + nextOffsetMeters + ); + const nextWGS84 = getDegreesFromCartesian(nextGeometry); + setAnnotations((previousAnnotations) => + previousAnnotations.map((measurement) => { + if ( + !isPointAnnotationEntry(measurement) || + measurement.id !== pointId + ) { + return measurement; + } + + return { + ...measurement, + geometryECEF: nextGeometry, + geometryWGS84: { + longitude: nextWGS84.longitude, + latitude: nextWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(nextWGS84.altitude), + }, + }; + }) + ); + + if ( + referencePoint && + Cartesian3.distance( + movedPointMeasurement.geometryECEF, + referencePoint + ) <= REFERENCE_POINT_SYNC_EPSILON_METERS + ) { + setReferencePoint(nextGeometry); + } + return; + } + + if (!moveSelectionAsGroup) { + updatePointAnnotationPositionById(pointId, nextPosition, { + treatNextPositionAsOffsetAnchor: true, + }); + return; + } + + if (!delta) { + return; + } + + updatePointAnnotationPositionById(pointId, nextPosition, { + treatNextPositionAsOffsetAnchor: true, + }); + + const selectedPointIdSet = new Set( + selectedPointIds.filter((selectedId) => selectedId !== pointId) + ); + verticalPolygonCoupledPointIdSet.forEach((candidatePointId) => { + if (candidatePointId !== pointId) { + selectedPointIdSet.add(candidatePointId); + } + }); + if (selectedPointIdSet.size === 0) { + return; + } + + setAnnotations((previousAnnotations) => + applyDeltaToSelectedPoints( + previousAnnotations, + selectedPointIdSet, + delta + ) + ); + + if ( + hasReferencePointInSelection( + annotations, + selectedPointIdSet, + referencePoint, + REFERENCE_POINT_SYNC_EPSILON_METERS + ) && + referencePoint + ) { + const movedReferencePoint = Cartesian3.add( + referencePoint, + delta, + new Cartesian3() + ); + setReferencePoint(movedReferencePoint); + } + }, + [ + annotations, + lockedMeasurementIdSet, + moveGizmo, + nodeChainAnnotations, + referencePoint, + selectablePointIds, + selectedAnnotationIds, + setAnnotations, + setNodeChainAnnotations, + setReferencePoint, + updatePointAnnotationPositionById, + ] + ); + + return { + updatePointAnnotationPositionById, + setPointAnnotationElevationById, + setPointAnnotationCoordinatesById, + setMoveGizmoPointElevationFromAnnotationId, + handleMoveGizmoPointPositionChange, + }; +}; + +export type PointEditingState = ReturnType; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/index.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/index.ts new file mode 100644 index 0000000000..95bb1d33d4 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/index.ts @@ -0,0 +1,8 @@ +export * from "./pointCreateConfig"; +export * from "./editing"; +export * from "./useAnnotationCursorOverlay"; +export * from "./useAnnotationNodeInteractionController"; +export * from "./useAnnotationCursorState"; +export * from "./useAnnotationCandidateState"; +export * from "./useAnnotationsUserInteraction"; +export * from "./usePointQueryCreationController"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/annotationModeSession.types.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/annotationModeSession.types.ts new file mode 100644 index 0000000000..d62421be5d --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/annotationModeSession.types.ts @@ -0,0 +1,15 @@ +import type { AnnotationToolType } from "@carma-mapping/annotations/core"; +import type { Cartesian3 } from "@carma/cesium"; + +export type AnnotationModeSession = { + toolType: AnnotationToolType; + hasActiveDraft: () => boolean; + requestStart: () => void; + requestClose: () => void; + discardDraft: () => void; + onNodeCreated?: (id: string, positionECEF: Cartesian3) => void; +}; + +export type AnnotationModeSessionMap = Partial< + Record +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useDistanceMeasureModeSession.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useDistanceMeasureModeSession.ts new file mode 100644 index 0000000000..53626040ee --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useDistanceMeasureModeSession.ts @@ -0,0 +1,82 @@ +import { useCallback, useMemo } from "react"; +import type { Cartesian3 } from "@carma/cesium"; + +import { + ANNOTATION_TYPE_DISTANCE, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationModeSession } from "../annotationModeSession.types"; + +export const useDistanceMeasureModeSession = ( + openChainPointId: string | null, + selectablePointIds: ReadonlySet, + selectedAnnotationId: string | null, + distanceRelations: readonly PointDistanceRelation[], + nodeChainMeasurements: readonly NodeChainAnnotation[], + requestStartDistanceMode: () => void, + finishDistanceMeasurementSession: (selectedPointId: string | null) => void, + discardDistanceMeasurementDraft: () => void, + onNodeCreated: (id: string, positionECEF: Cartesian3) => void +): AnnotationModeSession => { + const activeDistanceSourcePointId = + openChainPointId && selectablePointIds.has(openChainPointId) + ? openChainPointId + : null; + const hasActiveDraft = Boolean(activeDistanceSourcePointId); + + const requestStart = useCallback(() => { + requestStartDistanceMode(); + }, [requestStartDistanceMode]); + + const requestClose = useCallback(() => { + if (!activeDistanceSourcePointId) { + return; + } + + const canPersistDistanceDraft = + distanceRelations.some( + (relation) => + relation.pointAId === activeDistanceSourcePointId || + relation.pointBId === activeDistanceSourcePointId + ) || + nodeChainMeasurements.some((measurement) => + measurement.nodeIds.includes(activeDistanceSourcePointId) + ); + + if (canPersistDistanceDraft) { + finishDistanceMeasurementSession(selectedAnnotationId); + return; + } + + discardDistanceMeasurementDraft(); + }, [ + activeDistanceSourcePointId, + discardDistanceMeasurementDraft, + distanceRelations, + finishDistanceMeasurementSession, + nodeChainMeasurements, + selectedAnnotationId, + ]); + + const discardDraft = useCallback(() => { + if (!activeDistanceSourcePointId) { + return; + } + + discardDistanceMeasurementDraft(); + }, [activeDistanceSourcePointId, discardDistanceMeasurementDraft]); + + return useMemo( + () => ({ + toolType: ANNOTATION_TYPE_DISTANCE, + hasActiveDraft: () => hasActiveDraft, + requestStart, + requestClose, + discardDraft, + onNodeCreated, + }), + [discardDraft, hasActiveDraft, onNodeCreated, requestClose, requestStart] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useLabelPlacementModeSession.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useLabelPlacementModeSession.ts new file mode 100644 index 0000000000..99f883c4f3 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useLabelPlacementModeSession.ts @@ -0,0 +1,40 @@ +import { useCallback, useMemo } from "react"; +import type { Cartesian3 } from "@carma/cesium"; + +import { ANNOTATION_TYPE_LABEL } from "@carma-mapping/annotations/core"; + +import type { AnnotationModeSession } from "../annotationModeSession.types"; + +export const useLabelPlacementModeSession = ( + labelInputPromptPointId: string | null, + requestStartLabelPlacementMode: () => void, + requestFinishLabelPlacementDraft: () => void, + requestCancelLabelPlacementDraft: () => void, + onNodeCreated: (id: string, positionECEF: Cartesian3) => void +): AnnotationModeSession => { + const hasActiveDraft = Boolean(labelInputPromptPointId); + + const requestStart = useCallback(() => { + requestStartLabelPlacementMode(); + }, [requestStartLabelPlacementMode]); + + const requestClose = useCallback(() => { + requestFinishLabelPlacementDraft(); + }, [requestFinishLabelPlacementDraft]); + + const discardDraft = useCallback(() => { + requestCancelLabelPlacementDraft(); + }, [requestCancelLabelPlacementDraft]); + + return useMemo( + () => ({ + toolType: ANNOTATION_TYPE_LABEL, + hasActiveDraft: () => hasActiveDraft, + requestStart, + requestClose, + discardDraft, + onNodeCreated, + }), + [discardDraft, hasActiveDraft, onNodeCreated, requestClose, requestStart] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useNodeChainMeasureModeSession.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useNodeChainMeasureModeSession.ts new file mode 100644 index 0000000000..f1bfef2724 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/useNodeChainMeasureModeSession.ts @@ -0,0 +1,82 @@ +import { useCallback, useMemo } from "react"; +import type { Cartesian3 } from "@carma/cesium"; + +import { + ANNOTATION_TYPE_POLYLINE, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationModeSession } from "../annotationModeSession.types"; + +export const useNodeChainMeasureModeSession = ( + toolType: NodeChainAnnotation["type"], + activeNodeChainAnnotationId: string | null, + nodeChainMeasurements: readonly NodeChainAnnotation[], + requestStartNodeChainToolMode: () => void, + requestFinishNodeChainMeasurement: () => void, + discardNodeChainMeasurementDraft: (measurementId: string) => void, + onNodeCreated: (id: string, positionECEF: Cartesian3) => void +): AnnotationModeSession => { + const activeOpenNodeChainAnnotation = useMemo( + () => + activeNodeChainAnnotationId !== null + ? nodeChainMeasurements.find( + (measurement) => + measurement.id === activeNodeChainAnnotationId && + !measurement.closed + ) ?? null + : null, + [activeNodeChainAnnotationId, nodeChainMeasurements] + ); + const hasActiveDraft = Boolean(activeOpenNodeChainAnnotation); + + const requestStart = useCallback(() => { + requestStartNodeChainToolMode(); + }, [requestStartNodeChainToolMode]); + + const requestClose = useCallback(() => { + if (!activeOpenNodeChainAnnotation) { + return; + } + + const minimumNodeCount = toolType === ANNOTATION_TYPE_POLYLINE ? 2 : 3; + if (activeOpenNodeChainAnnotation.nodeIds.length >= minimumNodeCount) { + requestFinishNodeChainMeasurement(); + return; + } + + discardNodeChainMeasurementDraft(activeOpenNodeChainAnnotation.id); + }, [ + activeOpenNodeChainAnnotation, + discardNodeChainMeasurementDraft, + requestFinishNodeChainMeasurement, + toolType, + ]); + + const discardDraft = useCallback(() => { + if (!activeOpenNodeChainAnnotation) { + return; + } + + discardNodeChainMeasurementDraft(activeOpenNodeChainAnnotation.id); + }, [activeOpenNodeChainAnnotation, discardNodeChainMeasurementDraft]); + + return useMemo( + () => ({ + toolType, + hasActiveDraft: () => hasActiveDraft, + requestStart, + requestClose, + discardDraft, + onNodeCreated, + }), + [ + discardDraft, + hasActiveDraft, + onNodeCreated, + requestClose, + requestStart, + toolType, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/usePointMeasureModeSession.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/usePointMeasureModeSession.ts new file mode 100644 index 0000000000..a1a1015f3e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/modes/usePointMeasureModeSession.ts @@ -0,0 +1,76 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; +import type { Cartesian3 } from "@carma/cesium"; + +import { + ANNOTATION_TYPE_POINT, + isPointMeasurementEntry, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationModeSession } from "../annotationModeSession.types"; + +export const usePointMeasureModeSession = ( + annotations: AnnotationCollection, + setAnnotations: Dispatch>, + clearAnnotationsByIds: (ids: string[]) => void, + requestStartPointMeasureMode: () => void, + onNodeCreated: (id: string, positionECEF: Cartesian3) => void +): AnnotationModeSession => { + const temporaryPointMeasureIds = useMemo( + () => + annotations + .filter( + (annotation) => + isPointMeasurementEntry(annotation) && + !annotation.auxiliaryLabelAnchor && + Boolean(annotation.temporary) + ) + .map((annotation) => annotation.id), + [annotations] + ); + + const hasActiveDraft = temporaryPointMeasureIds.length > 0; + + const requestClose = useCallback(() => { + if (!hasActiveDraft) { + return; + } + + setAnnotations((previousAnnotations) => + previousAnnotations.map((annotation) => + isPointMeasurementEntry(annotation) && annotation.temporary + ? { ...annotation, temporary: false } + : annotation + ) + ); + }, [hasActiveDraft, setAnnotations]); + + const discardDraft = useCallback(() => { + if (!hasActiveDraft) { + return; + } + + clearAnnotationsByIds(temporaryPointMeasureIds); + }, [clearAnnotationsByIds, hasActiveDraft, temporaryPointMeasureIds]); + + const requestStart = useCallback(() => { + requestStartPointMeasureMode(); + }, [requestStartPointMeasureMode]); + + return useMemo( + () => ({ + toolType: ANNOTATION_TYPE_POINT, + hasActiveDraft: () => hasActiveDraft, + requestStart, + requestClose, + discardDraft, + onNodeCreated, + }), + [discardDraft, hasActiveDraft, onNodeCreated, requestClose, requestStart] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useActiveToolType.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useActiveToolType.ts new file mode 100644 index 0000000000..4f0560515c --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useActiveToolType.ts @@ -0,0 +1,15 @@ +import { useMemo } from "react"; + +import { + SELECT_TOOL_TYPE, + type AnnotationToolType, +} from "@carma-mapping/annotations/core"; + +export const useActiveToolType = ( + annotationToolType: AnnotationToolType, + selectionModeActive: boolean +): AnnotationToolType => + useMemo( + () => (selectionModeActive ? SELECT_TOOL_TYPE : annotationToolType), + [annotationToolType, selectionModeActive] + ); diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftActions.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftActions.ts new file mode 100644 index 0000000000..647b6409ad --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftActions.ts @@ -0,0 +1,143 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import type { + AnnotationCollection, + NodeChainAnnotation, + PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationDraftActionsParams = { + createdPointIds: readonly string[]; + createdRelationIds: readonly string[]; + moveGizmoPointId: string | null; + setActiveNodeChainAnnotationId: Dispatch>; + setDoubleClickChainSourcePointId: Dispatch>; + setPendingPolylinePromotionRingClosurePointId: Dispatch< + SetStateAction + >; + setLabelInputPromptPointId: Dispatch>; + setNodeChainAnnotations: Dispatch>; + setDistanceRelations: Dispatch>; + setAnnotations: Dispatch>; + pruneSelectionByRemovedIds: (removedIds: ReadonlySet) => void; + clearMeasurementDraftSession: () => void; + clearAnnotationCursor: () => void; + clearAnnotationSelection: () => void; + clearMoveGizmo: () => void; +}; + +export const useAnnotationDraftActions = ({ + createdPointIds, + createdRelationIds, + moveGizmoPointId, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setPendingPolylinePromotionRingClosurePointId, + setLabelInputPromptPointId, + setNodeChainAnnotations, + setDistanceRelations, + setAnnotations, + pruneSelectionByRemovedIds, + clearMeasurementDraftSession, + clearAnnotationCursor, + clearAnnotationSelection, + clearMoveGizmo, +}: UseAnnotationDraftActionsParams) => { + const clearActiveNodeChainAnnotation = useCallback(() => { + setActiveNodeChainAnnotationId((previousId) => + previousId === null ? previousId : null + ); + }, [setActiveNodeChainAnnotationId]); + + const clearPendingPolylineRingPromotion = useCallback(() => { + setPendingPolylinePromotionRingClosurePointId((previousId) => + previousId === null ? previousId : null + ); + }, [setPendingPolylinePromotionRingClosurePointId]); + + const clearPendingLabelPlacementAnnotation = useCallback(() => { + setLabelInputPromptPointId((previousId) => + previousId === null ? previousId : null + ); + }, [setLabelInputPromptPointId]); + + const clearActiveNodeChainDrawingState = useCallback(() => { + clearActiveNodeChainAnnotation(); + setDoubleClickChainSourcePointId(null); + clearMeasurementDraftSession(); + }, [ + clearActiveNodeChainAnnotation, + clearMeasurementDraftSession, + setDoubleClickChainSourcePointId, + ]); + + const discardActiveMeasurementDraft = useCallback( + (activeGroupId: string | null) => { + const createdPointIdSet = new Set(createdPointIds); + const createdRelationIdSet = new Set(createdRelationIds); + + if (activeGroupId) { + setNodeChainAnnotations((previousGroups) => + previousGroups.filter((group) => group.id !== activeGroupId) + ); + } + + if (createdRelationIdSet.size > 0) { + setDistanceRelations((previousRelations) => + previousRelations.filter( + (relation) => !createdRelationIdSet.has(relation.id) + ) + ); + } + + if (createdPointIdSet.size > 0) { + setAnnotations((previousAnnotations) => + previousAnnotations.filter( + (annotation) => !createdPointIdSet.has(annotation.id) + ) + ); + pruneSelectionByRemovedIds(createdPointIdSet); + + if (moveGizmoPointId && createdPointIdSet.has(moveGizmoPointId)) { + clearMoveGizmo(); + } + + setLabelInputPromptPointId((previousPromptPointId) => + previousPromptPointId && createdPointIdSet.has(previousPromptPointId) + ? null + : previousPromptPointId + ); + } + + clearAnnotationCursor(); + clearAnnotationSelection(); + clearActiveNodeChainDrawingState(); + clearMoveGizmo(); + setPendingPolylinePromotionRingClosurePointId(null); + setLabelInputPromptPointId(null); + }, + [ + clearActiveNodeChainDrawingState, + clearAnnotationCursor, + clearAnnotationSelection, + clearMoveGizmo, + createdPointIds, + createdRelationIds, + moveGizmoPointId, + pruneSelectionByRemovedIds, + setAnnotations, + setDistanceRelations, + setLabelInputPromptPointId, + setNodeChainAnnotations, + setPendingPolylinePromotionRingClosurePointId, + ] + ); + + return { + clearActiveNodeChainAnnotation, + clearPendingPolylineRingPromotion, + clearPendingLabelPlacementAnnotation, + clearActiveNodeChainDrawingState, + discardActiveMeasurementDraft, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftRollbackState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftRollbackState.ts new file mode 100644 index 0000000000..97c02c7f20 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftRollbackState.ts @@ -0,0 +1,150 @@ +import { useCallback } from "react"; + +import { useStoreSelector } from "@carma-commons/react-store"; + +import type { AnnotationsStore } from "../../store"; + +const areStringListsEqual = ( + left: readonly string[], + right: readonly string[] +): boolean => { + if (left.length !== right.length) { + return false; + } + + return left.every((value, index) => value === right[index]); +}; + +const mergeUniqueIds = ( + previousIds: readonly string[], + nextIds: readonly string[] +): readonly string[] => { + const idsToAdd = nextIds + .filter(Boolean) + .filter((id) => !previousIds.includes(id)); + if (idsToAdd.length === 0) { + return previousIds; + } + + return [...previousIds, ...idsToAdd]; +}; + +export const useAnnotationDraftRollbackState = ( + annotationsStore: AnnotationsStore +) => { + const createdPointIds = useStoreSelector( + annotationsStore, + (state) => state.createdPointIds + ); + const createdRelationIds = useStoreSelector( + annotationsStore, + (state) => state.createdRelationIds + ); + + const clearMeasurementDraftSession = useCallback(() => { + annotationsStore.setState((previousState) => + previousState.createdPointIds.length === 0 && + previousState.createdRelationIds.length === 0 + ? previousState + : { + ...previousState, + createdPointIds: [], + createdRelationIds: [], + } + ); + }, [annotationsStore]); + + const trackMeasurementDraftPointIds = useCallback( + (pointIds: readonly string[]) => { + const normalizedPointIds = pointIds.filter(Boolean); + if (normalizedPointIds.length === 0) { + return; + } + + annotationsStore.setState((previousState) => { + const nextPointIds = mergeUniqueIds( + previousState.createdPointIds, + normalizedPointIds + ); + return nextPointIds === previousState.createdPointIds + ? previousState + : { + ...previousState, + createdPointIds: nextPointIds, + }; + }); + }, + [annotationsStore] + ); + + const trackMeasurementDraftRelationId = useCallback( + (relationId: string | null) => { + if (!relationId) { + return; + } + + annotationsStore.setState((previousState) => { + const nextRelationIds = mergeUniqueIds( + previousState.createdRelationIds, + [relationId] + ); + return nextRelationIds === previousState.createdRelationIds + ? previousState + : { + ...previousState, + createdRelationIds: nextRelationIds, + }; + }); + }, + [annotationsStore] + ); + + const pruneMeasurementDraftSession = useCallback( + ( + removedPointIds: ReadonlySet, + removedRelationIds?: ReadonlySet + ) => { + if ( + removedPointIds.size === 0 && + (!removedRelationIds || removedRelationIds.size === 0) + ) { + return; + } + + annotationsStore.setState((previousState) => { + const nextPointIds = previousState.createdPointIds.filter( + (pointId) => !removedPointIds.has(pointId) + ); + const nextRelationIds = previousState.createdRelationIds.filter( + (relationId) => !removedRelationIds?.has(relationId) + ); + + return areStringListsEqual( + previousState.createdPointIds, + nextPointIds + ) && + areStringListsEqual(previousState.createdRelationIds, nextRelationIds) + ? previousState + : { + ...previousState, + createdPointIds: nextPointIds, + createdRelationIds: nextRelationIds, + }; + }); + }, + [annotationsStore] + ); + + return { + createdPointIds, + createdRelationIds, + clearMeasurementDraftSession, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + pruneMeasurementDraftSession, + }; +}; + +export type MeasurementDraftRollbackState = ReturnType< + typeof useAnnotationDraftRollbackState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftSessionState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftSessionState.ts new file mode 100644 index 0000000000..de5b20b786 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationDraftSessionState.ts @@ -0,0 +1,138 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { useStoreSelector } from "@carma-commons/react-store"; + +import type { AnnotationsStore } from "../../store"; + +const resolveSetStateAction = ( + action: SetStateAction, + previousValue: TValue +): TValue => + typeof action === "function" + ? (action as (previousValue: TValue) => TValue)(previousValue) + : action; + +export const useAnnotationDraftSessionState = ( + annotationsStore: AnnotationsStore +) => { + const activeNodeChainAnnotationId = useStoreSelector( + annotationsStore, + (state) => state.activeNodeChainAnnotationId + ); + const pendingPolylinePromotionRingClosurePointId = useStoreSelector( + annotationsStore, + (state) => state.pendingPolylineRingPromotionPointId + ); + const labelInputPromptPointId = useStoreSelector( + annotationsStore, + (state) => state.pendingLabelPlacementAnnotationId + ); + const doubleClickChainSourcePointId = useStoreSelector( + annotationsStore, + (state) => state.openChainPointId + ); + + const setActiveNodeChainAnnotationId = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousState) => { + const nextActiveNodeChainAnnotationId = resolveSetStateAction( + nextValueOrUpdater, + previousState.activeNodeChainAnnotationId + ); + + return nextActiveNodeChainAnnotationId === + previousState.activeNodeChainAnnotationId + ? previousState + : { + ...previousState, + activeNodeChainAnnotationId: nextActiveNodeChainAnnotationId, + }; + }); + }, + [annotationsStore] + ); + + const setPendingPolylinePromotionRingClosurePointId = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousState) => { + const nextPendingPolylineRingPromotionPointId = resolveSetStateAction( + nextValueOrUpdater, + previousState.pendingPolylineRingPromotionPointId + ); + + return nextPendingPolylineRingPromotionPointId === + previousState.pendingPolylineRingPromotionPointId + ? previousState + : { + ...previousState, + pendingPolylineRingPromotionPointId: + nextPendingPolylineRingPromotionPointId, + }; + }); + }, + [annotationsStore] + ); + + const setLabelInputPromptPointId = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousState) => { + const nextPendingLabelPlacementAnnotationId = resolveSetStateAction( + nextValueOrUpdater, + previousState.pendingLabelPlacementAnnotationId + ); + + return nextPendingLabelPlacementAnnotationId === + previousState.pendingLabelPlacementAnnotationId + ? previousState + : { + ...previousState, + pendingLabelPlacementAnnotationId: + nextPendingLabelPlacementAnnotationId, + }; + }); + }, + [annotationsStore] + ); + + const setDoubleClickChainSourcePointId = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousState) => { + const nextOpenChainPointId = resolveSetStateAction( + nextValueOrUpdater, + previousState.openChainPointId + ); + + return nextOpenChainPointId === previousState.openChainPointId + ? previousState + : { + ...previousState, + openChainPointId: nextOpenChainPointId, + }; + }); + }, + [annotationsStore] + ); + + return { + activeNodeChainAnnotationId, + pendingPolylinePromotionRingClosurePointId, + labelInputPromptPointId, + doubleClickChainSourcePointId, + setActiveNodeChainAnnotationId, + setPendingPolylinePromotionRingClosurePointId, + setLabelInputPromptPointId, + setDoubleClickChainSourcePointId, + }; +}; + +export type AnnotationDraftSessionState = ReturnType< + typeof useAnnotationDraftSessionState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeLifecycle.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeLifecycle.ts new file mode 100644 index 0000000000..478ddfca6f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeLifecycle.ts @@ -0,0 +1,72 @@ +import { useCallback } from "react"; + +import type { AnnotationToolType } from "@carma-mapping/annotations/core"; + +import type { + AnnotationModeSession, + AnnotationModeSessionMap, +} from "./annotationModeSession.types"; + +const getModeSession = ( + sessionsByToolType: AnnotationModeSessionMap, + toolType: AnnotationToolType +): AnnotationModeSession | null => sessionsByToolType[toolType] ?? null; + +export const useAnnotationModeLifecycle = ( + activeToolType: AnnotationToolType, + sessionsByToolType: AnnotationModeSessionMap, + clearSharedModeExitState: () => void +) => { + const requestCloseActiveMeasurement = useCallback(() => { + const activeSession = getModeSession(sessionsByToolType, activeToolType); + if (!activeSession || !activeSession.hasActiveDraft()) { + return; + } + + activeSession.requestClose(); + }, [activeToolType, sessionsByToolType]); + + const requestModeChange = useCallback( + (nextToolType: AnnotationToolType) => { + if (nextToolType === activeToolType) { + return; + } + + const activeSession = getModeSession(sessionsByToolType, activeToolType); + if (activeSession?.hasActiveDraft()) { + activeSession.requestClose(); + } + + clearSharedModeExitState(); + const nextSession = getModeSession(sessionsByToolType, nextToolType); + nextSession?.requestStart(); + }, + [activeToolType, clearSharedModeExitState, sessionsByToolType] + ); + + const requestStartMeasurement = useCallback( + (toolType: AnnotationToolType = activeToolType) => { + if (toolType !== activeToolType) { + requestModeChange(toolType); + return; + } + + const activeSession = getModeSession(sessionsByToolType, activeToolType); + activeSession?.discardDraft(); + clearSharedModeExitState(); + activeSession?.requestStart(); + }, + [ + activeToolType, + clearSharedModeExitState, + requestModeChange, + sessionsByToolType, + ] + ); + + return { + requestModeChange, + requestStartMeasurement, + requestCloseActiveMeasurement, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeTransition.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeTransition.ts new file mode 100644 index 0000000000..186be00344 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationModeTransition.ts @@ -0,0 +1,72 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { + SELECT_TOOL_TYPE, + type AnnotationToolType, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationsStore } from "../../store"; + +type UseAnnotationModeTransitionParams = { + annotationsStore: AnnotationsStore; + setSelectionModeActive: Dispatch>; + clearAnnotationCursor: () => void; + clearAnnotationSelection: () => void; + clearActiveNodeChainDrawingState: () => void; + clearMoveGizmo: () => void; + clearPendingPolylineRingPromotion: () => void; + clearPendingLabelPlacementAnnotation: () => void; +}; + +export const useAnnotationModeTransition = ({ + annotationsStore, + setSelectionModeActive, + clearAnnotationCursor, + clearAnnotationSelection, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + clearPendingPolylineRingPromotion, + clearPendingLabelPlacementAnnotation, +}: UseAnnotationModeTransitionParams) => { + const requestEnterToolType = useCallback( + (toolType: AnnotationToolType) => { + const nextSelectionModeActive = toolType === SELECT_TOOL_TYPE; + + setSelectionModeActive((previousValue) => + previousValue === nextSelectionModeActive + ? previousValue + : nextSelectionModeActive + ); + annotationsStore.setState((previousStoreState) => + previousStoreState.annotationToolType === toolType + ? previousStoreState + : { + ...previousStoreState, + annotationToolType: toolType, + } + ); + }, + [annotationsStore, setSelectionModeActive] + ); + + const clearSharedModeExitState = useCallback(() => { + clearAnnotationCursor(); + clearAnnotationSelection(); + clearActiveNodeChainDrawingState(); + clearMoveGizmo(); + clearPendingPolylineRingPromotion(); + clearPendingLabelPlacementAnnotation(); + }, [ + clearActiveNodeChainDrawingState, + clearAnnotationCursor, + clearAnnotationSelection, + clearMoveGizmo, + clearPendingLabelPlacementAnnotation, + clearPendingPolylineRingPromotion, + ]); + + return { + requestEnterToolType, + clearSharedModeExitState, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolLifecycle.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolLifecycle.ts new file mode 100644 index 0000000000..7205cc46f8 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolLifecycle.ts @@ -0,0 +1,141 @@ +import { type Dispatch, type SetStateAction } from "react"; +import { type Cartesian3 } from "@carma/cesium"; +import { + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + type AnnotationCollection, + type AnnotationToolType, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import { useAnnotationModeLifecycle } from "./useAnnotationModeLifecycle"; +import { useAnnotationToolSessions } from "./useAnnotationToolSessions"; +import { usePointMeasureModeSession } from "./modes/usePointMeasureModeSession"; +import { useLabelPlacementModeSession } from "./modes/useLabelPlacementModeSession"; +import { usePointQueryToolRouting } from "./usePointQueryToolRouting"; + +type UseAnnotationToolLifecycleParams = { + activeToolType: AnnotationToolType; + annotations: AnnotationCollection; + setAnnotations: Dispatch>; + clearAnnotationsByIds: (ids: string[]) => void; + labelInputPromptPointId: string | null; + requestEnterToolType: (toolType: AnnotationToolType) => void; + requestFinishLabelPlacementDraft: () => void; + requestCancelLabelPlacementDraft: () => void; + handlePointAnnotationCreated: (pointId: string) => void; + handleLabelAnnotationCreated: (pointId: string) => void; + activeNodeChainAnnotationId: string | null; + doubleClickChainSourcePointId: string | null; + selectablePointIds: ReadonlySet; + selectedAnnotationId: string | null; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + discardActiveMeasurementDraft: ( + activeNodeChainAnnotationId: string | null + ) => void; + finishDistanceMeasurementSession: (selectedPointId: string | null) => void; + finishActivePolylineAnnotation: () => void; + closeActivePolygonAnnotation: () => void; + handleDistancePointCreated: (id: string, positionECEF: Cartesian3) => void; + handleNodeChainPointCreated: (id: string, positionECEF: Cartesian3) => void; + clearSharedModeExitState: () => void; + setLabelInputPromptPointId: Dispatch>; +}; + +export const useAnnotationToolLifecycle = ({ + activeToolType, + annotations, + setAnnotations, + clearAnnotationsByIds, + labelInputPromptPointId, + requestEnterToolType, + requestFinishLabelPlacementDraft, + requestCancelLabelPlacementDraft, + handlePointAnnotationCreated, + handleLabelAnnotationCreated, + activeNodeChainAnnotationId, + doubleClickChainSourcePointId, + selectablePointIds, + selectedAnnotationId, + distanceRelations, + nodeChainAnnotations, + discardActiveMeasurementDraft, + finishDistanceMeasurementSession, + finishActivePolylineAnnotation, + closeActivePolygonAnnotation, + handleDistancePointCreated, + handleNodeChainPointCreated, + clearSharedModeExitState, + setLabelInputPromptPointId, +}: UseAnnotationToolLifecycleParams) => { + const pointMeasureModeSession = usePointMeasureModeSession( + annotations, + setAnnotations, + clearAnnotationsByIds, + () => { + requestEnterToolType(ANNOTATION_TYPE_POINT); + }, + handlePointAnnotationCreated + ); + + const labelPlacementModeSession = useLabelPlacementModeSession( + labelInputPromptPointId, + () => { + requestEnterToolType(ANNOTATION_TYPE_LABEL); + }, + requestFinishLabelPlacementDraft, + requestCancelLabelPlacementDraft, + handleLabelAnnotationCreated + ); + + const toolSessions = useAnnotationToolSessions( + pointMeasureModeSession, + labelPlacementModeSession, + { + activeNodeChainAnnotationId, + openChainPointId: doubleClickChainSourcePointId, + selectablePointIds, + selectedAnnotationId, + distanceRelations, + nodeChainMeasurements: nodeChainAnnotations, + }, + { + requestEnterToolType, + discardActiveMeasurementDraft, + finishDistanceMeasurementSession, + finishActivePolylineAnnotation, + closeActivePolygonAnnotation, + handlePointMeasurePointCreated: handlePointAnnotationCreated, + handleDistancePointCreated, + handleNodeChainPointCreated, + } + ); + + const { confirmLabelPlacementById, handlePointQueryPointCreated } = + usePointQueryToolRouting({ + activeToolType, + toolSessions, + handlePointAnnotationCreated, + setLabelInputPromptPointId, + }); + + const { + requestModeChange, + requestStartMeasurement, + requestCloseActiveMeasurement, + } = useAnnotationModeLifecycle( + activeToolType, + toolSessions, + clearSharedModeExitState + ); + + return { + confirmLabelPlacementById, + handlePointQueryPointCreated, + requestModeChange, + requestStartMeasurement, + requestCloseActiveMeasurement, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolSessions.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolSessions.ts new file mode 100644 index 0000000000..c4b5b9cbb7 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useAnnotationToolSessions.ts @@ -0,0 +1,163 @@ +import { useMemo } from "react"; +import { Cartesian3 } from "@carma/cesium"; + +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + SELECT_TOOL_TYPE, + type AnnotationToolType, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import type { + AnnotationModeSession, + AnnotationModeSessionMap, +} from "./annotationModeSession.types"; +import { useDistanceMeasureModeSession } from "./modes/useDistanceMeasureModeSession"; +import { useNodeChainMeasureModeSession } from "./modes/useNodeChainMeasureModeSession"; + +type AnnotationToolSessionState = { + activeNodeChainAnnotationId: string | null; + openChainPointId: string | null; + selectablePointIds: ReadonlySet; + selectedAnnotationId: string | null; + distanceRelations: readonly PointDistanceRelation[]; + nodeChainMeasurements: readonly NodeChainAnnotation[]; +}; + +type AnnotationToolSessionActions = { + requestEnterToolType: (toolType: AnnotationToolType) => void; + discardActiveMeasurementDraft: ( + activeNodeChainAnnotationId: string | null + ) => void; + finishDistanceMeasurementSession: (selectedPointId: string | null) => void; + finishActivePolylineAnnotation: () => void; + closeActivePolygonAnnotation: () => void; + handlePointMeasurePointCreated: (id: string) => void; + handleDistancePointCreated: (id: string, positionECEF: Cartesian3) => void; + handleNodeChainPointCreated: (id: string, positionECEF: Cartesian3) => void; +}; + +const buildSelectToolSession = ( + requestEnterToolType: AnnotationToolSessionActions["requestEnterToolType"] +): AnnotationModeSession => ({ + toolType: SELECT_TOOL_TYPE, + hasActiveDraft: () => false, + requestStart: () => { + requestEnterToolType(SELECT_TOOL_TYPE); + }, + requestClose: () => {}, + discardDraft: () => {}, +}); + +export const useAnnotationToolSessions = ( + pointMeasureModeSession: AnnotationModeSession, + labelPlacementModeSession: AnnotationModeSession, + { + activeNodeChainAnnotationId, + openChainPointId, + selectablePointIds, + selectedAnnotationId, + distanceRelations, + nodeChainMeasurements, + }: AnnotationToolSessionState, + { + requestEnterToolType, + discardActiveMeasurementDraft, + finishDistanceMeasurementSession, + finishActivePolylineAnnotation, + closeActivePolygonAnnotation, + handlePointMeasurePointCreated, + handleDistancePointCreated, + handleNodeChainPointCreated, + }: AnnotationToolSessionActions +): AnnotationModeSessionMap => { + const distanceToolSession = useDistanceMeasureModeSession( + openChainPointId, + selectablePointIds, + selectedAnnotationId, + distanceRelations, + nodeChainMeasurements, + () => { + requestEnterToolType(ANNOTATION_TYPE_DISTANCE); + }, + finishDistanceMeasurementSession, + () => { + discardActiveMeasurementDraft(null); + }, + handleDistancePointCreated + ); + const polylineToolSession = useNodeChainMeasureModeSession( + ANNOTATION_TYPE_POLYLINE, + activeNodeChainAnnotationId, + nodeChainMeasurements, + () => { + requestEnterToolType(ANNOTATION_TYPE_POLYLINE); + }, + finishActivePolylineAnnotation, + discardActiveMeasurementDraft, + handleNodeChainPointCreated + ); + const groundAreaToolSession = useNodeChainMeasureModeSession( + ANNOTATION_TYPE_AREA_GROUND, + activeNodeChainAnnotationId, + nodeChainMeasurements, + () => { + requestEnterToolType(ANNOTATION_TYPE_AREA_GROUND); + }, + closeActivePolygonAnnotation, + discardActiveMeasurementDraft, + handleNodeChainPointCreated + ); + const verticalAreaToolSession = useNodeChainMeasureModeSession( + ANNOTATION_TYPE_AREA_VERTICAL, + activeNodeChainAnnotationId, + nodeChainMeasurements, + () => { + requestEnterToolType(ANNOTATION_TYPE_AREA_VERTICAL); + }, + closeActivePolygonAnnotation, + discardActiveMeasurementDraft, + handleNodeChainPointCreated + ); + const planarAreaToolSession = useNodeChainMeasureModeSession( + ANNOTATION_TYPE_AREA_PLANAR, + activeNodeChainAnnotationId, + nodeChainMeasurements, + () => { + requestEnterToolType(ANNOTATION_TYPE_AREA_PLANAR); + }, + closeActivePolygonAnnotation, + discardActiveMeasurementDraft, + handleNodeChainPointCreated + ); + + return useMemo( + () => ({ + [SELECT_TOOL_TYPE]: buildSelectToolSession(requestEnterToolType), + [ANNOTATION_TYPE_POINT]: pointMeasureModeSession, + [ANNOTATION_TYPE_LABEL]: labelPlacementModeSession, + [ANNOTATION_TYPE_DISTANCE]: distanceToolSession, + [ANNOTATION_TYPE_POLYLINE]: polylineToolSession, + [ANNOTATION_TYPE_AREA_GROUND]: groundAreaToolSession, + [ANNOTATION_TYPE_AREA_VERTICAL]: verticalAreaToolSession, + [ANNOTATION_TYPE_AREA_PLANAR]: planarAreaToolSession, + }), + [ + distanceToolSession, + groundAreaToolSession, + labelPlacementModeSession, + planarAreaToolSession, + pointMeasureModeSession, + polylineToolSession, + requestEnterToolType, + verticalAreaToolSession, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useDistanceMeasureAuthoring.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useDistanceMeasureAuthoring.ts new file mode 100644 index 0000000000..c5c7104b8e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useDistanceMeasureAuthoring.ts @@ -0,0 +1,248 @@ +import { useCallback } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import { + type DirectLineLabelMode, + getDistanceRelationId, + getMeasurementEdgeId, + isSameDistanceRelationPair, + type ReferenceLineLabelKind, + withDistanceRelationEdgeId, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type UseDistanceMeasureAuthoringParams = { + distanceCreationLineVisibility: { + direct: boolean; + vertical: boolean; + horizontal: boolean; + }; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; + defaultDirectLineLabelMode: DirectLineLabelMode; + distanceModeStickyToFirstPoint: boolean; + distanceRelations: PointDistanceRelation[]; + doubleClickChainSourcePointId: string | null; + selectablePointIds: ReadonlySet; + referencePointMeasurementId: string | null; + clearMeasurementDraftSession: () => void; + selectAnnotationById: (id: string | null) => void; + selectAnnotationByIdImmediate: (id: string | null) => void; + setDoubleClickChainSourcePointId: (id: string | null) => void; + setDistanceRelations: React.Dispatch< + React.SetStateAction + >; + setActiveNodeChainAnnotationId: (id: string | null) => void; + setReferencePoint: React.Dispatch>; + trackMeasurementDraftPointIds: (ids: string[]) => void; + trackMeasurementDraftRelationId: (id: string | null) => void; +}; + +export const useDistanceMeasureAuthoring = ({ + distanceCreationLineVisibility, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, + distanceModeStickyToFirstPoint, + distanceRelations, + doubleClickChainSourcePointId, + selectablePointIds, + referencePointMeasurementId, + clearMeasurementDraftSession, + selectAnnotationById, + selectAnnotationByIdImmediate, + setDoubleClickChainSourcePointId, + setDistanceRelations, + setActiveNodeChainAnnotationId, + setReferencePoint, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, +}: UseDistanceMeasureAuthoringParams) => { + const finishDistanceMeasurementSession = useCallback( + (selectedPointId: string | null, immediateSelection: boolean = false) => { + clearMeasurementDraftSession(); + setDoubleClickChainSourcePointId(null); + if (selectedPointId === null) { + return; + } + + if (immediateSelection) { + selectAnnotationByIdImmediate(selectedPointId); + return; + } + + selectAnnotationById(selectedPointId); + }, + [ + clearMeasurementDraftSession, + selectAnnotationById, + selectAnnotationByIdImmediate, + setDoubleClickChainSourcePointId, + ] + ); + + const resolveDistanceRelationSourcePointId = useCallback( + (targetPointId: string) => { + if (distanceModeStickyToFirstPoint && referencePointMeasurementId) { + return referencePointMeasurementId === targetPointId + ? null + : referencePointMeasurementId; + } + + const hasChainSource = Boolean( + doubleClickChainSourcePointId && + selectablePointIds.has(doubleClickChainSourcePointId) + ); + if (!hasChainSource) { + return null; + } + + return doubleClickChainSourcePointId === targetPointId + ? null + : doubleClickChainSourcePointId; + }, + [ + distanceModeStickyToFirstPoint, + doubleClickChainSourcePointId, + selectablePointIds, + referencePointMeasurementId, + ] + ); + + const upsertDirectDistanceRelation = useCallback( + (sourcePointId: string, targetPointId: string) => { + if (!sourcePointId || !targetPointId || sourcePointId === targetPointId) { + return; + } + + setDistanceRelations((previousRelations) => { + const relationIndex = previousRelations.findIndex((relation) => + isSameDistanceRelationPair(relation, sourcePointId, targetPointId) + ); + const relation = + relationIndex >= 0 + ? withDistanceRelationEdgeId(previousRelations[relationIndex]) + : ({ + id: getDistanceRelationId(sourcePointId, targetPointId), + edgeId: getMeasurementEdgeId(sourcePointId, targetPointId), + pointAId: sourcePointId, + pointBId: targetPointId, + anchorPointId: sourcePointId, + showDirectLine: distanceCreationLineVisibility.direct, + showVerticalLine: distanceCreationLineVisibility.vertical, + showHorizontalLine: distanceCreationLineVisibility.horizontal, + showComponentLines: + distanceCreationLineVisibility.vertical || + distanceCreationLineVisibility.horizontal, + labelVisibilityByKind: defaultDistanceRelationLabelVisibility, + } satisfies PointDistanceRelation); + + const nextRelation: PointDistanceRelation = { + ...relation, + edgeId: getMeasurementEdgeId(sourcePointId, targetPointId), + anchorPointId: sourcePointId, + showDirectLine: + relation.showDirectLine ?? distanceCreationLineVisibility.direct, + showVerticalLine: + relation.showVerticalLine ?? + relation.showComponentLines ?? + distanceCreationLineVisibility.vertical, + showHorizontalLine: + relation.showHorizontalLine ?? + relation.showComponentLines ?? + distanceCreationLineVisibility.horizontal, + showComponentLines: + relation.showComponentLines ?? + relation.showVerticalLine ?? + relation.showHorizontalLine ?? + (distanceCreationLineVisibility.vertical || + distanceCreationLineVisibility.horizontal), + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + }, + directLabelMode: + relation.directLabelMode ?? defaultDirectLineLabelMode, + }; + + if (relationIndex < 0) { + return [...previousRelations, nextRelation]; + } + + return previousRelations.map((entry, index) => + index === relationIndex ? nextRelation : entry + ); + }); + }, + [ + defaultDirectLineLabelMode, + defaultDistanceRelationLabelVisibility, + distanceCreationLineVisibility, + setDistanceRelations, + ] + ); + + const handleDistancePointCreated = useCallback( + (newPointId: string, newPointPositionECEF: Cartesian3) => { + const sourcePointId = resolveDistanceRelationSourcePointId(newPointId); + const directRelationId = sourcePointId + ? getDistanceRelationId(sourcePointId, newPointId) + : null; + const relationAlreadyExists = directRelationId + ? distanceRelations.some((relation) => relation.id === directRelationId) + : false; + + trackMeasurementDraftPointIds([newPointId]); + if (sourcePointId) { + upsertDirectDistanceRelation(sourcePointId, newPointId); + if (!relationAlreadyExists) { + trackMeasurementDraftRelationId(directRelationId); + } + } + + setActiveNodeChainAnnotationId(null); + if (distanceModeStickyToFirstPoint) { + if (!referencePointMeasurementId) { + setReferencePoint(newPointPositionECEF); + } + setDoubleClickChainSourcePointId( + referencePointMeasurementId ?? newPointId + ); + } else if (sourcePointId) { + finishDistanceMeasurementSession(newPointId, true); + } else { + setDoubleClickChainSourcePointId(newPointId); + } + + if (!sourcePointId || distanceModeStickyToFirstPoint) { + selectAnnotationByIdImmediate(newPointId); + } + }, + [ + distanceModeStickyToFirstPoint, + distanceRelations, + finishDistanceMeasurementSession, + referencePointMeasurementId, + resolveDistanceRelationSourcePointId, + selectAnnotationByIdImmediate, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + setReferencePoint, + trackMeasurementDraftPointIds, + trackMeasurementDraftRelationId, + upsertDirectDistanceRelation, + ] + ); + + return { + finishDistanceMeasurementSession, + handleDistancePointCreated, + resolveDistanceRelationSourcePointId, + upsertDirectDistanceRelation, + }; +}; + +export type DistanceMeasureAuthoringState = ReturnType< + typeof useDistanceMeasureAuthoring +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useLabelPlacementDraftActions.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useLabelPlacementDraftActions.ts new file mode 100644 index 0000000000..06fac6b7cb --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useLabelPlacementDraftActions.ts @@ -0,0 +1,38 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +type UseLabelPlacementDraftActionsParams = { + labelInputPromptPointId: string | null; + setLabelInputPromptPointId: Dispatch>; + clearAnnotationsByIds: (ids: string[]) => void; +}; + +export const useLabelPlacementDraftActions = ({ + labelInputPromptPointId, + setLabelInputPromptPointId, + clearAnnotationsByIds, +}: UseLabelPlacementDraftActionsParams) => { + const requestFinishLabelPlacementDraft = useCallback(() => { + if (!labelInputPromptPointId) { + return; + } + + setLabelInputPromptPointId((previousPromptPointId) => + previousPromptPointId === labelInputPromptPointId + ? null + : previousPromptPointId + ); + }, [labelInputPromptPointId, setLabelInputPromptPointId]); + + const requestCancelLabelPlacementDraft = useCallback(() => { + if (!labelInputPromptPointId) { + return; + } + + clearAnnotationsByIds([labelInputPromptPointId]); + }, [clearAnnotationsByIds, labelInputPromptPointId]); + + return { + requestFinishLabelPlacementDraft, + requestCancelLabelPlacementDraft, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useNodeChainFinishing.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useNodeChainFinishing.ts new file mode 100644 index 0000000000..5567b7a56b --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/useNodeChainFinishing.ts @@ -0,0 +1,277 @@ +import { useCallback } from "react"; + +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POLYLINE, + buildEdgeRelationIdsForPolygon, + computePolygonGroupDerivedData, + getDistanceRelationId, + getPointPositionMap, + isAreaToolType, + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationEntry, + type AnnotationToolType, + type NodeChainAnnotation, + type PolygonAreaType, +} from "@carma-mapping/annotations/core"; + +type UseNodeChainFinishingParams = { + sceneCameraPosition: Cartesian3 | null; + activeToolType: AnnotationToolType; + activeNodeChainAnnotationId: string | null; + pendingPolylineRingPromotionPointId: string | null; + annotations: AnnotationCollection; + nodeChainAnnotations: readonly NodeChainAnnotation[]; + setAnnotations: React.Dispatch>; + setNodeChainAnnotations: React.Dispatch< + React.SetStateAction + >; + setPendingPolylineRingPromotionPointId: (id: string | null) => void; + clearAnnotationCursor: () => void; + clearActiveNodeChainDrawingState: () => void; + clearMoveGizmo: () => void; + selectRepresentativeNodeForMeasurementId: (id: string | null) => void; +}; + +export const useNodeChainFinishing = ({ + sceneCameraPosition, + activeToolType, + activeNodeChainAnnotationId, + pendingPolylineRingPromotionPointId, + annotations, + nodeChainAnnotations, + setAnnotations, + setNodeChainAnnotations, + setPendingPolylineRingPromotionPointId, + clearAnnotationCursor, + clearActiveNodeChainDrawingState, + clearMoveGizmo, + selectRepresentativeNodeForMeasurementId, +}: UseNodeChainFinishingParams) => { + const computePolygonGroupDerivedDataWithCamera = useCallback( + (group: NodeChainAnnotation, pointById: Map) => + computePolygonGroupDerivedData(group, pointById, { + preferredFacingPositionECEF: sceneCameraPosition, + }), + [sceneCameraPosition] + ); + + const closeActivePolygonAnnotation = useCallback( + (typeOverride?: PolygonAreaType) => { + let closedGroupId: string | null = null; + clearAnnotationCursor(); + + setNodeChainAnnotations((previousMeasurements) => { + if (!activeNodeChainAnnotationId) { + return previousMeasurements; + } + + const activeMeasurement = previousMeasurements.find( + (measurement) => measurement.id === activeNodeChainAnnotationId + ); + if ( + !activeMeasurement || + activeMeasurement.closed || + activeMeasurement.nodeIds.length < 3 + ) { + return previousMeasurements; + } + + const nextClosedType = typeOverride ?? activeMeasurement.type; + const pointById = getPointPositionMap(annotations); + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + activeMeasurement.nodeIds, + true, + getDistanceRelationId + ); + const closedMeasurement = computePolygonGroupDerivedDataWithCamera( + { + ...activeMeasurement, + type: nextClosedType, + closed: true, + edgeRelationIds: nextEdgeRelationIds, + }, + pointById + ); + closedGroupId = closedMeasurement.id; + + return previousMeasurements.map((measurement) => + measurement.id === activeMeasurement.id + ? closedMeasurement + : measurement + ); + }); + + if (closedGroupId) { + selectRepresentativeNodeForMeasurementId(closedGroupId); + } else { + clearActiveNodeChainDrawingState(); + } + }, + [ + activeNodeChainAnnotationId, + annotations, + clearActiveNodeChainDrawingState, + clearAnnotationCursor, + computePolygonGroupDerivedDataWithCamera, + selectRepresentativeNodeForMeasurementId, + setNodeChainAnnotations, + ] + ); + + const closeActivePolylineAnnotationAsRing = useCallback( + (ringClosurePointId: string) => { + if (!activeNodeChainAnnotationId) { + return; + } + + const finishedGroupId = activeNodeChainAnnotationId; + clearAnnotationCursor(); + + setNodeChainAnnotations((previousMeasurements) => { + const pointById = getPointPositionMap(annotations); + return previousMeasurements.map((measurement) => { + if ( + measurement.id !== activeNodeChainAnnotationId || + measurement.closed + ) { + return measurement; + } + if (measurement.nodeIds.length < 3) { + return measurement; + } + + const lastPointId = + measurement.nodeIds[measurement.nodeIds.length - 1] ?? null; + const nextNodeIds = + lastPointId === ringClosurePointId + ? [...measurement.nodeIds] + : [...measurement.nodeIds, ringClosurePointId]; + const nextEdgeRelationIds = buildEdgeRelationIdsForPolygon( + nextNodeIds, + false, + getDistanceRelationId + ); + + return computePolygonGroupDerivedDataWithCamera( + { + ...measurement, + closed: false, + edgeRelationIds: nextEdgeRelationIds, + nodeIds: nextNodeIds, + }, + pointById + ); + }); + }); + + selectRepresentativeNodeForMeasurementId(finishedGroupId); + }, + [ + activeNodeChainAnnotationId, + annotations, + clearAnnotationCursor, + computePolygonGroupDerivedDataWithCamera, + selectRepresentativeNodeForMeasurementId, + setNodeChainAnnotations, + ] + ); + + const confirmPolylineRingPromotion = useCallback( + (type: PolygonAreaType) => { + if (!pendingPolylineRingPromotionPointId) { + return; + } + + setPendingPolylineRingPromotionPointId(null); + closeActivePolygonAnnotation(type); + }, + [ + closeActivePolygonAnnotation, + pendingPolylineRingPromotionPointId, + setPendingPolylineRingPromotionPointId, + ] + ); + + const cancelPolylineRingPromotion = useCallback(() => { + if (!pendingPolylineRingPromotionPointId) { + return; + } + + const ringClosurePointId = pendingPolylineRingPromotionPointId; + setPendingPolylineRingPromotionPointId(null); + closeActivePolylineAnnotationAsRing(ringClosurePointId); + }, [ + closeActivePolylineAnnotationAsRing, + pendingPolylineRingPromotionPointId, + setPendingPolylineRingPromotionPointId, + ]); + + const finishActivePolylineAnnotation = useCallback(() => { + if (!activeNodeChainAnnotationId) { + return; + } + + const finishedGroupId = activeNodeChainAnnotationId; + clearAnnotationCursor(); + selectRepresentativeNodeForMeasurementId(finishedGroupId); + }, [ + activeNodeChainAnnotationId, + clearAnnotationCursor, + selectRepresentativeNodeForMeasurementId, + ]); + + const handlePointQueryDoubleClick = useCallback(() => { + if ( + (activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType)) && + activeNodeChainAnnotationId + ) { + const activeOpenMeasurement = + nodeChainAnnotations.find( + (measurement) => + measurement.id === activeNodeChainAnnotationId && + !measurement.closed + ) ?? null; + const firstNodeId = activeOpenMeasurement?.nodeIds[0] ?? null; + const canCloseRing = Boolean( + firstNodeId && + activeOpenMeasurement && + activeOpenMeasurement.nodeIds.length >= 3 + ); + + if (canCloseRing && firstNodeId) { + if (activeToolType !== ANNOTATION_TYPE_POLYLINE) { + closeActivePolygonAnnotation(); + } else { + finishActivePolylineAnnotation(); + } + return; + } + } + + finishActivePolylineAnnotation(); + }, [ + activeNodeChainAnnotationId, + activeToolType, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + nodeChainAnnotations, + ]); + + return { + cancelPolylineRingPromotion, + closeActivePolygonAnnotation, + confirmPolylineRingPromotion, + finishActivePolylineAnnotation, + handlePointQueryDoubleClick, + }; +}; + +export type NodeChainFinishingState = ReturnType; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/usePointQueryToolRouting.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/usePointQueryToolRouting.ts new file mode 100644 index 0000000000..fb5781567f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/mode-lifecycle/usePointQueryToolRouting.ts @@ -0,0 +1,54 @@ +import { useCallback } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import type { AnnotationToolType } from "@carma-mapping/annotations/core"; + +import type { AnnotationModeSessionMap } from "./annotationModeSession.types"; + +type UsePointQueryToolRoutingParams = { + activeToolType: AnnotationToolType; + toolSessions: AnnotationModeSessionMap; + handlePointAnnotationCreated: (newPointId: string) => void; + setLabelInputPromptPointId: React.Dispatch< + React.SetStateAction + >; +}; + +export const usePointQueryToolRouting = ({ + activeToolType, + toolSessions, + handlePointAnnotationCreated, + setLabelInputPromptPointId, +}: UsePointQueryToolRoutingParams) => { + const activeToolSession = toolSessions[activeToolType] ?? null; + + const handlePointQueryPointCreated = useCallback( + (newPointId: string, newPointPositionECEF: Cartesian3) => { + const nodeCreatedHandler = activeToolSession?.onNodeCreated; + if (nodeCreatedHandler) { + nodeCreatedHandler(newPointId, newPointPositionECEF); + return; + } + handlePointAnnotationCreated(newPointId); + }, + [activeToolSession, handlePointAnnotationCreated] + ); + + const confirmLabelPlacementById = useCallback( + (id: string) => { + if (!id) { + return; + } + + setLabelInputPromptPointId((previousPromptPointId) => + previousPromptPointId === id ? null : previousPromptPointId + ); + }, + [setLabelInputPromptPointId] + ); + + return { + handlePointQueryPointCreated, + confirmLabelPlacementById, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/navigation/useAnnotationFlyToActions.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/navigation/useAnnotationFlyToActions.ts new file mode 100644 index 0000000000..2ae2a4032d --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/navigation/useAnnotationFlyToActions.ts @@ -0,0 +1,47 @@ +import { useCallback } from "react"; + +import { type Scene } from "@carma/cesium"; +import { + getAnnotationFlyToPointsById, + getMeasurementEntryFlyToPoints, + type AnnotationCollection, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; +import { flyToMeasurementPoints } from "@carma-mapping/annotations/cesium"; + +type UseAnnotationFlyToActionsParams = { + scene: Scene; + annotations: AnnotationCollection; + nodeChainAnnotations: NodeChainAnnotation[]; +}; + +export const useAnnotationFlyToActions = ({ + scene, + annotations, + nodeChainAnnotations, +}: UseAnnotationFlyToActionsParams) => { + const flyToAnnotationById = useCallback( + (id: string) => { + if (!id) return; + const flyToPoints = getAnnotationFlyToPointsById( + id, + annotations, + nodeChainAnnotations + ); + if (flyToPoints.length === 0) return; + flyToMeasurementPoints(scene, flyToPoints); + }, + [annotations, nodeChainAnnotations, scene] + ); + + const flyToAllAnnotations = useCallback(() => { + if (annotations.length === 0) return; + const points = annotations.flatMap(getMeasurementEntryFlyToPoints); + flyToMeasurementPoints(scene, points); + }, [annotations, scene]); + + return { + flyToAnnotationById, + flyToAllAnnotations, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/pointCreateConfig.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/pointCreateConfig.ts new file mode 100644 index 0000000000..51b15055c7 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/pointCreateConfig.ts @@ -0,0 +1,126 @@ +import { + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + isAreaToolType, + type AnnotationLabelAnchor, + type AnnotationLabelAppearance, + SELECT_TOOL_TYPE, + type AnnotationToolType, + type PointLabelMetricMode, +} from "@carma-mapping/annotations/core"; + +export const PURE_LABEL_DEFAULT_FONT_SIZE_PX = 12; +export const PURE_LABEL_DEFAULT_BACKGROUND_COLOR = "rgba(200, 200, 200, 0.7)"; +export const PURE_LABEL_DEFAULT_TEXT_COLOR = "#000000"; + +export type ActivePointCreateConfig = { + temporaryMode: boolean; + verticalOffsetMeters: number; + nameOnCreate: string | undefined; + labelOnCreate: PointLabelMetricMode | undefined; + hiddenOnCreate: boolean; + auxiliaryOnCreate: boolean; + labelAnchorOnCreate?: (pointId: string) => AnnotationLabelAnchor; + labelAppearanceOnCreate?: AnnotationLabelAppearance; + useTemporaryForCreatedPoints: boolean; +}; + +type BuildActivePointCreateConfigParams = { + activeToolType: AnnotationToolType; + temporaryMode: boolean; + pointVerticalOffsetMeters: number; + lastCustomPointAnnotationName?: string; + isPolylineCandidateMode: boolean; + polylineVerticalOffsetMeters: number; +}; + +export const buildActivePointCreateConfig = ({ + activeToolType, + temporaryMode, + pointVerticalOffsetMeters, + lastCustomPointAnnotationName, + isPolylineCandidateMode, + polylineVerticalOffsetMeters, +}: BuildActivePointCreateConfigParams): ActivePointCreateConfig | null => { + if (activeToolType === ANNOTATION_TYPE_POINT) { + return { + temporaryMode, + verticalOffsetMeters: pointVerticalOffsetMeters, + nameOnCreate: undefined, + labelOnCreate: "elevation" as const, + hiddenOnCreate: false, + auxiliaryOnCreate: false, + labelAnchorOnCreate: (pointId: string): AnnotationLabelAnchor => ({ + anchorPointId: pointId, + collapseToCompact: false, + }), + labelAppearanceOnCreate: undefined, + useTemporaryForCreatedPoints: true, + }; + } + + if (activeToolType === ANNOTATION_TYPE_LABEL) { + return { + temporaryMode: false, + verticalOffsetMeters: 0, + nameOnCreate: lastCustomPointAnnotationName, + labelOnCreate: "none" as const, + hiddenOnCreate: true, + auxiliaryOnCreate: true, + labelAnchorOnCreate: (pointId: string): AnnotationLabelAnchor => ({ + anchorPointId: pointId, + collapseToCompact: false, + }), + labelAppearanceOnCreate: { + fontSizePx: PURE_LABEL_DEFAULT_FONT_SIZE_PX, + backgroundColor: PURE_LABEL_DEFAULT_BACKGROUND_COLOR, + textColor: PURE_LABEL_DEFAULT_TEXT_COLOR, + }, + useTemporaryForCreatedPoints: false, + }; + } + + if (activeToolType === ANNOTATION_TYPE_DISTANCE) { + return { + temporaryMode: false, + verticalOffsetMeters: 0, + nameOnCreate: undefined, + labelOnCreate: undefined, + hiddenOnCreate: false, + auxiliaryOnCreate: false, + labelAnchorOnCreate: undefined, + labelAppearanceOnCreate: undefined, + useTemporaryForCreatedPoints: true, + }; + } + + if ( + activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType) + ) { + return { + temporaryMode: false, + verticalOffsetMeters: isPolylineCandidateMode + ? polylineVerticalOffsetMeters + : 0, + nameOnCreate: undefined, + labelOnCreate: undefined, + hiddenOnCreate: false, + auxiliaryOnCreate: false, + labelAnchorOnCreate: (pointId: string): AnnotationLabelAnchor => ({ + anchorPointId: pointId, + collapseToCompact: false, + }), + labelAppearanceOnCreate: undefined, + useTemporaryForCreatedPoints: true, + }; + } + + if (activeToolType === SELECT_TOOL_TYPE) { + return null; + } + + return null; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/settings/useAnnotationSettingsState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/settings/useAnnotationSettingsState.ts new file mode 100644 index 0000000000..e5ee2b9edc --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/settings/useAnnotationSettingsState.ts @@ -0,0 +1,303 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; + +import { useStoreSelector } from "@carma-commons/react-store"; +import type { + DirectLineLabelMode, + LinearSegmentLineMode, + ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationsStore } from "../../store"; + +const resolveSetStateAction = ( + action: SetStateAction, + previousValue: TValue +): TValue => + typeof action === "function" + ? (action as (previousValue: TValue) => TValue)(previousValue) + : action; + +export const useAnnotationSettingsState = ( + annotationsStore: AnnotationsStore +) => { + const settingsState = useStoreSelector( + annotationsStore, + (state) => state.settingsState + ); + const pointQuerySettings = settingsState.pointQuery; + const pointSettings = settingsState.point; + const distanceSettings = settingsState.distance; + const polylineSettings = settingsState.polyline; + + const setSettingsState = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + annotationsStore.setState((previousStoreState) => { + const nextSettingsState = resolveSetStateAction( + nextValueOrUpdater, + previousStoreState.settingsState + ); + + return Object.is(nextSettingsState, previousStoreState.settingsState) + ? previousStoreState + : { + ...previousStoreState, + settingsState: nextSettingsState, + }; + }); + }, + [annotationsStore, settingsState] + ); + + const setPointSettings = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setSettingsState((previousState) => { + const nextPointSettings = resolveSetStateAction( + nextValueOrUpdater, + previousState.point + ); + + return Object.is(nextPointSettings, previousState.point) + ? previousState + : { + ...previousState, + point: nextPointSettings, + }; + }); + }, + [setSettingsState] + ); + + const setDistanceSettings = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setSettingsState((previousState) => { + const nextDistanceSettings = resolveSetStateAction( + nextValueOrUpdater, + previousState.distance + ); + + return Object.is(nextDistanceSettings, previousState.distance) + ? previousState + : { + ...previousState, + distance: nextDistanceSettings, + }; + }); + }, + [setSettingsState] + ); + + const setPolylineSettings = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setSettingsState((previousState) => { + const nextPolylineSettings = resolveSetStateAction( + nextValueOrUpdater, + previousState.polyline + ); + + return Object.is(nextPolylineSettings, previousState.polyline) + ? previousState + : { + ...previousState, + polyline: nextPolylineSettings, + }; + }); + }, + [setSettingsState] + ); + + const pointRadius = pointQuerySettings.radius; + const pointVerticalOffsetMeters = pointSettings.verticalOffsetMeters; + const pointTemporaryMode = pointSettings.temporaryMode; + const defaultPolylineVerticalOffsetMeters = + polylineSettings.defaultVerticalOffsetMeters; + const defaultPolylineSegmentLineMode = + polylineSettings.defaultSegmentLineMode; + const distanceModeStickyToFirstPoint = distanceSettings.stickyToFirstPoint; + const distanceCreationLineVisibility = + distanceSettings.creationLineVisibility; + const distanceDefaultLabelVisibilityByKind = + distanceSettings.defaultLabelVisibilityByKind; + const distanceDefaultDirectLineLabelMode = + distanceSettings.defaultDirectLineLabelMode; + const heightOffset = pointQuerySettings.heightOffset; + + const setPointVerticalOffsetMeters = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setPointSettings((previousState) => { + const nextVerticalOffsetMeters = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.verticalOffsetMeters) + : nextValueOrUpdater; + + return nextVerticalOffsetMeters === previousState.verticalOffsetMeters + ? previousState + : { + ...previousState, + verticalOffsetMeters: nextVerticalOffsetMeters, + }; + }); + }, + [setPointSettings] + ); + + const setPointTemporaryMode = useCallback>>( + (nextValueOrUpdater) => { + setPointSettings((previousState) => { + const nextTemporaryMode = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.temporaryMode) + : nextValueOrUpdater; + + return nextTemporaryMode === previousState.temporaryMode + ? previousState + : { + ...previousState, + temporaryMode: nextTemporaryMode, + }; + }); + }, + [setPointSettings] + ); + + const setDefaultPolylineVerticalOffsetMeters = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setPolylineSettings((previousState) => { + const nextDefaultVerticalOffsetMeters = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.defaultVerticalOffsetMeters) + : nextValueOrUpdater; + + return nextDefaultVerticalOffsetMeters === + previousState.defaultVerticalOffsetMeters + ? previousState + : { + ...previousState, + defaultVerticalOffsetMeters: nextDefaultVerticalOffsetMeters, + }; + }); + }, + [setPolylineSettings] + ); + + const setDefaultPolylineSegmentLineMode = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setPolylineSettings((previousState) => { + const nextDefaultSegmentLineMode = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.defaultSegmentLineMode) + : nextValueOrUpdater; + + return nextDefaultSegmentLineMode === + previousState.defaultSegmentLineMode + ? previousState + : { + ...previousState, + defaultSegmentLineMode: nextDefaultSegmentLineMode, + }; + }); + }, + [setPolylineSettings] + ); + + const setDistanceModeStickyToFirstPoint = useCallback< + Dispatch> + >( + (nextValueOrUpdater) => { + setDistanceSettings((previousState) => { + const nextStickyToFirstPoint = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.stickyToFirstPoint) + : nextValueOrUpdater; + + return nextStickyToFirstPoint === previousState.stickyToFirstPoint + ? previousState + : { + ...previousState, + stickyToFirstPoint: nextStickyToFirstPoint, + }; + }); + }, + [setDistanceSettings] + ); + + const setDistanceCreationLineVisibility = useCallback< + Dispatch< + SetStateAction<{ + direct: boolean; + vertical: boolean; + horizontal: boolean; + }> + > + >( + (nextValueOrUpdater) => { + setDistanceSettings((previousState) => { + const nextCreationLineVisibility = + typeof nextValueOrUpdater === "function" + ? nextValueOrUpdater(previousState.creationLineVisibility) + : nextValueOrUpdater; + + return Object.is( + nextCreationLineVisibility, + previousState.creationLineVisibility + ) + ? previousState + : { + ...previousState, + creationLineVisibility: nextCreationLineVisibility, + }; + }); + }, + [setDistanceSettings] + ); + + const setDistanceCreationLineVisibilityByKind = useCallback( + (kind: "direct" | "vertical" | "horizontal", visible: boolean) => { + setDistanceCreationLineVisibility((prev) => + prev[kind] === visible + ? prev + : { + ...prev, + [kind]: visible, + } + ); + }, + [setDistanceCreationLineVisibility] + ); + + return { + pointRadius, + pointVerticalOffsetMeters, + pointTemporaryMode, + defaultPolylineVerticalOffsetMeters, + defaultPolylineSegmentLineMode, + distanceModeStickyToFirstPoint, + distanceCreationLineVisibility, + distanceDefaultLabelVisibilityByKind, + distanceDefaultDirectLineLabelMode, + heightOffset, + setPointVerticalOffsetMeters, + setPointTemporaryMode, + setDefaultPolylineVerticalOffsetMeters, + setDefaultPolylineSegmentLineMode, + setDistanceModeStickyToFirstPoint, + setDistanceCreationLineVisibilityByKind, + }; +}; + +export type AnnotationSettingsState = ReturnType< + typeof useAnnotationSettingsState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useActiveDrawModeState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useActiveDrawModeState.ts new file mode 100644 index 0000000000..61b4c9ee44 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useActiveDrawModeState.ts @@ -0,0 +1,23 @@ +import { useMemo } from "react"; + +import type { NodeChainAnnotation } from "@carma-mapping/annotations/core"; + +export const useActiveDrawModeState = ( + doubleClickChainSourcePointId: string | null, + selectablePointIds: ReadonlySet, + activeNodeChainAnnotationId: string | null, + nodeChainAnnotations: NodeChainAnnotation[] +) => + useMemo(() => { + if (!doubleClickChainSourcePointId) return false; + if (!selectablePointIds.has(doubleClickChainSourcePointId)) return false; + if (!activeNodeChainAnnotationId) return false; + return nodeChainAnnotations.some( + (group) => group.id === activeNodeChainAnnotationId && !group.closed + ); + }, [ + activeNodeChainAnnotationId, + doubleClickChainSourcePointId, + nodeChainAnnotations, + selectablePointIds, + ]); diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCandidateState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCandidateState.ts new file mode 100644 index 0000000000..d54d9f6c9e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCandidateState.ts @@ -0,0 +1,141 @@ +import { type Dispatch, type SetStateAction } from "react"; + +import { Cartesian2, Cartesian3, type Scene } from "@carma/cesium"; + +import { + ANNOTATION_CANDIDATE_KIND_DISTANCE, + ANNOTATION_CANDIDATE_KIND_NONE, + ANNOTATION_CANDIDATE_KIND_POINT, + ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND, + ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR, + ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + ANNOTATION_CANDIDATE_KIND_POLYLINE, + resolveCandidateCapabilities, +} from "@carma-mapping/annotations/core"; +import type { + AnnotationCollection, + AnnotationCandidateDescriptor, + NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +import { useAnnotationCursorState } from "./useAnnotationCursorState"; +import { useVerticalPolygonCandidate } from "./candidate/useVerticalPolygonCandidate"; + +export { + ANNOTATION_CANDIDATE_KIND_DISTANCE, + ANNOTATION_CANDIDATE_KIND_NONE, + ANNOTATION_CANDIDATE_KIND_POINT, + ANNOTATION_CANDIDATE_KIND_POLYGON_GROUND, + ANNOTATION_CANDIDATE_KIND_POLYGON_PLANAR, + ANNOTATION_CANDIDATE_KIND_POLYGON_VERTICAL, + ANNOTATION_CANDIDATE_KIND_POLYLINE, + type AnnotationCandidateDescriptor, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationCandidateStateParams = { + pointQueryEnabled: boolean; + moveGizmoPointId: string | null; + isMoveGizmoDragging: boolean; + setNodeChainAnnotations: Dispatch>; + getPositionWithVerticalOffsetFromAnchor: ( + positionECEF: Cartesian3, + verticalOffsetMeters: number + ) => Cartesian3; +}; + +type UseAnnotationCandidateStateResult = { + activeCandidateNodeECEF: Cartesian3 | null; + cursorScreenPosition: { x: number; y: number } | null; + activeCandidateNodeSurfaceNormalECEF: Cartesian3 | null; + activeCandidateNodeVerticalOffsetAnchorECEF: Cartesian3 | null; + handleAnnotationCursorMove: ( + positionECEF: Cartesian3 | null, + screenPosition?: Cartesian2, + surfaceNormalECEF?: Cartesian3 | null + ) => void; + clearAnnotationCursor: () => void; + syncAnnotationCursorToExistingPoint: ( + pointId: string, + anchorPosition?: { x: number; y: number } | null + ) => boolean; + releaseAnnotationCursorSnap: () => void; + scheduleAnnotationCursorSnapRelease: (pointId: string) => void; + isPolylineCandidateMode: boolean; + hasCandidateNode: boolean; + candidateSupportsEdgeLine: boolean; + candidateUsesPolylineEdgeRules: boolean; + candidateForcesDirectEdgeLine: boolean; + annotationCursorEnabled: boolean; +}; + +const SNAPPED_NODE_CURSOR_RELEASE_DELAY_MS = 80; + +export const useAnnotationCandidateState = ( + scene: Scene | null, + annotations: AnnotationCollection, + candidate: AnnotationCandidateDescriptor, + { + pointQueryEnabled, + moveGizmoPointId, + isMoveGizmoDragging, + setNodeChainAnnotations, + getPositionWithVerticalOffsetFromAnchor, + }: UseAnnotationCandidateStateParams +): UseAnnotationCandidateStateResult => { + const capabilities = resolveCandidateCapabilities(candidate.kind); + const { + isPolylineCandidateMode, + hasCandidateNode, + candidateSupportsEdgeLine, + candidateUsesPolylineEdgeRules, + candidateForcesDirectEdgeLine, + } = capabilities; + const annotationCursorEnabled = + hasCandidateNode && + pointQueryEnabled && + !moveGizmoPointId && + !isMoveGizmoDragging; + + const updateVerticalPolygonCandidate = useVerticalPolygonCandidate( + scene, + annotations, + candidate, + setNodeChainAnnotations + ); + + const { + candidateNodePositionECEF: activeCandidateNodeECEF, + cursorScreenPosition, + candidateNodeSurfaceNormalECEF: activeCandidateNodeSurfaceNormalECEF, + candidateNodeVerticalOffsetAnchorECEF: + activeCandidateNodeVerticalOffsetAnchorECEF, + clearMeasurementCursor, + handleAnnotationCursorMove, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + syncAnnotationCursorToExistingPoint, + } = useAnnotationCursorState(scene, annotations, candidate, { + enabled: annotationCursorEnabled, + snappedPointReleaseDelayMs: SNAPPED_NODE_CURSOR_RELEASE_DELAY_MS, + getPositionWithVerticalOffsetFromAnchor, + onCandidateNodePositionChange: updateVerticalPolygonCandidate, + }); + + return { + activeCandidateNodeECEF, + cursorScreenPosition, + activeCandidateNodeSurfaceNormalECEF, + activeCandidateNodeVerticalOffsetAnchorECEF, + clearAnnotationCursor: clearMeasurementCursor, + handleAnnotationCursorMove, + releaseAnnotationCursorSnap, + syncAnnotationCursorToExistingPoint, + scheduleAnnotationCursorSnapRelease, + isPolylineCandidateMode, + hasCandidateNode, + candidateSupportsEdgeLine, + candidateUsesPolylineEdgeRules, + candidateForcesDirectEdgeLine, + annotationCursorEnabled, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorOverlay.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorOverlay.ts new file mode 100644 index 0000000000..4b7432bd8e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorOverlay.ts @@ -0,0 +1,148 @@ +import { createElement, useEffect, useMemo } from "react"; +import { useLabelOverlay } from "@carma-providers/label-overlay"; +import type { CssPixelPosition } from "@carma/units/types"; + +const ANNOTATION_CURSOR_OVERLAY_ID = "annotation-candidate-crosshair"; +const CURSOR_STROKE_COLOR = "rgba(255, 255, 255, 0.96)"; +const CURSOR_CONTRAST_FILTER = + "drop-shadow(0 0 1px rgba(0, 0, 0, 1)) drop-shadow(0 0 2px rgba(0, 0, 0, 0.95))"; +const CURSOR_THICKNESS_PX = 3; +const CURSOR_CENTER_DOT_SIZE_PX = 1; +const CURSOR_CENTER_GAP_PX = 5; +const CURSOR_FAR_DASH_LENGTH_PX = 12; +const CURSOR_INNER_TIP_PX = CURSOR_THICKNESS_PX / 2; +const CURSOR_HALF_EXTENT_PX = CURSOR_CENTER_GAP_PX + CURSOR_FAR_DASH_LENGTH_PX; +const CURSOR_SIZE_PX = CURSOR_HALF_EXTENT_PX * 2 + CURSOR_CENTER_DOT_SIZE_PX; +const CURSOR_CENTER_PX = CURSOR_HALF_EXTENT_PX; + +type AnnotationCursorOverlayOptions = { + enabled?: boolean; +}; + +export const useAnnotationCursorOverlay = ( + cursorScreenPosition: { x: number; y: number } | null = null, + { enabled = true }: AnnotationCursorOverlayOptions = {} +) => { + const { addLabelOverlayElement, removeLabelOverlayElement } = + useLabelOverlay(); + + const cursorContent = useMemo(() => { + const strokeStyle = { + backgroundColor: CURSOR_STROKE_COLOR, + }; + + return createElement( + "div", + { + style: { + position: "relative", + width: `${CURSOR_SIZE_PX}px`, + height: `${CURSOR_SIZE_PX}px`, + pointerEvents: "none", + filter: CURSOR_CONTRAST_FILTER, + }, + }, + createElement("div", { + key: "center-dot", + style: { + position: "absolute", + left: `${CURSOR_CENTER_PX}px`, + top: `${CURSOR_CENTER_PX}px`, + width: `${CURSOR_CENTER_DOT_SIZE_PX}px`, + height: `${CURSOR_CENTER_DOT_SIZE_PX}px`, + transform: "translate(-50%, -50%)", + ...strokeStyle, + }, + }), + createElement("div", { + key: "h-right-dash", + style: { + position: "absolute", + left: `${CURSOR_CENTER_PX + CURSOR_CENTER_GAP_PX}px`, + top: `${CURSOR_CENTER_PX}px`, + width: `${CURSOR_FAR_DASH_LENGTH_PX}px`, + height: `${CURSOR_THICKNESS_PX}px`, + transform: "translateY(-50%)", + clipPath: `polygon(0 50%, ${CURSOR_INNER_TIP_PX}px 0, 100% 0, 100% 100%, ${CURSOR_INNER_TIP_PX}px 100%)`, + ...strokeStyle, + }, + }), + createElement("div", { + key: "h-left-dash", + style: { + position: "absolute", + left: `${ + CURSOR_CENTER_PX - CURSOR_CENTER_GAP_PX - CURSOR_FAR_DASH_LENGTH_PX + }px`, + top: `${CURSOR_CENTER_PX}px`, + width: `${CURSOR_FAR_DASH_LENGTH_PX}px`, + height: `${CURSOR_THICKNESS_PX}px`, + transform: "translateY(-50%)", + clipPath: `polygon(0 0, calc(100% - ${CURSOR_INNER_TIP_PX}px) 0, 100% 50%, calc(100% - ${CURSOR_INNER_TIP_PX}px) 100%, 0 100%)`, + ...strokeStyle, + }, + }), + createElement("div", { + key: "v-bottom-dash", + style: { + position: "absolute", + left: `${CURSOR_CENTER_PX}px`, + top: `${CURSOR_CENTER_PX + CURSOR_CENTER_GAP_PX}px`, + width: `${CURSOR_THICKNESS_PX}px`, + height: `${CURSOR_FAR_DASH_LENGTH_PX}px`, + transform: "translateX(-50%)", + clipPath: `polygon(0 ${CURSOR_INNER_TIP_PX}px, 50% 0, 100% ${CURSOR_INNER_TIP_PX}px, 100% 100%, 0 100%)`, + ...strokeStyle, + }, + }), + createElement("div", { + key: "v-top-dash", + style: { + position: "absolute", + left: `${CURSOR_CENTER_PX}px`, + top: `${ + CURSOR_CENTER_PX - CURSOR_CENTER_GAP_PX - CURSOR_FAR_DASH_LENGTH_PX + }px`, + width: `${CURSOR_THICKNESS_PX}px`, + height: `${CURSOR_FAR_DASH_LENGTH_PX}px`, + transform: "translateX(-50%)", + clipPath: `polygon(0 0, 100% 0, 100% calc(100% - ${CURSOR_INNER_TIP_PX}px), 50% 100%, 0 calc(100% - ${CURSOR_INNER_TIP_PX}px))`, + ...strokeStyle, + }, + }) + ); + }, []); + + useEffect(() => { + addLabelOverlayElement({ + id: ANNOTATION_CURSOR_OVERLAY_ID, + zIndex: 22, + getCanvasPosition: () => { + if ( + !enabled || + !cursorScreenPosition || + !Number.isFinite(cursorScreenPosition.x) || + !Number.isFinite(cursorScreenPosition.y) + ) { + return null; + } + return { + x: cursorScreenPosition.x, + y: cursorScreenPosition.y, + } as CssPixelPosition; + }, + content: cursorContent, + visible: enabled, + }); + + return () => { + removeLabelOverlayElement(ANNOTATION_CURSOR_OVERLAY_ID); + }; + }, [ + addLabelOverlayElement, + cursorContent, + cursorScreenPosition, + enabled, + removeLabelOverlayElement, + ]); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorState.ts new file mode 100644 index 0000000000..e780d0cb9f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationCursorState.ts @@ -0,0 +1,547 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { + Cartesian2, + Cartesian3, + SceneTransforms, + defined, + isValidScene, + getLocalUpDirectionAtAnchor, + type Scene, +} from "@carma/cesium"; + +import { + getPointById, + hasPointCandidateOffsetStem, + resolveCandidateCapabilities, + type AnnotationCandidateDescriptor, + type AnnotationCandidateKind, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; + +type AnnotationCursorSource = "none" | "raw" | "snapped-node"; + +type AnnotationCursorScreenPosition = { x: number; y: number }; + +type AnnotationCursorState = { + source: AnnotationCursorSource; + cursorScreenPosition: AnnotationCursorScreenPosition | null; + rawCursorScreenPosition: AnnotationCursorScreenPosition | null; + snappedCursorScreenPosition: AnnotationCursorScreenPosition | null; + candidateNodePositionECEF: Cartesian3 | null; + candidateNodeSurfaceNormalECEF: Cartesian3 | null; + candidateNodeVerticalOffsetAnchorECEF: Cartesian3 | null; + candidatePointId: string | null; +}; + +type RawMeasurementPointerSample = { + positionECEF: Cartesian3 | null; + screenPosition: Cartesian2 | null; + surfaceNormalECEF: Cartesian3 | null; +}; + +type UseAnnotationCursorStateParams = { + enabled: boolean; + snappedPointReleaseDelayMs: number; + getPositionWithVerticalOffsetFromAnchor: ( + positionECEF: Cartesian3, + verticalOffsetMeters: number + ) => Cartesian3; + onCandidateNodePositionChange?: (positionECEF: Cartesian3 | null) => void; +}; + +const cloneCartesian3OrNull = (value: Cartesian3 | null) => + value ? Cartesian3.clone(value) : null; + +const cloneCartesian2OrNull = (value: Cartesian2 | null) => + value ? Cartesian2.clone(value) : null; + +const cloneRawPointerSample = ( + sample: RawMeasurementPointerSample +): RawMeasurementPointerSample => ({ + positionECEF: cloneCartesian3OrNull(sample.positionECEF), + screenPosition: cloneCartesian2OrNull(sample.screenPosition), + surfaceNormalECEF: cloneCartesian3OrNull(sample.surfaceNormalECEF), +}); + +const toScreenPosition = ( + screenPosition?: Cartesian2 | AnnotationCursorScreenPosition | null +): AnnotationCursorScreenPosition | null => { + if (!screenPosition) return null; + if ( + !Number.isFinite(screenPosition.x) || + !Number.isFinite(screenPosition.y) + ) { + return null; + } + return { x: screenPosition.x, y: screenPosition.y }; +}; + +const projectScreenPositionFromScene = ( + scene: Scene | null, + positionECEF: Cartesian3 | null +): AnnotationCursorScreenPosition | null => { + if (!isValidScene(scene) || !positionECEF) { + return null; + } + + const screenPosition = SceneTransforms.worldToWindowCoordinates( + scene, + positionECEF + ); + if (!defined(screenPosition)) { + return null; + } + + return toScreenPosition(screenPosition); +}; + +const isSameCartesian3 = (left: Cartesian3 | null, right: Cartesian3 | null) => + left === right || + (!!left && + !!right && + left.x === right.x && + left.y === right.y && + left.z === right.z); + +const isSameScreenPosition = ( + left: AnnotationCursorScreenPosition | null, + right: AnnotationCursorScreenPosition | null +) => + left === right || + (!!left && !!right && left.x === right.x && left.y === right.y); + +const resolveCandidateWorldState = ({ + candidateKind, + verticalOffsetMeters, + getPositionWithVerticalOffsetFromAnchor, + anchorPositionECEF, + surfaceNormalECEF, +}: { + candidateKind: AnnotationCandidateKind; + verticalOffsetMeters: number; + getPositionWithVerticalOffsetFromAnchor: ( + positionECEF: Cartesian3, + verticalOffsetMeters: number + ) => Cartesian3; + anchorPositionECEF: Cartesian3 | null; + surfaceNormalECEF?: Cartesian3 | null; +}) => { + const hasOffsetStem = hasPointCandidateOffsetStem( + candidateKind, + verticalOffsetMeters + ); + const candidateNodeVerticalOffsetAnchorECEF = + hasOffsetStem && anchorPositionECEF + ? Cartesian3.clone(anchorPositionECEF) + : null; + const candidateNodePositionECEF = anchorPositionECEF + ? hasOffsetStem + ? getPositionWithVerticalOffsetFromAnchor( + anchorPositionECEF, + verticalOffsetMeters + ) + : Cartesian3.clone(anchorPositionECEF) + : null; + const candidateNodeSurfaceNormalECEF = surfaceNormalECEF + ? Cartesian3.normalize(surfaceNormalECEF, new Cartesian3()) + : null; + + return { + candidateNodePositionECEF, + candidateNodeSurfaceNormalECEF, + candidateNodeVerticalOffsetAnchorECEF, + }; +}; + +export const useAnnotationCursorState = ( + scene: Scene | null, + annotations: AnnotationCollection, + candidate: AnnotationCandidateDescriptor, + { + enabled, + snappedPointReleaseDelayMs, + getPositionWithVerticalOffsetFromAnchor, + onCandidateNodePositionChange, + }: UseAnnotationCursorStateParams +) => { + const { hasCandidateNode } = resolveCandidateCapabilities(candidate.kind); + const candidateKind = candidate.kind; + const verticalOffsetMeters = candidate.verticalOffsetMeters; + const [cursorState, setCursorState] = useState({ + source: "none", + cursorScreenPosition: null, + rawCursorScreenPosition: null, + snappedCursorScreenPosition: null, + candidateNodePositionECEF: null, + candidateNodeSurfaceNormalECEF: null, + candidateNodeVerticalOffsetAnchorECEF: null, + candidatePointId: null, + }); + + const snappedPointIdRef = useRef(null); + const snappedPointScreenPositionRef = + useRef(null); + const snappedPointReleaseTimeoutRef = useRef(null); + const candidateNodePositionChangeRef = useRef(onCandidateNodePositionChange); + const clearMeasurementCursorRef = useRef<(() => void) | null>(null); + const rawPointerSampleRef = useRef({ + positionECEF: null, + screenPosition: null, + surfaceNormalECEF: null, + }); + + useEffect(() => { + candidateNodePositionChangeRef.current = onCandidateNodePositionChange; + }, [onCandidateNodePositionChange]); + + useEffect( + function effectTrackRawPointerScreenPosition() { + if (!enabled || !hasCandidateNode) { + return; + } + + const handlePointerMove = (event: PointerEvent) => { + const nextRawCursorScreenPosition = toScreenPosition({ + x: event.clientX, + y: event.clientY, + }); + + rawPointerSampleRef.current = { + ...rawPointerSampleRef.current, + screenPosition: nextRawCursorScreenPosition + ? new Cartesian2( + nextRawCursorScreenPosition.x, + nextRawCursorScreenPosition.y + ) + : null, + }; + + setCursorState((previousState) => + isSameScreenPosition( + previousState.rawCursorScreenPosition, + nextRawCursorScreenPosition + ) && + isSameScreenPosition( + previousState.cursorScreenPosition, + nextRawCursorScreenPosition + ) + ? previousState + : { + ...previousState, + rawCursorScreenPosition: nextRawCursorScreenPosition, + cursorScreenPosition: nextRawCursorScreenPosition, + } + ); + }; + + window.addEventListener("pointermove", handlePointerMove, true); + + return () => { + window.removeEventListener("pointermove", handlePointerMove, true); + }; + }, + [enabled, hasCandidateNode] + ); + + const clearSnappedPointReleaseTimeout = useCallback(() => { + if (snappedPointReleaseTimeoutRef.current === null) return; + window.clearTimeout(snappedPointReleaseTimeoutRef.current); + snappedPointReleaseTimeoutRef.current = null; + }, []); + + const setCursorStateInternal = useCallback( + ({ + source, + anchorPositionECEF, + surfaceNormalECEF, + rawCursorScreenPosition, + snappedCursorScreenPosition, + snappedPointId, + }: { + source: AnnotationCursorSource; + anchorPositionECEF: Cartesian3 | null; + surfaceNormalECEF?: Cartesian3 | null; + rawCursorScreenPosition?: AnnotationCursorScreenPosition | null; + snappedCursorScreenPosition?: AnnotationCursorScreenPosition | null; + snappedPointId?: string | null; + }) => { + const { + candidateNodePositionECEF, + candidateNodeSurfaceNormalECEF, + candidateNodeVerticalOffsetAnchorECEF, + } = resolveCandidateWorldState({ + candidateKind, + verticalOffsetMeters, + getPositionWithVerticalOffsetFromAnchor, + anchorPositionECEF, + surfaceNormalECEF, + }); + const nextRawCursorScreenPosition = + rawCursorScreenPosition === undefined ? null : rawCursorScreenPosition; + const nextSnappedCursorScreenPosition = + snappedCursorScreenPosition === undefined + ? null + : snappedCursorScreenPosition; + // Keep the crosshair overlay anchored to the actual pointer sample. + // Snapping should only affect the candidate world position/rendered + // preview, not visually drag the mouse cursor to the snapped node. + const nextCursorScreenPosition = + nextRawCursorScreenPosition ?? + (source === "snapped-node" ? nextSnappedCursorScreenPosition : null); + const nextSnappedPointId = + source === "snapped-node" ? snappedPointId ?? null : null; + + let hasStateChanged = false; + setCursorState((previousState) => { + if ( + previousState.source === source && + previousState.candidatePointId === nextSnappedPointId && + isSameCartesian3( + previousState.candidateNodePositionECEF, + candidateNodePositionECEF + ) && + isSameCartesian3( + previousState.candidateNodeSurfaceNormalECEF, + candidateNodeSurfaceNormalECEF + ) && + isSameCartesian3( + previousState.candidateNodeVerticalOffsetAnchorECEF, + candidateNodeVerticalOffsetAnchorECEF + ) && + isSameScreenPosition( + previousState.rawCursorScreenPosition, + nextRawCursorScreenPosition + ) && + isSameScreenPosition( + previousState.snappedCursorScreenPosition, + nextSnappedCursorScreenPosition + ) && + isSameScreenPosition( + previousState.cursorScreenPosition, + nextCursorScreenPosition + ) + ) { + return previousState; + } + hasStateChanged = true; + + return { + source, + cursorScreenPosition: nextCursorScreenPosition, + rawCursorScreenPosition: nextRawCursorScreenPosition, + snappedCursorScreenPosition: nextSnappedCursorScreenPosition, + candidateNodePositionECEF, + candidateNodeSurfaceNormalECEF, + candidateNodeVerticalOffsetAnchorECEF, + candidatePointId: nextSnappedPointId, + }; + }); + + if (!hasStateChanged) { + return; + } + candidateNodePositionChangeRef.current?.(candidateNodePositionECEF); + scene?.requestRender(); + }, + [ + candidateKind, + getPositionWithVerticalOffsetFromAnchor, + scene, + verticalOffsetMeters, + ] + ); + + const clearMeasurementCursor = useCallback(() => { + clearSnappedPointReleaseTimeout(); + snappedPointIdRef.current = null; + snappedPointScreenPositionRef.current = null; + rawPointerSampleRef.current = { + positionECEF: null, + screenPosition: null, + surfaceNormalECEF: null, + }; + setCursorStateInternal({ + source: "none", + anchorPositionECEF: null, + rawCursorScreenPosition: null, + snappedCursorScreenPosition: null, + snappedPointId: null, + }); + }, [clearSnappedPointReleaseTimeout, setCursorStateInternal]); + + useEffect(() => { + clearMeasurementCursorRef.current = clearMeasurementCursor; + }, [clearMeasurementCursor]); + + const clearSnappedMeasurementCursor = useCallback(() => { + clearSnappedPointReleaseTimeout(); + snappedPointIdRef.current = null; + snappedPointScreenPositionRef.current = null; + }, [clearSnappedPointReleaseTimeout]); + + const applyLastRawPointerSample = useCallback(() => { + const rawSample = cloneRawPointerSample(rawPointerSampleRef.current); + setCursorStateInternal({ + source: rawSample.positionECEF ? "raw" : "none", + anchorPositionECEF: rawSample.positionECEF, + surfaceNormalECEF: rawSample.surfaceNormalECEF, + rawCursorScreenPosition: toScreenPosition(rawSample.screenPosition), + snappedCursorScreenPosition: null, + snappedPointId: null, + }); + }, [setCursorStateInternal]); + + const syncMeasurementCursorToExistingPoint = useCallback( + ( + pointId: string, + anchorPosition?: AnnotationCursorScreenPosition | null + ) => { + clearSnappedPointReleaseTimeout(); + const hoveredPoint = getPointById(annotations, pointId); + if (!hoveredPoint) { + return false; + } + + const snappedScreenPosition = + projectScreenPositionFromScene(scene, hoveredPoint.geometryECEF) ?? + toScreenPosition(anchorPosition) ?? + (snappedPointIdRef.current === pointId + ? snappedPointScreenPositionRef.current + : null); + snappedPointIdRef.current = pointId; + snappedPointScreenPositionRef.current = snappedScreenPosition; + setCursorStateInternal({ + source: "snapped-node", + anchorPositionECEF: hoveredPoint.geometryECEF, + surfaceNormalECEF: getLocalUpDirectionAtAnchor( + hoveredPoint.geometryECEF + ), + rawCursorScreenPosition: toScreenPosition( + rawPointerSampleRef.current.screenPosition + ), + snappedCursorScreenPosition: snappedScreenPosition, + snappedPointId: pointId, + }); + return true; + }, + [ + annotations, + clearSnappedPointReleaseTimeout, + scene, + setCursorStateInternal, + ] + ); + + const handleRawMeasurementPointerMove = useCallback( + ( + positionECEF: Cartesian3 | null, + screenPosition?: Cartesian2, + surfaceNormalECEF?: Cartesian3 | null + ) => { + rawPointerSampleRef.current = { + positionECEF: cloneCartesian3OrNull(positionECEF), + screenPosition: + screenPosition && + Number.isFinite(screenPosition.x) && + Number.isFinite(screenPosition.y) + ? Cartesian2.clone(screenPosition) + : null, + surfaceNormalECEF: cloneCartesian3OrNull(surfaceNormalECEF ?? null), + }; + + const snappedPointId = snappedPointIdRef.current; + const nextRawCursorScreenPosition = toScreenPosition(screenPosition); + if (!snappedPointId) { + setCursorStateInternal({ + source: positionECEF ? "raw" : "none", + anchorPositionECEF: positionECEF, + surfaceNormalECEF, + rawCursorScreenPosition: nextRawCursorScreenPosition, + snappedCursorScreenPosition: null, + snappedPointId: null, + }); + return; + } + + const snappedPoint = getPointById(annotations, snappedPointId); + if (!snappedPoint) { + clearSnappedMeasurementCursor(); + applyLastRawPointerSample(); + return; + } + + const nextSnappedCursorScreenPosition = + projectScreenPositionFromScene(scene, snappedPoint.geometryECEF) ?? + snappedPointScreenPositionRef.current ?? + nextRawCursorScreenPosition; + snappedPointScreenPositionRef.current = nextSnappedCursorScreenPosition; + setCursorStateInternal({ + source: "snapped-node", + anchorPositionECEF: snappedPoint.geometryECEF, + surfaceNormalECEF: getLocalUpDirectionAtAnchor( + snappedPoint.geometryECEF + ), + rawCursorScreenPosition: nextRawCursorScreenPosition, + snappedCursorScreenPosition: nextSnappedCursorScreenPosition, + snappedPointId, + }); + }, + [ + annotations, + applyLastRawPointerSample, + clearSnappedMeasurementCursor, + scene, + setCursorStateInternal, + ] + ); + + const scheduleMeasurementCursorSnapRelease = useCallback( + (pointId: string) => { + if (snappedPointIdRef.current !== pointId) return; + clearSnappedPointReleaseTimeout(); + snappedPointReleaseTimeoutRef.current = window.setTimeout(() => { + snappedPointReleaseTimeoutRef.current = null; + if (snappedPointIdRef.current !== pointId) return; + clearSnappedMeasurementCursor(); + applyLastRawPointerSample(); + }, snappedPointReleaseDelayMs); + }, + [ + applyLastRawPointerSample, + clearSnappedMeasurementCursor, + clearSnappedPointReleaseTimeout, + snappedPointReleaseDelayMs, + ] + ); + + const releaseMeasurementCursorSnap = useCallback(() => { + if (!snappedPointIdRef.current) { + return; + } + clearSnappedMeasurementCursor(); + applyLastRawPointerSample(); + }, [applyLastRawPointerSample, clearSnappedMeasurementCursor]); + + useEffect(() => { + if (enabled && hasCandidateNode) return; + clearMeasurementCursor(); + }, [clearMeasurementCursor, enabled, hasCandidateNode]); + + useEffect( + () => () => { + clearMeasurementCursorRef.current?.(); + }, + [] + ); + + return { + ...cursorState, + cursorScreenPosition: cursorState.rawCursorScreenPosition, + clearMeasurementCursor, + handleAnnotationCursorMove: handleRawMeasurementPointerMove, + releaseAnnotationCursorSnap: releaseMeasurementCursorSnap, + scheduleAnnotationCursorSnapRelease: scheduleMeasurementCursorSnapRelease, + syncAnnotationCursorToExistingPoint: syncMeasurementCursorToExistingPoint, + } as const; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationNodeInteractionController.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationNodeInteractionController.ts new file mode 100644 index 0000000000..c6e20f1811 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationNodeInteractionController.ts @@ -0,0 +1,266 @@ +import { useCallback, useMemo } from "react"; + +import { + ANNOTATION_TYPE_DISTANCE, + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + ANNOTATION_TYPE_POLYLINE, + isAreaToolType, + type AnnotationCollection, + type AnnotationToolType, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +const EMPTY_INTERACTIVE_POINT_ID_SET = new Set(); + +type UseAnnotationNodeInteractionControllerParams = { + activeToolType: AnnotationToolType; + allowInteractiveNodeCursorSnap: boolean; + selectionModeActive: boolean; + effectiveSelectModeAdditive: boolean; + selectablePointIds: ReadonlySet; + isActiveDrawMode: boolean; + distanceModeStickyToFirstPoint: boolean; + activeNodeChainAnnotationId: string | null; + selectAnnotationIds: (ids: string[], additive?: boolean) => void; + selectAnnotationById: (id: string) => void; + syncAnnotationCursorToExistingPoint: ( + pointId: string, + anchorPosition?: { x: number; y: number } | null + ) => boolean; + releaseAnnotationCursorSnap: () => void; + scheduleAnnotationCursorSnapRelease: (pointId: string) => void; + resolveDistanceRelationSourcePointId: ( + targetPointId: string + ) => string | null; + insertExistingNodeIntoActiveChain: ( + existingPointId: string, + sourcePointId?: string | null + ) => boolean | void; + upsertDirectDistanceRelation: ( + sourcePointId: string, + targetPointId: string + ) => void; + closeActivePolygonAnnotation: () => void; + finishActivePolylineAnnotation: () => void; + finishDistanceMeasurementSession: (selectedPointId: string | null) => void; + setDoubleClickChainSourcePointId: (pointId: string | null) => void; + handleDefaultPointNodeClick: (pointId: string) => void; +}; + +const getActiveOpenNodeChainAnnotation = ( + nodeChainAnnotations: readonly NodeChainAnnotation[], + activeNodeChainAnnotationId: string | null +) => + activeNodeChainAnnotationId !== null + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId && !group.closed + ) ?? null + : null; + +export const useAnnotationNodeInteractionController = ( + annotations: AnnotationCollection, + nodeChainAnnotations: readonly NodeChainAnnotation[], + { + activeToolType, + allowInteractiveNodeCursorSnap, + selectionModeActive, + effectiveSelectModeAdditive, + selectablePointIds, + isActiveDrawMode, + distanceModeStickyToFirstPoint, + activeNodeChainAnnotationId, + selectAnnotationIds, + selectAnnotationById, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + resolveDistanceRelationSourcePointId, + insertExistingNodeIntoActiveChain, + upsertDirectDistanceRelation, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + finishDistanceMeasurementSession, + setDoubleClickChainSourcePointId, + handleDefaultPointNodeClick, + }: UseAnnotationNodeInteractionControllerParams +) => { + const interactivePointIds = useMemo( + () => + allowInteractiveNodeCursorSnap + ? selectablePointIds + : EMPTY_INTERACTIVE_POINT_ID_SET, + [allowInteractiveNodeCursorSnap, selectablePointIds] + ); + + const handlePointNodeHoverChange = useCallback( + ( + pointId: string, + hovered: boolean, + anchorPosition?: { x: number; y: number } | null + ) => { + if (!allowInteractiveNodeCursorSnap) return; + if (hovered) { + syncAnnotationCursorToExistingPoint(pointId, anchorPosition); + return; + } + if (!anchorPosition) { + return; + } + scheduleAnnotationCursorSnapRelease(pointId); + }, + [ + allowInteractiveNodeCursorSnap, + scheduleAnnotationCursorSnapRelease, + syncAnnotationCursorToExistingPoint, + ] + ); + + const handlePointNodeClick = useCallback( + (pointId: string) => { + if (selectionModeActive) { + selectAnnotationIds([pointId], effectiveSelectModeAdditive); + return; + } + + const clickedMeasurement = annotations.find( + (measurement) => measurement.id === pointId + ); + const isAuxiliaryLabelAnchor = Boolean( + clickedMeasurement?.auxiliaryLabelAnchor + ); + const isNodeChainAuthoringTool = + activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType); + + if ( + activeToolType === ANNOTATION_TYPE_POINT || + activeToolType === ANNOTATION_TYPE_LABEL + ) { + selectAnnotationById(pointId); + return; + } + + if ( + (activeToolType === ANNOTATION_TYPE_DISTANCE || + isNodeChainAuthoringTool) && + !selectablePointIds.has(pointId) + ) { + return; + } + + if (isAuxiliaryLabelAnchor) { + selectAnnotationById(pointId); + return; + } + + if ( + activeToolType === ANNOTATION_TYPE_DISTANCE || + isNodeChainAuthoringTool + ) { + syncAnnotationCursorToExistingPoint(pointId); + } + + if (activeToolType === ANNOTATION_TYPE_DISTANCE) { + const sourcePointId = resolveDistanceRelationSourcePointId(pointId); + if (sourcePointId) { + upsertDirectDistanceRelation(sourcePointId, pointId); + if (distanceModeStickyToFirstPoint) { + setDoubleClickChainSourcePointId(sourcePointId); + } else { + finishDistanceMeasurementSession(null); + } + releaseAnnotationCursorSnap(); + return; + } + + setDoubleClickChainSourcePointId(pointId); + releaseAnnotationCursorSnap(); + return; + } + + if (isNodeChainAuthoringTool) { + if (!isActiveDrawMode) { + const didStartNodeChain = Boolean( + insertExistingNodeIntoActiveChain(pointId, null) + ); + if (!didStartNodeChain) { + return; + } + setDoubleClickChainSourcePointId(pointId); + releaseAnnotationCursorSnap(); + return; + } + + const activeOpenGroup = getActiveOpenNodeChainAnnotation( + nodeChainAnnotations, + activeNodeChainAnnotationId + ); + const firstNodeId = activeOpenGroup?.nodeIds[0] ?? null; + const shouldHandleRingClosure = Boolean( + firstNodeId && + firstNodeId === pointId && + activeOpenGroup && + activeOpenGroup.nodeIds.length >= 3 + ); + if (shouldHandleRingClosure) { + if (activeToolType !== ANNOTATION_TYPE_POLYLINE) { + closeActivePolygonAnnotation(); + } else { + finishActivePolylineAnnotation(); + } + return; + } + + const sourcePointId = resolveDistanceRelationSourcePointId(pointId); + const didAppendExistingPoint = Boolean( + insertExistingNodeIntoActiveChain(pointId, sourcePointId) + ); + if (didAppendExistingPoint) { + setDoubleClickChainSourcePointId(pointId); + releaseAnnotationCursorSnap(); + return; + } + + if (sourcePointId) { + upsertDirectDistanceRelation(sourcePointId, pointId); + } + + setDoubleClickChainSourcePointId(pointId); + releaseAnnotationCursorSnap(); + return; + } + + handleDefaultPointNodeClick(pointId); + }, + [ + selectionModeActive, + selectAnnotationIds, + effectiveSelectModeAdditive, + activeToolType, + annotations, + selectablePointIds, + selectAnnotationById, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + resolveDistanceRelationSourcePointId, + upsertDirectDistanceRelation, + setDoubleClickChainSourcePointId, + distanceModeStickyToFirstPoint, + isActiveDrawMode, + insertExistingNodeIntoActiveChain, + nodeChainAnnotations, + activeNodeChainAnnotationId, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + finishDistanceMeasurementSession, + handleDefaultPointNodeClick, + ] + ); + + return { + interactivePointIds, + handlePointNodeClick, + handlePointNodeHoverChange, + } as const; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsInteractionLifecycle.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsInteractionLifecycle.ts new file mode 100644 index 0000000000..08ea18112a --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsInteractionLifecycle.ts @@ -0,0 +1,345 @@ +import { useEffect } from "react"; + +import { isKeyboardTargetEditable } from "@carma-commons/utils"; +import { + ANNOTATION_TYPE_AREA_VERTICAL, + ANNOTATION_TYPE_POLYLINE, + SELECT_TOOL_TYPE, + hasAnyVisibleDistanceRelationLine, + isAreaToolType, + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationToolType, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type InteractionLifecycleState = { + annotations: AnnotationCollection; + selectedAnnotationIds: string[]; + selectablePointIds: ReadonlySet; + lockedMeasurementIdSet: ReadonlySet; + selectedAnnotationId: string | null; + deleteSelectedAnnotations: () => void; + clearAnnotationsByIds: (ids: string[]) => void; + pointMeasureEntries: AnnotationCollection; + selectAnnotationById: (id: string | null) => void; + setAnnotations: ( + updater: + | AnnotationCollection + | ((previous: AnnotationCollection) => AnnotationCollection) + ) => void; + pointTemporaryMode: boolean; + activeToolType: AnnotationToolType; + requestStartMeasurement: (toolType: AnnotationToolType) => void; + setDoubleClickChainSourcePointId: (id: string | null) => void; + isInteractionActive: boolean; + doubleClickChainSourcePointId: string | null; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + setPendingPolylinePromotionRingClosurePointId: (id: string | null) => void; + activeNodeChainAnnotationId: string | null; + setActiveNodeChainAnnotationId: (id: string | null) => void; + setNodeChainAnnotations: ( + next: + | NodeChainAnnotation[] + | ((previous: NodeChainAnnotation[]) => NodeChainAnnotation[]) + ) => void; +}; + +type UseAnnotationsInteractionLifecycleParams = InteractionLifecycleState & { + isPointMeasureCreateModeActive: boolean; +}; + +export const useAnnotationsInteractionLifecycle = ({ + annotations, + selectedAnnotationIds, + selectablePointIds, + lockedMeasurementIdSet, + selectedAnnotationId, + deleteSelectedAnnotations, + clearAnnotationsByIds, + pointMeasureEntries, + selectAnnotationById, + setAnnotations, + pointTemporaryMode, + activeToolType, + requestStartMeasurement, + setDoubleClickChainSourcePointId, + isInteractionActive, + doubleClickChainSourcePointId, + distanceRelations, + nodeChainAnnotations, + setPendingPolylinePromotionRingClosurePointId, + activeNodeChainAnnotationId, + setActiveNodeChainAnnotationId, + setNodeChainAnnotations, + isPointMeasureCreateModeActive, +}: UseAnnotationsInteractionLifecycleParams) => { + useEffect( + function effectClearPendingRingPromotionOutsidePolylineMode() { + if (activeToolType === ANNOTATION_TYPE_POLYLINE) return; + setPendingPolylinePromotionRingClosurePointId(null); + }, + [activeToolType, setPendingPolylinePromotionRingClosurePointId] + ); + + useEffect( + function effectBindDeleteKeyHandler() { + const handleDeleteKey = (event: KeyboardEvent) => { + if (event.key !== "Delete" && event.key !== "Backspace") return; + if (event.defaultPrevented) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isKeyboardTargetEditable(event.target)) return; + const selectedIds = selectedAnnotationIds.filter( + (id) => selectablePointIds.has(id) && !lockedMeasurementIdSet.has(id) + ); + if (selectedIds.length > 1) { + return; + } + const hasDeletablePrimarySelection = + Boolean(selectedAnnotationId) && + selectablePointIds.has(selectedAnnotationId) && + !lockedMeasurementIdSet.has(selectedAnnotationId); + if (selectedIds.length === 0 && !hasDeletablePrimarySelection) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + deleteSelectedAnnotations(); + }; + + window.addEventListener("keydown", handleDeleteKey, true); + return () => { + window.removeEventListener("keydown", handleDeleteKey, true); + }; + }, + [ + deleteSelectedAnnotations, + lockedMeasurementIdSet, + selectablePointIds, + selectedAnnotationId, + selectedAnnotationIds, + ] + ); + + useEffect( + function effectBindPointModeKeyboardShortcuts() { + const handlePointModeKeyboardShortcuts = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + if (event.metaKey || event.ctrlKey || event.altKey) return; + if (isKeyboardTargetEditable(event.target)) return; + if (activeToolType === SELECT_TOOL_TYPE) { + return; + } + + const hasSelection = + selectedAnnotationIds.length > 0 || Boolean(selectedAnnotationId); + + if ( + event.key === "Enter" && + isPointMeasureCreateModeActive && + pointTemporaryMode + ) { + const latestTemporaryPointMeasurement = [...annotations] + .reverse() + .find( + (measurement) => + isPointAnnotationEntry(measurement) && measurement.temporary + ); + if (!latestTemporaryPointMeasurement) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setAnnotations((prev) => + prev.map((measurement) => + measurement.temporary + ? { ...measurement, temporary: false } + : measurement + ) + ); + selectAnnotationById(latestTemporaryPointMeasurement.id); + return; + } + + if (event.key !== "Backspace") return; + if (hasSelection) return; + + const latestPointMeasurement = + pointMeasureEntries[pointMeasureEntries.length - 1]; + if (!latestPointMeasurement) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + clearAnnotationsByIds([latestPointMeasurement.id]); + selectAnnotationById( + pointMeasureEntries[pointMeasureEntries.length - 2]?.id ?? null + ); + }; + + window.addEventListener( + "keydown", + handlePointModeKeyboardShortcuts, + true + ); + return () => { + window.removeEventListener( + "keydown", + handlePointModeKeyboardShortcuts, + true + ); + }; + }, + [ + activeToolType, + clearAnnotationsByIds, + pointMeasureEntries, + selectedAnnotationId, + selectedAnnotationIds.length, + selectAnnotationById, + setAnnotations, + isPointMeasureCreateModeActive, + pointTemporaryMode, + annotations, + ] + ); + + useEffect( + function effectSyncInteractionEnabledState() { + if (!isInteractionActive) { + requestStartMeasurement(SELECT_TOOL_TYPE); + } + }, + [isInteractionActive, requestStartMeasurement] + ); + + useEffect( + function effectCleanUpInvalidOpenVerticalGroups() { + if ( + activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType) + ) { + return; + } + + const invalidOpenVerticalGroups = nodeChainAnnotations.filter((group) => { + return ( + !group.closed && + group.type === ANNOTATION_TYPE_AREA_VERTICAL && + group.nodeIds.length === 1 + ); + }); + if (invalidOpenVerticalGroups.length === 0) return; + + const invalidGroupIdSet = new Set( + invalidOpenVerticalGroups.map((group) => group.id) + ); + const removablePointIdSet = new Set(); + invalidOpenVerticalGroups.forEach((group) => { + const onlyPointId = group.nodeIds[0]; + if (onlyPointId) { + removablePointIdSet.add(onlyPointId); + } + }); + + const remainingGroups = nodeChainAnnotations.filter( + (group) => !invalidGroupIdSet.has(group.id) + ); + const protectedPointIdSet = new Set(); + remainingGroups.forEach((group) => { + group.nodeIds.forEach((pointId) => { + if (pointId) { + protectedPointIdSet.add(pointId); + } + }); + }); + distanceRelations.forEach((relation) => { + protectedPointIdSet.add(relation.pointAId); + protectedPointIdSet.add(relation.pointBId); + protectedPointIdSet.add(relation.anchorPointId); + }); + + setNodeChainAnnotations(remainingGroups); + + if (removablePointIdSet.size === 0) return; + setAnnotations((prev) => + prev.filter((measurement) => { + if (!isPointAnnotationEntry(measurement)) { + return true; + } + if (!removablePointIdSet.has(measurement.id)) { + return true; + } + return protectedPointIdSet.has(measurement.id); + }) + ); + }, + [ + activeToolType, + nodeChainAnnotations, + distanceRelations, + setNodeChainAnnotations, + setAnnotations, + ] + ); + + useEffect( + function effectClearMissingDoubleClickChainSource() { + if (!doubleClickChainSourcePointId) return; + const hasChainSourceMeasurement = annotations.some( + (measurement) => measurement.id === doubleClickChainSourcePointId + ); + if (!hasChainSourceMeasurement) { + setDoubleClickChainSourcePointId(null); + } + }, + [ + doubleClickChainSourcePointId, + annotations, + setDoubleClickChainSourcePointId, + ] + ); + + useEffect( + function effectSelectVisibleDistanceAnchorWhenNothingSelected() { + if (selectedAnnotationId || activeNodeChainAnnotationId) return; + + const relationWithVisibleLine = distanceRelations.find( + hasAnyVisibleDistanceRelationLine + ); + if (relationWithVisibleLine) { + selectAnnotationById(relationWithVisibleLine.anchorPointId); + } + }, + [ + activeNodeChainAnnotationId, + distanceRelations, + selectedAnnotationId, + selectAnnotationById, + ] + ); + + useEffect( + function effectClearMissingActiveNodeChainAnnotation() { + if (!activeNodeChainAnnotationId) return; + const hasActiveGroup = nodeChainAnnotations.some( + (group) => group.id === activeNodeChainAnnotationId + ); + if (!hasActiveGroup) { + setActiveNodeChainAnnotationId(null); + setDoubleClickChainSourcePointId(null); + } + }, + [ + activeNodeChainAnnotationId, + nodeChainAnnotations, + setActiveNodeChainAnnotationId, + setDoubleClickChainSourcePointId, + ] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsUserInteraction.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsUserInteraction.ts new file mode 100644 index 0000000000..63dea698af --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useAnnotationsUserInteraction.ts @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useMemo } from "react"; + +import { + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + SELECT_TOOL_TYPE, + type AnnotationEntry, + type AnnotationCollection, + type AnnotationToolType, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; +import type { Scene, Cartesian2, Cartesian3 } from "@carma/cesium"; + +import type { AnnotationsEditingState } from "./editing/useAnnotationsEditing"; +import { useAnnotationNodeInteractionController } from "./useAnnotationNodeInteractionController"; +import { buildActivePointCreateConfig } from "./pointCreateConfig"; +import { usePointQueryCreationController } from "./usePointQueryCreationController"; + +type AnnotationsUserInteractionInput = { + annotations: AnnotationCollection; + activeToolType: AnnotationToolType; + selectionModeActive: boolean; + effectiveSelectModeAdditive: boolean; + selectablePointIds: ReadonlySet; + moveGizmoPointId: string | null; + isMoveGizmoDragging: boolean; + pointQueryEnabled: boolean; + hasCandidateNode: boolean; + isActiveDrawMode: boolean; + distanceModeStickyToFirstPoint: boolean; + activeNodeChainAnnotationId: string | null; + nodeChainAnnotations: NodeChainAnnotation[]; + selectAnnotationIds: (ids: string[], additive?: boolean) => void; + selectAnnotationById: (id: string | null) => void; + syncAnnotationCursorToExistingPoint: ( + pointId: string, + anchorPosition?: { x: number; y: number } | null + ) => boolean; + releaseAnnotationCursorSnap: () => void; + scheduleAnnotationCursorSnapRelease: (pointId: string) => void; + resolveDistanceRelationSourcePointId: ( + targetPointId: string + ) => string | null; + insertExistingNodeIntoActiveChain: ( + existingPointId: string, + sourcePointId?: string | null + ) => boolean | void; + upsertDirectDistanceRelation: ( + sourcePointId: string, + targetPointId: string + ) => void; + closeActivePolygonAnnotation: () => void; + finishActivePolylineAnnotation: () => void; + finishDistanceMeasurementSession: (selectedPointId: string | null) => void; + setDoubleClickChainSourcePointId: (pointId: string | null) => void; + selectedAnnotationId: string | null; + cyclePointLabelMetricModeByMeasurementId: (id: string) => void; + labelInputPromptPointId: string | null; + setLabelInputPromptPointId: (id: string | null) => void; + setReferencePointId: (pointId: string) => void; + pointTemporaryMode: boolean; + pointVerticalOffsetMeters: number; + lastCustomPointAnnotationName: string; + isPolylineCandidateMode: boolean; + polylineVerticalOffsetMeters: number; + scene: Scene | null; + setAnnotations: ( + next: + | AnnotationCollection + | ((prev: AnnotationCollection) => AnnotationCollection) + ) => void; + handlePointQueryPointCreated: ( + id: string, + positionECEF: Cartesian3, + annotationEntry?: AnnotationEntry + ) => void; + handlePointQueryDoubleClick: () => void; + handlePointQueryBeforePointCreate: ( + positionECEF: Cartesian3 | null, + screenPosition: Cartesian2 + ) => boolean; + handleAnnotationCursorMove: ( + positionECEF: Cartesian3 | null, + screenPosition: Cartesian2, + surfaceNormalECEF?: Cartesian3 | null + ) => void; +}; + +export const useAnnotationsUserInteraction = ( + managedAnnotations: AnnotationsUserInteractionInput, + annotationEditing: AnnotationsEditingState +) => { + const { + annotations, + activeToolType, + selectionModeActive, + effectiveSelectModeAdditive, + selectablePointIds, + moveGizmoPointId, + isMoveGizmoDragging, + pointQueryEnabled, + hasCandidateNode, + isActiveDrawMode, + distanceModeStickyToFirstPoint, + activeNodeChainAnnotationId, + nodeChainAnnotations, + selectAnnotationIds, + selectAnnotationById, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + resolveDistanceRelationSourcePointId, + insertExistingNodeIntoActiveChain, + upsertDirectDistanceRelation, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + finishDistanceMeasurementSession, + setDoubleClickChainSourcePointId, + selectedAnnotationId, + cyclePointLabelMetricModeByMeasurementId, + labelInputPromptPointId, + setLabelInputPromptPointId, + setReferencePointId, + pointTemporaryMode, + pointVerticalOffsetMeters, + lastCustomPointAnnotationName, + isPolylineCandidateMode, + polylineVerticalOffsetMeters, + scene, + setAnnotations, + handlePointQueryPointCreated, + handlePointQueryDoubleClick, + handlePointQueryBeforePointCreate, + handleAnnotationCursorMove, + } = managedAnnotations; + const { requestUpdateEditTarget } = annotationEditing; + const allowInteractiveNodeCursorSnap = + pointQueryEnabled && + hasCandidateNode && + !moveGizmoPointId && + !isMoveGizmoDragging; + const isPointMeasureLabelModeActive = + activeToolType === ANNOTATION_TYPE_LABEL; + const isPointMeasureLabelInputPending = + isPointMeasureLabelModeActive && labelInputPromptPointId !== null; + const isPointMeasureCreateModeActive = + activeToolType === ANNOTATION_TYPE_POINT; + const pointQueryToolActive = + !isPointMeasureLabelInputPending && activeToolType !== SELECT_TOOL_TYPE; + const activePointCreateConfig = useMemo( + () => + buildActivePointCreateConfig({ + activeToolType, + temporaryMode: pointTemporaryMode, + pointVerticalOffsetMeters, + lastCustomPointAnnotationName, + isPolylineCandidateMode, + polylineVerticalOffsetMeters, + }), + [ + activeToolType, + isPolylineCandidateMode, + lastCustomPointAnnotationName, + pointVerticalOffsetMeters, + polylineVerticalOffsetMeters, + pointTemporaryMode, + ] + ); + + useEffect( + function effectClearLabelPromptOutsidePointLabelMode() { + if (activeToolType !== ANNOTATION_TYPE_LABEL) { + setLabelInputPromptPointId(null); + } + }, + [activeToolType, setLabelInputPromptPointId] + ); + + useEffect( + function effectClearMissingLabelPromptPoint() { + if (!labelInputPromptPointId) { + return; + } + + const hasPromptAnnotation = annotations.some( + (annotation) => annotation.id === labelInputPromptPointId + ); + if (!hasPromptAnnotation) { + setLabelInputPromptPointId(null); + } + }, + [annotations, labelInputPromptPointId, setLabelInputPromptPointId] + ); + + const { + interactivePointIds, + handlePointNodeClick, + handlePointNodeHoverChange: handlePointLabelHoverChange, + } = useAnnotationNodeInteractionController( + annotations, + nodeChainAnnotations, + { + activeToolType, + allowInteractiveNodeCursorSnap, + selectionModeActive, + effectiveSelectModeAdditive, + selectablePointIds, + isActiveDrawMode, + distanceModeStickyToFirstPoint, + activeNodeChainAnnotationId, + selectAnnotationIds, + selectAnnotationById, + syncAnnotationCursorToExistingPoint, + releaseAnnotationCursorSnap, + scheduleAnnotationCursorSnapRelease, + resolveDistanceRelationSourcePointId, + insertExistingNodeIntoActiveChain, + upsertDirectDistanceRelation, + closeActivePolygonAnnotation, + finishActivePolylineAnnotation, + finishDistanceMeasurementSession, + setDoubleClickChainSourcePointId, + handleDefaultPointNodeClick: (id) => { + if (selectedAnnotationId === id) { + cyclePointLabelMetricModeByMeasurementId(id); + return; + } + selectAnnotationById(id); + }, + } + ); + + const handlePointLabelClick = useCallback( + (pointId: string) => { + if ( + requestUpdateEditTarget({ + kind: "point-elevation-reference", + pointId, + }) + ) { + return; + } + handlePointNodeClick(pointId); + }, + [handlePointNodeClick, requestUpdateEditTarget] + ); + + const handlePointLabelDoubleClick = useCallback( + (pointId: string) => { + if (!selectablePointIds.has(pointId)) { + return; + } + + setReferencePointId(pointId); + finishDistanceMeasurementSession(pointId); + }, + [finishDistanceMeasurementSession, selectablePointIds, setReferencePointId] + ); + + usePointQueryCreationController( + scene, + activeToolType, + activePointCreateConfig, + { + pointQueryToolActive, + pointQueryEnabled, + selectionModeActive, + moveGizmoPointId, + isMoveGizmoDragging, + setAnnotations, + handlePointQueryPointCreated, + handlePointQueryDoubleClick, + handlePointQueryBeforePointCreate, + handleAnnotationCursorMove, + } + ); + + return { + interactivePointIds, + handlePointLabelClick, + handlePointLabelDoubleClick, + handlePointLabelHoverChange, + isPointMeasureLabelModeActive, + isPointMeasureLabelInputPending, + isPointMeasureCreateModeActive, + }; +}; + +export type AnnotationsUserInteractionState = ReturnType< + typeof useAnnotationsUserInteraction +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useOverlayPositionSync.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useOverlayPositionSync.ts new file mode 100644 index 0000000000..4023c8bfea --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useOverlayPositionSync.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; + +import { useLabelOverlay } from "@carma-providers/label-overlay"; +import { useCesiumOverlaySync } from "@carma-mapping/annotations/cesium"; +import type { Scene } from "@carma/cesium"; + +export const useOverlayPositionSync = (scene: Scene) => { + const requestUpdateCallback = useCesiumOverlaySync(scene); + const overlayContext = useLabelOverlay(); + + useEffect( + function effectSyncOverlayContextPositions() { + if (overlayContext && overlayContext.updatePositions) { + requestUpdateCallback(overlayContext.updatePositions); + } + }, + [overlayContext, requestUpdateCallback] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointQueryCreationController.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQueryCreationController.ts similarity index 54% rename from libraries/mapping/annotations/provider/src/lib/context/hooks/usePointQueryCreationController.ts rename to libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQueryCreationController.ts index a0d8d7bfe5..0e8a9f63c5 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/usePointQueryCreationController.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQueryCreationController.ts @@ -5,33 +5,85 @@ import { Cartesian3, getDegreesFromCartesian, getEllipsoidalAltitudeOrZero, + getLocalUpDirectionAtPosition, type Scene, } from "@carma/cesium"; -import { useAnnotationPointCreation } from "@carma-mapping/annotations/core"; import { - ANNOTATION_TYPE_POINT, ANNOTATION_TYPE_DISTANCE, - isPointAnnotationEntry, + ANNOTATION_TYPE_LABEL, + ANNOTATION_TYPE_POINT, + isDistancePointEntry, + isPointMeasurementEntry, type AnnotationCollection, type AnnotationEntry, - type AnnotationMode, + type AnnotationToolType, } from "@carma-mapping/annotations/core"; +import { useAnnotationPointCreation } from "./create/useAnnotationPointCreation"; import { useCesiumPointQuery, type CesiumPointQueryCreatePayload, } from "@carma-mapping/annotations/cesium"; -import { type ActivePointCreateConfig } from "./usePointCreateConfigState"; +import { type ActivePointCreateConfig } from "./pointCreateConfig"; + +type PointCreatePayload = { + geometryPositionECEF: Cartesian3; + anchorPositionECEF: Cartesian3; + hasVerticalOffsetStem: boolean; +}; + +const buildPointCreatePayload = ( + payload: CesiumPointQueryCreatePayload, + { + verticalOffsetMeters, + preferGlobeAnchorForVerticalOffset, + }: { + verticalOffsetMeters: number; + preferGlobeAnchorForVerticalOffset: boolean; + } +): PointCreatePayload => { + const safeVerticalOffsetMeters = Number.isFinite(verticalOffsetMeters) + ? verticalOffsetMeters + : 0; + const hasVerticalOffsetStem = Math.abs(safeVerticalOffsetMeters) > 1e-9; + const anchorPosition = + hasVerticalOffsetStem && preferGlobeAnchorForVerticalOffset + ? payload.globePositionECEF ?? payload.pickedPositionECEF + : payload.pickedPositionECEF; + + if (!hasVerticalOffsetStem) { + return { + geometryPositionECEF: Cartesian3.clone( + payload.pickedPositionECEF, + new Cartesian3() + ), + anchorPositionECEF: Cartesian3.clone(anchorPosition, new Cartesian3()), + hasVerticalOffsetStem: false, + }; + } + + const localUpDirectionECEF = getLocalUpDirectionAtPosition(anchorPosition); + return { + geometryPositionECEF: Cartesian3.add( + anchorPosition, + Cartesian3.multiplyByScalar( + localUpDirectionECEF, + safeVerticalOffsetMeters, + new Cartesian3() + ), + new Cartesian3() + ), + anchorPositionECEF: Cartesian3.clone(anchorPosition, new Cartesian3()), + hasVerticalOffsetStem: true, + }; +}; type UsePointQueryCreationControllerParams = { - scene: Scene | null; - annotationMode: AnnotationMode; pointQueryToolActive: boolean; pointQueryEnabled: boolean; selectionModeActive: boolean; moveGizmoPointId: string | null; isMoveGizmoDragging: boolean; - activePointCreateConfig: ActivePointCreateConfig | null; setAnnotations: Dispatch>; handlePointQueryPointCreated: ( pointId: string, @@ -42,35 +94,34 @@ type UsePointQueryCreationControllerParams = { positionECEF: Cartesian3 | null, screenPosition: Cartesian2 ) => boolean; - handlePointQueryPointerMoveWithHoveredNodeAnchor: ( + handleAnnotationCursorMove: ( positionECEF: Cartesian3 | null, screenPosition: Cartesian2, surfaceNormalECEF?: Cartesian3 | null ) => void; }; -export const usePointQueryCreationController = ({ - scene, - annotationMode, - pointQueryToolActive, - pointQueryEnabled, - selectionModeActive, - moveGizmoPointId, - isMoveGizmoDragging, - activePointCreateConfig, - setAnnotations, - handlePointQueryPointCreated, - handlePointQueryDoubleClick, - handlePointQueryBeforePointCreate, - handlePointQueryPointerMoveWithHoveredNodeAnchor, -}: UsePointQueryCreationControllerParams) => { +export const usePointQueryCreationController = ( + scene: Scene | null, + activeToolType: AnnotationToolType, + activePointCreateConfig: ActivePointCreateConfig | null, + { + pointQueryToolActive, + pointQueryEnabled, + selectionModeActive, + moveGizmoPointId, + isMoveGizmoDragging, + setAnnotations, + handlePointQueryPointCreated, + handlePointQueryDoubleClick, + handlePointQueryBeforePointCreate, + handleAnnotationCursorMove, + }: UsePointQueryCreationControllerParams +) => { const { handlePointCreate: handlePointQueryCreate, handleLineFinish: handlePointQueryLineFinish, - } = useAnnotationPointCreation< - AnnotationEntry, - CesiumPointQueryCreatePayload - >({ + } = useAnnotationPointCreation({ temporaryMode: activePointCreateConfig?.temporaryMode ?? false, setCollection: setAnnotations as Dispatch< SetStateAction @@ -84,6 +135,17 @@ export const usePointQueryCreationController = ({ temporaryMode: createTemporaryMode, useTemporaryForCreatedEntries, }) => { + const createdPointType = + activeToolType === ANNOTATION_TYPE_POINT || + activeToolType === ANNOTATION_TYPE_LABEL + ? ANNOTATION_TYPE_POINT + : ANNOTATION_TYPE_DISTANCE; + const createdPointIndexEntries = + previousCollection?.filter( + createdPointType === ANNOTATION_TYPE_POINT + ? isPointMeasurementEntry + : isDistancePointEntry + ) ?? []; const geometryWGS84 = getDegreesFromCartesian( payload.geometryPositionECEF ); @@ -94,11 +156,11 @@ export const usePointQueryCreationController = ({ const insertionIndex = createTemporaryMode ? useTemporaryForCreatedEntries ? 0 - : previousCollection?.filter(isPointAnnotationEntry).length || 0 - : previousCollection?.filter(isPointAnnotationEntry).length || 0; + : createdPointIndexEntries.length + : createdPointIndexEntries.length; return { - type: ANNOTATION_TYPE_DISTANCE, + type: createdPointType, id: pointId, index: insertionIndex, geometryECEF: payload.geometryPositionECEF, @@ -116,9 +178,6 @@ export const usePointQueryCreationController = ({ ...(activePointCreateConfig?.auxiliaryOnCreate ? { auxiliaryLabelAnchor: true } : {}), - ...(activePointCreateConfig?.markCreatedPointsAsDistanceAdhoc - ? { distanceAdhocNode: true } - : {}), ...(payload.hasVerticalOffsetStem ? { verticalOffsetAnchorECEF: { @@ -142,6 +201,9 @@ export const usePointQueryCreationController = ({ onLineFinish: handlePointQueryDoubleClick, }); + const pointVerticalOffsetMeters = + activePointCreateConfig?.verticalOffsetMeters ?? 0; + useCesiumPointQuery(scene, { enabled: pointQueryToolActive && @@ -150,12 +212,16 @@ export const usePointQueryCreationController = ({ !moveGizmoPointId && !isMoveGizmoDragging && Boolean(activePointCreateConfig), - verticalOffsetMeters: activePointCreateConfig?.verticalOffsetMeters ?? 0, - preferGlobeAnchorForVerticalOffset: - annotationMode === ANNOTATION_TYPE_POINT, onBeforePointCreate: handlePointQueryBeforePointCreate, - onPointCreate: handlePointQueryCreate, + onPointCreate: (payload) => + handlePointQueryCreate( + buildPointCreatePayload(payload, { + verticalOffsetMeters: pointVerticalOffsetMeters, + preferGlobeAnchorForVerticalOffset: + activeToolType === ANNOTATION_TYPE_POINT, + }) + ), onLineFinish: handlePointQueryLineFinish, - onPointerMove: handlePointQueryPointerMoveWithHoveredNodeAnchor, + onPointerMove: handleAnnotationCursorMove, }); }; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQuerySelectionGuard.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQuerySelectionGuard.ts new file mode 100644 index 0000000000..8ac19fbd97 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/usePointQuerySelectionGuard.ts @@ -0,0 +1,113 @@ +import { useCallback, useEffect } from "react"; + +import { + Cartesian2, + ScreenSpaceEventHandler, + ScreenSpaceEventType, + isValidScene, + type Cartesian3, + type Scene, +} from "@carma/cesium"; +import { + ANNOTATION_TYPE_POLYLINE, + isAreaToolType, + type AnnotationToolType, +} from "@carma-mapping/annotations/core"; + +type UsePointQuerySelectionGuardParams = { + scene: Scene; + activeToolType: AnnotationToolType; + isActiveDrawMode: boolean; + focusedSelectedNodeChainAnnotationId: string | null; + selectionModeActive: boolean; + selectAnnotationById: (id: string | null) => void; + selectRepresentativeNodeForMeasurementId: ( + measurementId: string | null + ) => void; +}; + +export const usePointQuerySelectionGuard = ({ + scene, + activeToolType, + isActiveDrawMode, + focusedSelectedNodeChainAnnotationId, + selectionModeActive, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, +}: UsePointQuerySelectionGuardParams) => { + const handlePointQueryBeforePointCreate = useCallback( + (_positionECEF: Cartesian3 | null, screenPosition: Cartesian2) => { + if (isValidScene(scene)) { + const picked = scene.pick(screenPosition); + const pickedPolygonGroupId = picked?.id?.polygonGroupId; + if (pickedPolygonGroupId) { + selectRepresentativeNodeForMeasurementId(pickedPolygonGroupId); + return false; + } + } + + if (isActiveDrawMode) { + return true; + } + + if (focusedSelectedNodeChainAnnotationId) { + selectRepresentativeNodeForMeasurementId(null); + if ( + activeToolType === ANNOTATION_TYPE_POLYLINE || + isAreaToolType(activeToolType) + ) { + return true; + } + return false; + } + + return true; + }, + [ + activeToolType, + focusedSelectedNodeChainAnnotationId, + isActiveDrawMode, + scene, + selectRepresentativeNodeForMeasurementId, + ] + ); + + useEffect( + function effectBindPolygonFillSelectionClickHandler() { + if (!isValidScene(scene) || !selectionModeActive) { + return; + } + + const clickHandler = new ScreenSpaceEventHandler(scene.canvas); + clickHandler.setInputAction((event) => { + const screenPosition = event.position; + if (!screenPosition) return; + + const picked = scene.pick(screenPosition); + if (!picked) { + selectAnnotationById(null); + return; + } + const pickedPolygonGroupId = picked?.id?.polygonGroupId; + if (typeof pickedPolygonGroupId !== "string") return; + if (!pickedPolygonGroupId.trim()) return; + + selectRepresentativeNodeForMeasurementId(pickedPolygonGroupId); + }, ScreenSpaceEventType.LEFT_CLICK); + + return () => { + clickHandler.destroy(); + }; + }, + [ + scene, + selectionModeActive, + selectAnnotationById, + selectRepresentativeNodeForMeasurementId, + ] + ); + + return { + handlePointQueryBeforePointCreate, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointMeasurementId.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointMeasurementId.ts new file mode 100644 index 0000000000..75e51ad0ca --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointMeasurementId.ts @@ -0,0 +1,24 @@ +import { useMemo } from "react"; +import { Cartesian3 } from "@carma/cesium"; +import { + isPointAnnotationEntry, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; + +export const useReferencePointMeasurementId = ( + annotations: AnnotationCollection, + referencePoint: Cartesian3 | null, + referencePointSyncEpsilonMeters: number +) => + useMemo(() => { + if (!referencePoint) return null; + const pointMeasurement = annotations.find( + (measurement) => + isPointAnnotationEntry(measurement) && + Cartesian3.distance(measurement.geometryECEF, referencePoint) <= + referencePointSyncEpsilonMeters + ); + return pointMeasurement && isPointAnnotationEntry(pointMeasurement) + ? pointMeasurement.id + : null; + }, [annotations, referencePoint, referencePointSyncEpsilonMeters]); diff --git a/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointState.ts b/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointState.ts new file mode 100644 index 0000000000..f9cc47ce2d --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/interaction/useReferencePointState.ts @@ -0,0 +1,86 @@ +import { + useCallback, + useEffect, + type Dispatch, + type SetStateAction, +} from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import type { PointAnnotationEntry } from "@carma-mapping/annotations/core"; + +type UseReferencePointStateParams = { + pointEntries: PointAnnotationEntry[]; + referencePoint: Cartesian3 | null; + setReferencePoint: Dispatch>; + referencePointSyncEpsilonMeters: number; +}; + +export const useReferencePointState = ({ + pointEntries, + referencePoint, + setReferencePoint, + referencePointSyncEpsilonMeters, +}: UseReferencePointStateParams) => { + useEffect( + function effectSyncReferencePointAfterPointDeletion() { + if (!referencePoint) return; + + if (pointEntries.length === 0) { + setReferencePoint(null); + return; + } + + const hasReferenceMeasurement = pointEntries.some( + (measurement) => + Cartesian3.distance(measurement.geometryECEF, referencePoint) <= + referencePointSyncEpsilonMeters + ); + + if (hasReferenceMeasurement) { + return; + } + + const nextReferencePoint = + pointEntries[pointEntries.length - 1]?.geometryECEF ?? null; + setReferencePoint(nextReferencePoint); + }, + [ + pointEntries, + referencePoint, + referencePointSyncEpsilonMeters, + setReferencePoint, + ] + ); + + useEffect( + function effectInitializeReferencePointFromPointEntries() { + if (referencePoint !== null) return; + if (pointEntries.length > 1) { + setReferencePoint(pointEntries[0]?.geometryECEF ?? null); + } + }, + [pointEntries, referencePoint, setReferencePoint] + ); + + const setReferencePointId = useCallback( + (id: string | null) => { + if (id === null) { + setReferencePoint(null); + return; + } + + const referenceMeasurement = + pointEntries.find((pointEntry) => pointEntry.id === id) ?? null; + if (!referenceMeasurement) { + return; + } + + setReferencePoint(referenceMeasurement.geometryECEF); + }, + [pointEntries, setReferencePoint] + ); + + return { + setReferencePointId, + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/annotationBadgeTokens.ts b/libraries/mapping/annotations/provider/src/lib/context/render/annotationBadgeTokens.ts similarity index 90% rename from libraries/mapping/annotations/core/src/lib/context/hooks/annotationBadgeTokens.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/annotationBadgeTokens.ts index cce5eed3f9..6318aed77a 100644 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/annotationBadgeTokens.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/annotationBadgeTokens.ts @@ -1,16 +1,16 @@ -import { toAlphabeticSequence } from "../../utils/alphabeticSequence"; import { ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, ANNOTATION_TYPE_DISTANCE, ANNOTATION_TYPE_LABEL, - ANNOTATION_TYPE_AREA_PLANAR, ANNOTATION_TYPE_POINT, ANNOTATION_TYPE_POLYLINE, - ANNOTATION_TYPE_AREA_VERTICAL, + toAlphabeticSequence, type AnnotationShortLabelKind, -} from "../../types/annotationTypes"; -export { toAlphabeticSequence } from "../../utils/alphabeticSequence"; -export type { AnnotationShortLabelKind } from "../../types/annotationTypes"; +} from "@carma-mapping/annotations/core"; +export { toAlphabeticSequence } from "@carma-mapping/annotations/core"; +export type { AnnotationShortLabelKind } from "@carma-mapping/annotations/core"; export type AnnotationShortLabelCounterStyle = "numeric" | "alphabetic"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/annotationVisualization.types.ts b/libraries/mapping/annotations/provider/src/lib/context/render/annotationVisualization.types.ts new file mode 100644 index 0000000000..24c6ce9f17 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/annotationVisualization.types.ts @@ -0,0 +1,22 @@ +import type { + LineType, + PointAnnotationEntry, + PolygonPreviewGroup, +} from "@carma-mapping/annotations/core"; +import type { Color } from "@carma/cesium"; + +export type EdgeSceneLineRenderModel = { + id: string; + start: PointAnnotationEntry["geometryECEF"]; + end: PointAnnotationEntry["geometryECEF"]; + stroke: string; + strokeWidth: number; + dashed?: boolean; + lineType?: LineType; +}; + +export type PolygonPrimitiveRenderModel = { + id: string; + vertexPoints: ReadonlyArray; + fillColor: Color; +}; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelVisualizer.types.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/areaLabelVisualizer.types.ts similarity index 60% rename from libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelVisualizer.types.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/area/labels/areaLabelVisualizer.types.ts index 48ea781c16..7d7caa1e98 100644 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/areaLabelVisualizer.types.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/areaLabelVisualizer.types.ts @@ -1,12 +1,10 @@ import type { Cartesian3Json, Matrix4ConstructorArgs } from "@carma/cesium"; - import { - type GroundPolygonPreviewGroup, - type PlanarPolygonPreviewGroup, + type AreaLabelText, + type NodeChainAnnotation, type PolygonPreviewGroup, - type VerticalPolygonPreviewGroup, -} from "../../preview/annotationPreviewVisuals"; -import { type PlanarPolygonGroup } from "../../types/planarTypes"; +} from "@carma-mapping/annotations/core"; + import type { CssPixelPosition } from "@carma/units/types"; export type PolygonAreaBadge = { @@ -15,13 +13,7 @@ export type PolygonAreaBadge = { textColor?: string; }; -export type AreaLabelText = { - primaryText: string; - secondaryText?: string | null; -}; - type AreaLabelVisualizerCommonOptions = { - viewProjector: AreaLabelViewProjector; focusedPolygonGroupId: string | null; polygonAreaBadgeByGroupId: Readonly>; }; @@ -40,26 +32,19 @@ export type AreaLabelViewProjector = { }; export type GroundAreaLabelVisualizerOptions = - AreaLabelVisualizerCommonOptions & { - groundPolygonPreviewGroups: GroundPolygonPreviewGroup[]; - }; + AreaLabelVisualizerCommonOptions & {}; export type VerticalAreaLabelVisualizerOptions = - AreaLabelVisualizerCommonOptions & { - verticalPolygonPreviewGroups: VerticalPolygonPreviewGroup[]; - }; + AreaLabelVisualizerCommonOptions & {}; export type PlanarAreaLabelVisualizerOptions = - AreaLabelVisualizerCommonOptions & { - planarPolygonPreviewGroups: PlanarPolygonPreviewGroup[]; - }; + AreaLabelVisualizerCommonOptions & {}; export type PolygonAreaLabelOverlayBaseOptions = AreaLabelVisualizerCommonOptions & { overlayPrefix: string; - polygonPreviewGroups: PolygonPreviewGroup[]; resolveAreaLabelText: ( - group: PlanarPolygonGroup, + group: NodeChainAnnotation, vertices: Cartesian3Json[] ) => AreaLabelText; }; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/index.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/index.ts similarity index 100% rename from libraries/mapping/annotations/core/src/lib/visualizers/area-labels/index.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/area/labels/index.ts index df042d3aba..9d3adff17e 100644 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/index.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/index.ts @@ -1,4 +1,4 @@ export * from "./areaLabelVisualizer.types"; export * from "./useGroundAreaLabelVisualizer"; -export * from "./useVerticalAreaLabelVisualizer"; export * from "./usePlanarAreaLabelVisualizer"; +export * from "./useVerticalAreaLabelVisualizer"; diff --git a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useAreaLabelVisualizerBase.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useAreaLabelVisualizerBase.ts similarity index 92% rename from libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useAreaLabelVisualizerBase.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useAreaLabelVisualizerBase.ts index 07d09ac3a6..e87e951fee 100644 --- a/libraries/mapping/annotations/core/src/lib/visualizers/area-labels/useAreaLabelVisualizerBase.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useAreaLabelVisualizerBase.ts @@ -8,11 +8,19 @@ import { useLabelOverlay, type LayoutPointInput, } from "@carma-providers/label-overlay"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_AREA_VERTICAL, + computePolygonCentroid2D, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; -import { computePolygonCentroid2D } from "../../distanceScreenSpace"; import type { CssPixelPosition } from "@carma/units/types"; -import { type PlanarPolygonGroup } from "../../types/planarTypes"; -import { type PolygonAreaLabelOverlayBaseOptions } from "./areaLabelVisualizer.types"; +import { + type AreaLabelViewProjector, + type PolygonAreaLabelOverlayBaseOptions, +} from "./areaLabelVisualizer.types"; const POLYGON_PREVIEW_PADDING_PX = 6; const POLYGON_STRIPE_SIZE_PX = 6; @@ -56,19 +64,18 @@ const isFiniteScreenPoint = ( ): point is CssPixelPosition => Boolean(point) && Number.isFinite(point.x) && Number.isFinite(point.y); -const getPolygonStripeColor = ( - surfaceType: PlanarPolygonGroup["surfaceType"] -): string => { - if (surfaceType === "facade") return "rgba(111, 168, 255, 0.35)"; - if (surfaceType === "terrain") return "rgba(107, 188, 123, 0.35)"; - if (surfaceType === "footprint") return "rgba(226, 232, 240, 0.35)"; - return "rgba(239, 223, 145, 0.35)"; // roof +const getPolygonStripeColor = (type: NodeChainAnnotation["type"]): string => { + if (type === ANNOTATION_TYPE_AREA_VERTICAL) + return "rgba(111, 168, 255, 0.35)"; + if (type === ANNOTATION_TYPE_AREA_GROUND) return "rgba(107, 188, 123, 0.35)"; + if (type === ANNOTATION_TYPE_AREA_PLANAR) return "rgba(239, 223, 145, 0.35)"; + return "rgba(239, 223, 145, 0.35)"; }; -const buildPolygonPreviewContent = (group: PlanarPolygonGroup) => { +const buildPolygonPreviewContent = (group: NodeChainAnnotation) => { const patternId = `stripe-${group.id}`; - const stripeColor = getPolygonStripeColor(group.surfaceType); - const isFootprintSurface = (group.surfaceType ?? "roof") === "footprint"; + const stripeColor = getPolygonStripeColor(group.type); + const isGroundSurface = group.type === ANNOTATION_TYPE_AREA_GROUND; return createElement( "div", @@ -125,7 +132,7 @@ const buildPolygonPreviewContent = (group: PlanarPolygonGroup) => { }), createElement("polygon", { "data-polygon-preview-stripe": "true", - fill: isFootprintSurface ? "none" : `url(#${patternId})`, + fill: isGroundSurface ? "none" : `url(#${patternId})`, stroke: "none", style: { pointerEvents: "none", @@ -200,14 +207,19 @@ const createEmptyPolygonAreaLabelLayoutResult = const toMatrixCacheKey = (matrix: readonly number[]) => matrix.map((value) => value.toFixed(6)).join(","); -export const useAreaLabelVisualizerBase = ({ - overlayPrefix, - viewProjector, - polygonPreviewGroups, - focusedPolygonGroupId, - polygonAreaBadgeByGroupId, - resolveAreaLabelText, -}: PolygonAreaLabelOverlayBaseOptions) => { +export const useAreaLabelVisualizerBase = ( + viewProjector: AreaLabelViewProjector, + polygonPreviewGroups: readonly { + group: NodeChainAnnotation; + vertexPoints: import("@carma/cesium").Cartesian3Json[]; + }[], + { + overlayPrefix, + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + resolveAreaLabelText, + }: PolygonAreaLabelOverlayBaseOptions +) => { const { addLabelOverlayElement, removeLabelOverlayElement } = useLabelOverlay(); const overlayIdsRef = useRef([]); @@ -224,7 +236,7 @@ export const useAreaLabelVisualizerBase = ({ ); const polygonPreviewContent = useCallback( - (group: PlanarPolygonGroup) => buildPolygonPreviewContent(group), + (group: NodeChainAnnotation) => buildPolygonPreviewContent(group), [] ); const computeAreaLabelLayoutResult = useCallback(() => { diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useGroundAreaLabelVisualizer.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useGroundAreaLabelVisualizer.ts new file mode 100644 index 0000000000..55d9cb336b --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useGroundAreaLabelVisualizer.ts @@ -0,0 +1,22 @@ +import { buildGroundAreaLabelText } from "@carma-mapping/annotations/core"; +import { type GroundAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; +import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; +import type { AreaLabelViewProjector } from "./areaLabelVisualizer.types"; + +const GROUND_AREA_OVERLAY_PREFIX = "distance-ground-polygon-preview"; + +export const useGroundAreaLabelVisualizer = ( + viewProjector: AreaLabelViewProjector, + groundPolygonPreviewGroups: readonly import("@carma-mapping/annotations/core").PolygonPreviewGroup[], + { + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + }: GroundAreaLabelVisualizerOptions +) => { + useAreaLabelVisualizerBase(viewProjector, groundPolygonPreviewGroups, { + overlayPrefix: GROUND_AREA_OVERLAY_PREFIX, + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + resolveAreaLabelText: buildGroundAreaLabelText, + }); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/usePlanarAreaLabelVisualizer.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/usePlanarAreaLabelVisualizer.ts new file mode 100644 index 0000000000..ca3b399d05 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/usePlanarAreaLabelVisualizer.ts @@ -0,0 +1,22 @@ +import { buildPlanarAreaLabelText } from "@carma-mapping/annotations/core"; +import { type PlanarAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; +import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; +import type { AreaLabelViewProjector } from "./areaLabelVisualizer.types"; + +const PLANAR_AREA_OVERLAY_PREFIX = "distance-planar-polygon-preview"; + +export const usePlanarAreaLabelVisualizer = ( + viewProjector: AreaLabelViewProjector, + planarPolygonPreviewGroups: readonly import("@carma-mapping/annotations/core").PolygonPreviewGroup[], + { + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + }: PlanarAreaLabelVisualizerOptions +) => { + useAreaLabelVisualizerBase(viewProjector, planarPolygonPreviewGroups, { + overlayPrefix: PLANAR_AREA_OVERLAY_PREFIX, + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + resolveAreaLabelText: buildPlanarAreaLabelText, + }); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useVerticalAreaLabelVisualizer.ts b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useVerticalAreaLabelVisualizer.ts new file mode 100644 index 0000000000..46a40b9cde --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/area/labels/useVerticalAreaLabelVisualizer.ts @@ -0,0 +1,22 @@ +import { buildVerticalAreaLabelText } from "@carma-mapping/annotations/core"; +import { type VerticalAreaLabelVisualizerOptions } from "./areaLabelVisualizer.types"; +import { useAreaLabelVisualizerBase } from "./useAreaLabelVisualizerBase"; +import type { AreaLabelViewProjector } from "./areaLabelVisualizer.types"; + +const VERTICAL_AREA_OVERLAY_PREFIX = "distance-vertical-polygon-preview"; + +export const useVerticalAreaLabelVisualizer = ( + viewProjector: AreaLabelViewProjector, + verticalPolygonPreviewGroups: readonly import("@carma-mapping/annotations/core").PolygonPreviewGroup[], + { + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + }: VerticalAreaLabelVisualizerOptions +) => { + useAreaLabelVisualizerBase(viewProjector, verticalPolygonPreviewGroups, { + overlayPrefix: VERTICAL_AREA_OVERLAY_PREFIX, + focusedPolygonGroupId, + polygonAreaBadgeByGroupId, + resolveAreaLabelText: buildVerticalAreaLabelText, + }); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/hooks/annotationVisualizationContext.ts b/libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeRelationRenderContext.ts similarity index 54% rename from libraries/mapping/annotations/provider/src/lib/context/hooks/annotationVisualizationContext.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeRelationRenderContext.ts index 8e7868ffeb..6bff1a7682 100644 --- a/libraries/mapping/annotations/provider/src/lib/context/hooks/annotationVisualizationContext.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeRelationRenderContext.ts @@ -2,73 +2,71 @@ import { Cartesian3 } from "@carma/cesium"; import { ANNOTATION_TYPE_AREA_VERTICAL, type DistanceRelationRenderContext, - getRoofSharedEdgeRelationIds, + getDistanceRelationId, + getPlanarSharedEdgeRelationIds, getSplitMarkerRelationIds, getSplitMarkerRelationIdsByKind, getSplitMarkerRelationIdsByKindForGroups, getSplitMarkerRelationIdsForGroups, - type PlanarPolygonGroup, + type NodeChainAnnotation, type PointAnnotationEntry, } from "@carma-mapping/annotations/core"; -const FACADE_OPPOSING_EDGE_LABEL_EPSILON_METERS = 0.01; +const VERTICAL_OPPOSING_EDGE_LABEL_EPSILON_METERS = 0.01; -const getDistanceRelationId = (pointAId: string, pointBId: string) => { - const [left, right] = [pointAId, pointBId].sort((a, b) => a.localeCompare(b)); - return `distance-relation:${left}:${right}`; -}; - -export const buildDistanceRelationRenderContext = ({ - planarPolygonGroups, - selectedPlanarPolygonGroupId, - activePlanarPolygonGroupId, +export const buildEdgeRelationRenderContext = ({ + nodeChainAnnotations, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, pointsById, }: { - planarPolygonGroups: PlanarPolygonGroup[]; - selectedPlanarPolygonGroupId?: string | null; - activePlanarPolygonGroupId?: string | null; + nodeChainAnnotations: readonly NodeChainAnnotation[]; + focusedNodeChainAnnotationId?: string | null; + activeNodeChainAnnotationId?: string | null; pointsById: ReadonlyMap; }): DistanceRelationRenderContext => { const editableLineRelationIdsByKind = - getSplitMarkerRelationIdsByKind(planarPolygonGroups); - const polygonEdgeRelationIds = getSplitMarkerRelationIds(planarPolygonGroups); + getSplitMarkerRelationIdsByKind(nodeChainAnnotations); + const polygonEdgeRelationIds = + getSplitMarkerRelationIds(nodeChainAnnotations); const planarPolygonSharedEdgeRelationIds = - getRoofSharedEdgeRelationIds(planarPolygonGroups); + getPlanarSharedEdgeRelationIds(nodeChainAnnotations); const activeOrSelectedGroupIds = new Set(); - if (selectedPlanarPolygonGroupId) { - activeOrSelectedGroupIds.add(selectedPlanarPolygonGroupId); + if (focusedNodeChainAnnotationId) { + activeOrSelectedGroupIds.add(focusedNodeChainAnnotationId); } - if (activePlanarPolygonGroupId) { - activeOrSelectedGroupIds.add(activePlanarPolygonGroupId); + if (activeNodeChainAnnotationId) { + activeOrSelectedGroupIds.add(activeNodeChainAnnotationId); } + const midpointTickRelationIds = getSplitMarkerRelationIdsForGroups( - planarPolygonGroups, + nodeChainAnnotations, activeOrSelectedGroupIds ); const selectedOrActiveEditableLineRelationIdsByKind = getSplitMarkerRelationIdsByKindForGroups( - planarPolygonGroups, + nodeChainAnnotations, activeOrSelectedGroupIds ); const focusedGroupId = - selectedPlanarPolygonGroupId ?? activePlanarPolygonGroupId ?? null; + focusedNodeChainAnnotationId ?? activeNodeChainAnnotationId ?? null; const focusedGroup = focusedGroupId - ? planarPolygonGroups.find((group) => group.id === focusedGroupId) ?? null + ? nodeChainAnnotations.find((group) => group.id === focusedGroupId) ?? null : null; const focusedRelationIds = new Set(focusedGroup?.edgeRelationIds ?? []); const selectedOrActiveOpenPolylineRelationIds = selectedOrActiveEditableLineRelationIdsByKind.polyline; - const duplicateFacadeOpposingRelationIds = new Set(); - planarPolygonGroups.forEach((group) => { + const duplicateVerticalOpposingRelationIds = new Set(); + nodeChainAnnotations.forEach((group) => { if (!group.closed) return; - if (group.measurementKind !== ANNOTATION_TYPE_AREA_VERTICAL) return; - if (group.vertexPointIds.length !== 4) return; + if (group.type !== ANNOTATION_TYPE_AREA_VERTICAL) return; + if (group.nodeIds.length !== 4) return; - const [point0Id, point1Id, point2Id, point3Id] = group.vertexPointIds; + const [point0Id, point1Id, point2Id, point3Id] = group.nodeIds; if (!point0Id || !point1Id || !point2Id || !point3Id) return; const point0 = pointsById.get(point0Id)?.geometryECEF; @@ -80,9 +78,10 @@ export const buildDistanceRelationRenderContext = ({ const length01 = Cartesian3.distance(point0, point1); const length23 = Cartesian3.distance(point2, point3); if ( - Math.abs(length01 - length23) <= FACADE_OPPOSING_EDGE_LABEL_EPSILON_METERS + Math.abs(length01 - length23) <= + VERTICAL_OPPOSING_EDGE_LABEL_EPSILON_METERS ) { - duplicateFacadeOpposingRelationIds.add( + duplicateVerticalOpposingRelationIds.add( getDistanceRelationId(point2Id, point3Id) ); } @@ -90,9 +89,10 @@ export const buildDistanceRelationRenderContext = ({ const length12 = Cartesian3.distance(point1, point2); const length30 = Cartesian3.distance(point3, point0); if ( - Math.abs(length12 - length30) <= FACADE_OPPOSING_EDGE_LABEL_EPSILON_METERS + Math.abs(length12 - length30) <= + VERTICAL_OPPOSING_EDGE_LABEL_EPSILON_METERS ) { - duplicateFacadeOpposingRelationIds.add( + duplicateVerticalOpposingRelationIds.add( getDistanceRelationId(point3Id, point0Id) ); } @@ -106,6 +106,6 @@ export const buildDistanceRelationRenderContext = ({ midpointTickRelationIds, focusedRelationIds, selectedOrActiveOpenPolylineRelationIds, - duplicateFacadeOpposingRelationIds, + duplicateVerticalOpposingRelationIds, }; }; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeSceneLineRenderModels.ts b/libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeSceneLineRenderModels.ts new file mode 100644 index 0000000000..97712661ea --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/edge/buildEdgeSceneLineRenderModels.ts @@ -0,0 +1,270 @@ +import { Cartesian3, getDegreesFromCartesian } from "@carma/cesium"; +import { + type CandidateConnectionPreview, + LINE_TYPE_CARTESIAN, + REFERENCE_LINE_EPSILON_METERS, + isDistanceRelationHorizontalLineVisible, + isDistanceRelationVerticalLineVisible, + resolveDistanceRelation, + type LineType, + type PointAnnotationEntry, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import type { EdgeSceneLineRenderModel } from "../../render/annotationVisualization.types"; + +const DEFAULT_LINE_TYPE: LineType = LINE_TYPE_CARTESIAN; + +const DEFAULT_EDGE_SCENE_LINE_STYLES = { + direct: { + stroke: "rgba(255, 255, 255, 1)", + strokeWidth: 1, + dashed: false, + lineType: DEFAULT_LINE_TYPE, + }, + vertical: { + stroke: "rgba(111, 168, 255, 0.96)", + strokeWidth: 1, + dashed: false, + lineType: DEFAULT_LINE_TYPE, + }, + horizontal: { + stroke: "rgba(188, 194, 102, 0.95)", + strokeWidth: 1, + dashed: false, + lineType: DEFAULT_LINE_TYPE, + }, +} as const; + +type EdgeSceneLineStyle = + (typeof DEFAULT_EDGE_SCENE_LINE_STYLES)[keyof typeof DEFAULT_EDGE_SCENE_LINE_STYLES]; + +export type EdgeSceneLineStyleOverrides = { + direct?: Partial; + vertical?: Partial; + horizontal?: Partial; +}; + +type BuildEdgeSceneLineRenderModelsParams = { + pointsById: ReadonlyMap; + distanceRelations: readonly PointDistanceRelation[]; + previewEdges?: readonly EdgeSceneLineRenderModel[]; + styles?: EdgeSceneLineStyleOverrides; +}; + +const buildAuxiliaryPoint = ( + anchorPointECEF: Cartesian3, + targetPointECEF: Cartesian3 +) => { + const anchorWGS84 = getDegreesFromCartesian(anchorPointECEF); + const targetWGS84 = getDegreesFromCartesian(targetPointECEF); + return Cartesian3.fromDegrees( + anchorWGS84.longitude, + anchorWGS84.latitude, + targetWGS84.altitude ?? 0 + ); +}; + +const applyStyle = ( + line: Omit< + EdgeSceneLineRenderModel, + "stroke" | "strokeWidth" | "dashed" | "lineType" + >, + style: EdgeSceneLineStyle +): EdgeSceneLineRenderModel => ({ + ...line, + stroke: style.stroke, + strokeWidth: style.strokeWidth, + dashed: style.dashed, + lineType: style.lineType, +}); + +export const buildEdgeSceneLineRenderModels = ({ + pointsById, + distanceRelations, + previewEdges = [], + styles, +}: BuildEdgeSceneLineRenderModelsParams): EdgeSceneLineRenderModel[] => { + const relationPointsById = new Map(pointsById); + const resolvedStyles = { + direct: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.direct, + ...(styles?.direct ?? {}), + }, + vertical: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.vertical, + ...(styles?.vertical ?? {}), + }, + horizontal: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.horizontal, + ...(styles?.horizontal ?? {}), + }, + }; + + const sceneLines: EdgeSceneLineRenderModel[] = []; + + distanceRelations + .map((relation) => resolveDistanceRelation(relation, relationPointsById)) + .filter(Boolean) + .forEach((resolvedRelation) => { + const { + relation, + pointA, + pointB, + anchorPoint, + targetPoint, + auxiliaryPoint, + } = resolvedRelation; + + if (relation.showDirectLine) { + sceneLines.push( + applyStyle( + { + id: `reference-line-${relation.id}`, + start: pointA.geometryECEF, + end: pointB.geometryECEF, + }, + resolvedStyles.direct + ) + ); + } + + if ( + isDistanceRelationVerticalLineVisible(relation) && + Cartesian3.distance(anchorPoint.geometryECEF, auxiliaryPoint) > + REFERENCE_LINE_EPSILON_METERS + ) { + sceneLines.push( + applyStyle( + { + id: `reference-vertical-line-${relation.id}`, + start: anchorPoint.geometryECEF, + end: auxiliaryPoint, + }, + resolvedStyles.vertical + ) + ); + } + + if ( + isDistanceRelationHorizontalLineVisible(relation) && + Cartesian3.distance(auxiliaryPoint, targetPoint.geometryECEF) > + REFERENCE_LINE_EPSILON_METERS + ) { + sceneLines.push( + applyStyle( + { + id: `reference-horizontal-line-${relation.id}`, + start: auxiliaryPoint, + end: targetPoint.geometryECEF, + }, + resolvedStyles.horizontal + ) + ); + } + }); + + previewEdges.forEach((edge) => { + sceneLines.push({ + ...edge, + lineType: edge.lineType ?? DEFAULT_LINE_TYPE, + }); + }); + + return sceneLines; +}; + +export const buildCandidatePreviewEdgeRenderModels = ({ + candidateConnection, + styles, +}: { + candidateConnection: CandidateConnectionPreview | null; + styles?: EdgeSceneLineStyleOverrides; +}): EdgeSceneLineRenderModel[] => { + if (!candidateConnection) { + return []; + } + + const resolvedStyles = { + direct: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.direct, + ...(styles?.direct ?? {}), + }, + vertical: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.vertical, + ...(styles?.vertical ?? {}), + }, + horizontal: { + ...DEFAULT_EDGE_SCENE_LINE_STYLES.horizontal, + ...(styles?.horizontal ?? {}), + }, + }; + + if ( + Cartesian3.distance( + candidateConnection.anchorPointECEF, + candidateConnection.targetPointECEF + ) <= REFERENCE_LINE_EPSILON_METERS + ) { + return []; + } + + const auxiliaryPointECEF = buildAuxiliaryPoint( + candidateConnection.anchorPointECEF, + candidateConnection.targetPointECEF + ); + const previewLines: EdgeSceneLineRenderModel[] = []; + + if (candidateConnection.showDirectLine) { + previewLines.push( + applyStyle( + { + id: "reference-preview-direct", + start: candidateConnection.anchorPointECEF, + end: candidateConnection.targetPointECEF, + }, + resolvedStyles.direct + ) + ); + } + + if ( + candidateConnection.showVerticalLine && + Cartesian3.distance( + candidateConnection.anchorPointECEF, + auxiliaryPointECEF + ) > REFERENCE_LINE_EPSILON_METERS + ) { + previewLines.push( + applyStyle( + { + id: "reference-preview-vertical", + start: candidateConnection.anchorPointECEF, + end: auxiliaryPointECEF, + }, + resolvedStyles.vertical + ) + ); + } + + if ( + candidateConnection.showHorizontalLine && + Cartesian3.distance( + auxiliaryPointECEF, + candidateConnection.targetPointECEF + ) > REFERENCE_LINE_EPSILON_METERS + ) { + previewLines.push( + applyStyle( + { + id: "reference-preview-horizontal", + start: auxiliaryPointECEF, + end: candidateConnection.targetPointECEF, + }, + resolvedStyles.horizontal + ) + ); + } + + return previewLines; +}; diff --git a/libraries/mapping/annotations/core/src/lib/distanceOverlayDom.tsx b/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/edgeOverlayDom.tsx similarity index 100% rename from libraries/mapping/annotations/core/src/lib/distanceOverlayDom.tsx rename to libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/edgeOverlayDom.tsx diff --git a/libraries/mapping/annotations/core/src/lib/useDistancePairLabelOverlays.tsx b/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useDistancePairLabelOverlays.tsx similarity index 95% rename from libraries/mapping/annotations/core/src/lib/useDistancePairLabelOverlays.tsx rename to libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useDistancePairLabelOverlays.tsx index 713109dd23..5089d1f133 100644 --- a/libraries/mapping/annotations/core/src/lib/useDistancePairLabelOverlays.tsx +++ b/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useDistancePairLabelOverlays.tsx @@ -52,7 +52,6 @@ export type DistancePairLabelObstacleEntry = { }; export type UseDistancePairLabelOverlaysOptions = { - entries: DistancePairLabelEntry[]; obstacles: DistancePairLabelObstacleEntry[]; cameraPitch: number; viewportWidth: number; @@ -66,18 +65,20 @@ export type UseDistancePairLabelOverlaysOptions = { zIndex?: number; }; -export const useDistancePairLabelOverlays = ({ - entries, - obstacles, - cameraPitch, - viewportWidth, - viewportHeight, - resolveAnchorCanvasPosition, - addLabelOverlayElement, - removeLabelOverlayElement, - overlayIdPrefix = DEFAULT_OVERLAY_ID_PREFIX, - zIndex = 18, -}: UseDistancePairLabelOverlaysOptions) => { +export const useDistancePairLabelOverlays = ( + entries: DistancePairLabelEntry[], + { + obstacles, + cameraPitch, + viewportWidth, + viewportHeight, + resolveAnchorCanvasPosition, + addLabelOverlayElement, + removeLabelOverlayElement, + overlayIdPrefix = DEFAULT_OVERLAY_ID_PREFIX, + zIndex = 18, + }: UseDistancePairLabelOverlaysOptions +) => { const overlayIdsRef = useRef([]); const pointLabelLayoutConfig = useMemo( () => resolvePointLabelLayoutConfig(), diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useEdgeComponentOverlayVisualizer.tsx b/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useEdgeComponentOverlayVisualizer.tsx new file mode 100644 index 0000000000..bd37d73ea1 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/edge/overlay/useEdgeComponentOverlayVisualizer.tsx @@ -0,0 +1,925 @@ +/* @refresh reset */ +import { + createElement, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from "react"; + +import { + BoundingSphere, + Cartesian3, + SceneTransforms, + defined, + getArcPointsInSpannedPlane, + type Scene, +} from "@carma/cesium"; +import { + buildDistanceTriangleInsidePoint2D, + buildOutsideReferencePoint2D, + buildDistanceRelationEdgeLabelOverlays, + buildVerticalDistanceLineScreenData, + buildVerticalLabelReferencePoint2D, + type DistanceScreenTriangle, + type PointAnnotationEntry, + type PointDistanceRelation, + type ReferenceLineLabelKind, + getCustomPointAnnotationName, + hasVisibleDistanceRelationComponentLines, + isDistanceRelationHorizontalLineVisible, + isDistanceRelationVerticalLineVisible, + REFERENCE_LINE_EPSILON_METERS, + resolveDistanceRelation, + type ResolvedDistanceRelation, + type DistanceRelationRenderContext, +} from "@carma-mapping/annotations/core"; +import type { AnnotationPointMarkerBadge } from "../../../render"; +import type { CssPixelPosition } from "@carma/units/types"; +import { + useLabelOverlay, + useLineVisualizers, + type LineVisualizerData, +} from "@carma-providers/label-overlay"; + +import { + applyMidpointMarkerOverlayLayout, + applyRightAngleCornerOverlayLayout, + MidpointMarkerOverlay, + RightAngleCornerOverlay, +} from "./edgeOverlayDom"; +import { useDistancePairLabelOverlays } from "./useDistancePairLabelOverlays"; +import type { EdgeSceneLineRenderModel } from "../../../render/annotationVisualization.types"; + +export type EdgeComponentOverlayVisualizerOptions = { + distanceRelations?: PointDistanceRelation[]; + onDistanceLineLabelToggle?: ( + relationId: string, + kind: ReferenceLineLabelKind + ) => void; + onDistanceLineClick?: ( + relationId: string, + kind: ReferenceLineLabelKind + ) => void; + onDistanceRelationMidpointClick?: (relationId: string) => void; + lineLabelMinDistancePx?: number; + onDistanceRelationCornerClick?: (relationId: string) => void; + cumulativeDistanceByRelationId?: Readonly>; + pointMarkerBadgeByPointId?: Readonly< + Record + >; + previewEdges?: readonly EdgeSceneLineRenderModel[]; + distanceRelationRenderContext: DistanceRelationRenderContext; + enabled?: boolean; +}; + +// EN component color: light mix of the standard East (red) and North (green) axis colors. +const REFERENCE_COMPONENT_HORIZONTAL_COLOR = "rgba(188, 194, 102, 0.95)"; +// U component color: lighter blue for better readability and a softer look. +const REFERENCE_COMPONENT_VERTICAL_COLOR = "rgba(111, 168, 255, 0.96)"; +const REFERENCE_COMPONENT_ARC_COLOR = "rgba(246, 248, 255, 0.95)"; +const REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX = 1.25; +const CORNER_OVERLAY_ID_PREFIX = "distance-right-angle-corner"; +const MIDPOINT_OVERLAY_ID_PREFIX = "distance-edge-midpoint"; +const CORNER_OVERLAY_MIN_BOX_PX = 20; +const CORNER_OVERLAY_PADDING_PX = 6; +const CORNER_OVERLAY_TARGET_RADIUS_PX = 20; +const CORNER_OVERLAY_DOT_RADIUS_PX = + REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX / 2; +const CORNER_OVERLAY_SEGMENTS = 20; +const MIDPOINT_MARKER_HIT_TARGET_PX = 14; +const MIDPOINT_MARKER_TICK_LENGTH_PX = 8; +const MIDPOINT_MARKER_TICK_WIDTH_PX = 1.25; +const LABEL_REFERENCE_MIN_DISTANCE_PX = 24; +const LABEL_REFERENCE_MAX_DISTANCE_PX = 48; +const LABEL_INSIDE_BLEND_FACTOR = 0.35; +const VERTICAL_LABEL_SIDE_SWITCH_THRESHOLD_PX = 4; + +export const useEdgeComponentOverlayVisualizer = ( + scene: Scene | null, + points: readonly PointAnnotationEntry[], + { + distanceRelations = [], + onDistanceLineLabelToggle, + onDistanceLineClick, + onDistanceRelationMidpointClick, + lineLabelMinDistancePx = 50, + onDistanceRelationCornerClick, + cumulativeDistanceByRelationId, + pointMarkerBadgeByPointId, + previewEdges = [], + distanceRelationRenderContext, + enabled = true, + }: EdgeComponentOverlayVisualizerOptions +) => { + const cornerOverlayIdsRef = useRef([]); + const midpointOverlayIdsRef = useRef([]); + const verticalLabelSideByRelationIdRef = useRef>({}); + const [cameraPitch, setCameraPitch] = useState(-Math.PI / 4); + + const { addLabelOverlayElement, removeLabelOverlayElement } = + useLabelOverlay(); + + const pointsById = useMemo(() => { + const map = new Map(); + points.forEach((point) => { + map.set(point.id, point); + }); + return map; + }, [points]); + const enclosedPointLabelById = useMemo(() => { + const labelById: Record = {}; + points.forEach((point, index) => { + labelById[point.id] = + getCustomPointAnnotationName(point.name) ?? + pointMarkerBadgeByPointId?.[point.id]?.text ?? + `${index + 1}`; + }); + return labelById; + }, [pointMarkerBadgeByPointId, points]); + const defaultPointLabelById = useMemo(() => { + const labelById: Record = {}; + points.forEach((point, index) => { + labelById[point.id] = + pointMarkerBadgeByPointId?.[point.id]?.text ?? `${index + 1}`; + }); + return labelById; + }, [pointMarkerBadgeByPointId, points]); + + const splitMarkerRelationIdSet = + distanceRelationRenderContext.polygonEdgeRelationIds; + const planarPolygonSharedEdgeRelationIdSet = + distanceRelationRenderContext.planarPolygonSharedEdgeRelationIds; + const midpointTickRelationIdSet = + distanceRelationRenderContext.midpointTickRelationIds; + + useEffect(() => { + if (!scene || scene.isDestroyed()) return; + const camera = scene.camera; + + const updatePitch = () => { + const nextPitch = camera.pitch; + setCameraPitch((prev) => + Math.abs(nextPitch - prev) > 0.001 ? nextPitch : prev + ); + }; + + updatePitch(); + const removeChangedListener = camera.changed.addEventListener(updatePitch); + const removeMoveEndListener = camera.moveEnd.addEventListener(updatePitch); + + return () => { + removeChangedListener?.(); + removeMoveEndListener?.(); + }; + }, [scene]); + + const edgeRelationOwnerGroupIdSet = + distanceRelationRenderContext.focusedRelationIds; + const selectedOrActiveOpenPolylineEdgeRelationIdSet = + distanceRelationRenderContext.selectedOrActiveOpenPolylineRelationIds; + const duplicateVerticalOpposingEdgeRelationIdSet = + distanceRelationRenderContext.duplicateVerticalOpposingRelationIds; + + const resolvedRelations = useMemo( + () => + distanceRelations + .map((relation) => resolveDistanceRelation(relation, pointsById)) + .filter((relation): relation is ResolvedDistanceRelation => + Boolean(relation) + ), + [distanceRelations, pointsById] + ); + + useEffect(() => { + const activeRelationIdSet = new Set( + resolvedRelations.map(({ relation }) => relation.id) + ); + Object.keys(verticalLabelSideByRelationIdRef.current).forEach( + (relationId) => { + if (!activeRelationIdSet.has(relationId)) { + delete verticalLabelSideByRelationIdRef.current[relationId]; + } + } + ); + }, [resolvedRelations]); + + const distancePairLabelEntries = useMemo( + () => + resolvedRelations + .filter( + ({ relation }) => + relation.showDirectLine && + !splitMarkerRelationIdSet.has(relation.id) + ) + .map(({ relation, pointA, pointB }) => { + const higherPoint = + pointA.geometryWGS84.altitude >= pointB.geometryWGS84.altitude + ? pointA + : pointB; + const lowerPoint = higherPoint.id === pointA.id ? pointB : pointA; + const higherLabel = defaultPointLabelById[higherPoint.id]; + const lowerLabel = defaultPointLabelById[lowerPoint.id]; + if (!higherLabel || !lowerLabel) return null; + if (higherLabel === lowerLabel) { + // Avoid duplicate compact badges like "C" + "C" for the same + // standalone distance component. + return null; + } + + return { + relationId: relation.id, + anchorPointId: higherPoint.id, + text: `${higherLabel} ↔ ${lowerLabel}`, + hasCompanionPointLabel: true, + }; + }) + .filter( + ( + entry + ): entry is { + relationId: string; + anchorPointId: string; + text: string; + hasCompanionPointLabel: boolean; + } => Boolean(entry) + ), + [defaultPointLabelById, resolvedRelations, splitMarkerRelationIdSet] + ); + + const distancePairLabelObstacles = useMemo( + () => + points.map((point) => ({ + id: `point-label-obstacle-${point.id}`, + anchorPointId: point.id, + text: enclosedPointLabelById[point.id] ?? "", + })), + [enclosedPointLabelById, points] + ); + + const resolvePointCanvasPositionById = useCallback( + (pointId: string) => { + if (!scene || scene.isDestroyed()) return null; + const point = pointsById.get(pointId); + if (!point) return null; + const anchor = SceneTransforms.worldToWindowCoordinates( + scene, + point.geometryECEF + ); + if (!defined(anchor)) return null; + return { x: anchor.x, y: anchor.y } as CssPixelPosition; + }, + [pointsById, scene] + ); + + const viewportWidth = Math.max( + 1, + scene?.canvas.clientWidth || scene?.canvas.width || 1 + ); + const viewportHeight = Math.max( + 1, + scene?.canvas.clientHeight || scene?.canvas.height || 1 + ); + + useDistancePairLabelOverlays(enabled ? distancePairLabelEntries : [], { + obstacles: enabled ? distancePairLabelObstacles : [], + cameraPitch, + viewportWidth, + viewportHeight, + resolveAnchorCanvasPosition: resolvePointCanvasPositionById, + addLabelOverlayElement, + removeLabelOverlayElement, + }); + + const overlayLines = useMemo(() => { + if (!enabled) { + return []; + } + if (!scene || scene.isDestroyed()) { + return []; + } + + const lines: LineVisualizerData[] = []; + + resolvedRelations.forEach( + ({ + relation, + pointA, + pointB, + anchorPoint, + targetPoint, + auxiliaryPoint, + }) => { + const getWorldToScreen = ( + position: Cartesian3 + ): CssPixelPosition | null => { + if (!scene || scene.isDestroyed()) return null; + const p = SceneTransforms.worldToWindowCoordinates(scene, position); + return defined(p) ? ({ x: p.x, y: p.y } as CssPixelPosition) : null; + }; + const highestPoint = + pointA.geometryWGS84.altitude >= pointB.geometryWGS84.altitude + ? pointA + : pointB; + + let cachedTriangleFrameNumber: number | null = null; + let cachedTriangle: DistanceScreenTriangle | null = null; + + const getSceneFrameNumber = (): number | null => { + const frameNumber = ( + scene as unknown as { frameState?: { frameNumber?: number } } + ).frameState?.frameNumber; + return typeof frameNumber === "number" ? frameNumber : null; + }; + + const computeScreenTriangle = (): DistanceScreenTriangle | null => { + const anchor = getWorldToScreen(anchorPoint.geometryECEF); + const target = getWorldToScreen(targetPoint.geometryECEF); + const aux = getWorldToScreen(auxiliaryPoint); + const highest = getWorldToScreen(highestPoint.geometryECEF); + if (!anchor || !target || !aux || !highest) return null; + return { + anchor, + target, + aux, + highest, + centroid: { + x: (anchor.x + target.x + aux.x) / 3, + y: (anchor.y + target.y + aux.y) / 3, + } as CssPixelPosition, + }; + }; + + const getScreenTriangle = (): DistanceScreenTriangle | null => { + const frameNumber = getSceneFrameNumber(); + if ( + frameNumber !== null && + frameNumber === cachedTriangleFrameNumber + ) { + return cachedTriangle; + } + + const triangle = computeScreenTriangle(); + if (frameNumber !== null) { + cachedTriangleFrameNumber = frameNumber; + cachedTriangle = triangle; + } + return triangle; + }; + + const getScreenAnchor = (): CssPixelPosition | null => + getScreenTriangle()?.anchor ?? null; + const getScreenTarget = (): CssPixelPosition | null => + getScreenTriangle()?.target ?? null; + const getScreenAux = (): CssPixelPosition | null => + getScreenTriangle()?.aux ?? null; + + const getStableInsidePointForDirectAndHorizontal = + (): CssPixelPosition | null => { + const triangle = getScreenTriangle(); + if (!triangle) return null; + return buildDistanceTriangleInsidePoint2D({ + triangle, + auxiliaryAltitudeMeters: targetPoint.geometryWGS84.altitude, + highestAltitudeMeters: highestPoint.geometryWGS84.altitude, + insideBlendFactor: LABEL_INSIDE_BLEND_FACTOR, + elevationEpsilonMeters: REFERENCE_LINE_EPSILON_METERS, + }); + }; + + const getDirectLabelOutsideReferencePoint = + (): CssPixelPosition | null => { + const triangle = getScreenTriangle(); + const insidePoint = getStableInsidePointForDirectAndHorizontal(); + if (!triangle || !insidePoint) return null; + return buildOutsideReferencePoint2D( + triangle.anchor, + triangle.target, + insidePoint, + LABEL_REFERENCE_MIN_DISTANCE_PX, + LABEL_REFERENCE_MAX_DISTANCE_PX + ); + }; + + const getHorizontalLabelOutsideReferencePoint = + (): CssPixelPosition | null => { + const triangle = getScreenTriangle(); + const insidePoint = getStableInsidePointForDirectAndHorizontal(); + if (!triangle || !insidePoint) return null; + return buildOutsideReferencePoint2D( + triangle.aux, + triangle.target, + insidePoint, + LABEL_REFERENCE_MIN_DISTANCE_PX, + LABEL_REFERENCE_MAX_DISTANCE_PX + ); + }; + + const getVerticalLineScreenData = (): { + start: CssPixelPosition; + end: CssPixelPosition; + insideSign: -1 | 1; + midX: number; + midY: number; + normalX: number; + normalY: number; + lineLength: number; + } | null => { + const triangle = getScreenTriangle(); + if (!triangle) return null; + const edgeData = buildVerticalDistanceLineScreenData({ + triangle, + previousInsideSign: + verticalLabelSideByRelationIdRef.current[relation.id], + flipThresholdPx: VERTICAL_LABEL_SIDE_SWITCH_THRESHOLD_PX, + }); + if (!edgeData) return null; + verticalLabelSideByRelationIdRef.current[relation.id] = + edgeData.insideSign; + return edgeData; + }; + + const getVerticalLabelOutsideReferencePoint = + (): CssPixelPosition | null => { + const edge = getVerticalLineScreenData(); + if (!edge) return null; + return buildVerticalLabelReferencePoint2D( + edge, + LABEL_REFERENCE_MIN_DISTANCE_PX, + LABEL_REFERENCE_MAX_DISTANCE_PX + ); + }; + + const verticalDistanceMeters = Cartesian3.distance( + anchorPoint.geometryECEF, + auxiliaryPoint + ); + const horizontalDistanceMeters = Cartesian3.distance( + auxiliaryPoint, + targetPoint.geometryECEF + ); + const isPolygonEdgeRelation = splitMarkerRelationIdSet.has(relation.id); + const isSelectedOrActiveEdgeRelation = + edgeRelationOwnerGroupIdSet.has(relation.id) || + selectedOrActiveOpenPolylineEdgeRelationIdSet.has(relation.id); + const segmentDistanceMeters = Cartesian3.distance( + pointA.geometryECEF, + pointB.geometryECEF + ); + const edgeLabelOverlays = buildDistanceRelationEdgeLabelOverlays({ + relation, + segmentDistanceMeters, + cumulativeDistanceMeters: + cumulativeDistanceByRelationId?.[relation.id] ?? + segmentDistanceMeters, + verticalDistanceMeters, + horizontalDistanceMeters, + lineLabelMinDistancePx, + isPolygonEdgeRelation, + isSelectedOrActiveEdgeRelation, + isSharedPlanarPolygonEdge: planarPolygonSharedEdgeRelationIdSet.has( + relation.id + ), + isDuplicateVerticalOpposingEdgeRelation: + duplicateVerticalOpposingEdgeRelationIdSet.has(relation.id), + }); + + if (relation.showDirectLine) { + const onDirectLineClick = onDistanceLineClick + ? () => onDistanceLineClick(relation.id, "direct") + : undefined; + const onDirectLabelClick = onDistanceLineLabelToggle + ? () => onDistanceLineLabelToggle(relation.id, "direct") + : undefined; + lines.push({ + id: `reference-direct-${relation.id}`, + getCanvasLine: () => { + const start = getScreenAnchor(); + const end = getScreenTarget(); + if (!start || !end) return null; + return { start, end }; + }, + getLabelOutsideReferencePoint: getDirectLabelOutsideReferencePoint, + stroke: "rgba(255, 255, 255, 0.9)", + strokeWidth: 1.5, + strokeDasharray: "6 8", + hitTargetStrokeWidth: 10, + ...edgeLabelOverlays.direct, + onLineClick: onDirectLineClick, + onLabelClick: onDirectLabelClick, + }); + } + + if (isDistanceRelationVerticalLineVisible(relation)) { + const onVerticalLineClick = onDistanceLineLabelToggle + ? () => onDistanceLineLabelToggle(relation.id, "vertical") + : undefined; + lines.push({ + id: `reference-vertical-${relation.id}`, + getCanvasLine: () => { + const edge = getVerticalLineScreenData(); + if (!edge) return null; + return { start: edge.start, end: edge.end }; + }, + getLabelOutsideReferencePoint: + getVerticalLabelOutsideReferencePoint, + stroke: REFERENCE_COMPONENT_VERTICAL_COLOR, + strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, + strokeDasharray: "6 8", + hitTargetStrokeWidth: 10, + ...edgeLabelOverlays.vertical, + onLineClick: onVerticalLineClick, + }); + } + + if (isDistanceRelationHorizontalLineVisible(relation)) { + const onHorizontalLineClick = onDistanceLineLabelToggle + ? () => onDistanceLineLabelToggle(relation.id, "horizontal") + : undefined; + lines.push({ + id: `reference-horizontal-${relation.id}`, + getCanvasLine: () => { + const start = getScreenAux(); + const end = getScreenTarget(); + if (!start || !end) return null; + return { start, end }; + }, + getLabelOutsideReferencePoint: + getHorizontalLabelOutsideReferencePoint, + stroke: REFERENCE_COMPONENT_HORIZONTAL_COLOR, + strokeWidth: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, + strokeDasharray: "6 8", + hitTargetStrokeWidth: 10, + ...edgeLabelOverlays.horizontal, + onLineClick: onHorizontalLineClick, + }); + } + } + ); + + previewEdges.forEach((edge) => { + lines.push({ + id: `preview-edge-${edge.id}`, + getCanvasLine: () => { + if (!scene || scene.isDestroyed()) return null; + const start = SceneTransforms.worldToWindowCoordinates( + scene, + edge.start + ); + const end = SceneTransforms.worldToWindowCoordinates(scene, edge.end); + if (!defined(start) || !defined(end)) return null; + return { + start: { x: start.x, y: start.y } as CssPixelPosition, + end: { x: end.x, y: end.y } as CssPixelPosition, + }; + }, + stroke: edge.stroke, + strokeWidth: edge.strokeWidth, + strokeDasharray: edge.dashed ? "6 8" : undefined, + hitTargetStrokeWidth: 10, + }); + }); + + return lines; + }, [ + lineLabelMinDistancePx, + onDistanceLineLabelToggle, + onDistanceLineClick, + cumulativeDistanceByRelationId, + planarPolygonSharedEdgeRelationIdSet, + duplicateVerticalOpposingEdgeRelationIdSet, + previewEdges, + resolvedRelations, + scene, + edgeRelationOwnerGroupIdSet, + selectedOrActiveOpenPolylineEdgeRelationIdSet, + splitMarkerRelationIdSet, + enabled, + ]); + + useLineVisualizers(overlayLines, enabled && overlayLines.length > 0); + + const rightAngleCornerContent = useMemo( + () => + createElement(RightAngleCornerOverlay, { + strokeColor: REFERENCE_COMPONENT_ARC_COLOR, + strokeWidthPx: REFERENCE_COMPONENT_LINE_STROKE_WIDTH_PX, + dotRadiusPx: CORNER_OVERLAY_DOT_RADIUS_PX, + }), + [] + ); + + useEffect(() => { + cornerOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + cornerOverlayIdsRef.current = []; + + if (!enabled) { + return; + } + + if (!scene || scene.isDestroyed()) { + return; + } + + const nextCornerOverlayIds: string[] = []; + + resolvedRelations + .filter(({ relation }) => + hasVisibleDistanceRelationComponentLines(relation) + ) + .forEach(({ relation, anchorPoint, targetPoint, auxiliaryPoint }) => { + const overlayId = `${CORNER_OVERLAY_ID_PREFIX}-${relation.id}`; + + addLabelOverlayElement({ + id: overlayId, + content: rightAngleCornerContent, + onClick: onDistanceRelationCornerClick + ? () => onDistanceRelationCornerClick(relation.id) + : undefined, + updatePosition: (elementDiv) => { + if (!scene || scene.isDestroyed()) return false; + + const auxiliaryPointScreen = + SceneTransforms.worldToWindowCoordinates(scene, auxiliaryPoint); + const verticalPointScreen = + SceneTransforms.worldToWindowCoordinates( + scene, + anchorPoint.geometryECEF + ); + const horizontalPointScreen = + SceneTransforms.worldToWindowCoordinates( + scene, + targetPoint.geometryECEF + ); + + if ( + !defined(auxiliaryPointScreen) || + !defined(verticalPointScreen) || + !defined(horizontalPointScreen) + ) { + return false; + } + + const verticalLengthMeters = Cartesian3.distance( + anchorPoint.geometryECEF, + auxiliaryPoint + ); + const horizontalLengthMeters = Cartesian3.distance( + auxiliaryPoint, + targetPoint.geometryECEF + ); + if ( + verticalLengthMeters <= REFERENCE_LINE_EPSILON_METERS || + horizontalLengthMeters <= REFERENCE_LINE_EPSILON_METERS + ) { + return false; + } + + const drawingBufferWidth = scene.drawingBufferWidth; + const drawingBufferHeight = scene.drawingBufferHeight; + if (drawingBufferWidth <= 0 || drawingBufferHeight <= 0) { + return false; + } + + let metersPerPixel = Number.NaN; + try { + metersPerPixel = scene.camera.getPixelSize( + new BoundingSphere(auxiliaryPoint, 1), + drawingBufferWidth, + drawingBufferHeight + ); + } catch { + metersPerPixel = Number.NaN; + } + + if (!Number.isFinite(metersPerPixel) || metersPerPixel <= 0) { + const cameraDistanceMeters = Math.max( + Cartesian3.distance(scene.camera.position, auxiliaryPoint), + 1 + ); + const fovRad = + (scene.camera.frustum as { fov?: number }).fov ?? Math.PI / 3; + metersPerPixel = Math.max( + (cameraDistanceMeters * Math.tan(fovRad / 2) * 2) / + Math.max(drawingBufferHeight, 1), + 1e-6 + ); + } + + const arcRadiusPx = CORNER_OVERLAY_TARGET_RADIUS_PX; + const arcRadiusMeters = arcRadiusPx * metersPerPixel; + + const arcPointsWorld = getArcPointsInSpannedPlane( + auxiliaryPoint, + anchorPoint.geometryECEF, + targetPoint.geometryECEF, + arcRadiusMeters, + CORNER_OVERLAY_SEGMENTS + ); + if (!arcPointsWorld || arcPointsWorld.length < 2) { + return false; + } + + const arcMidpointWorld = + arcPointsWorld[Math.floor(arcPointsWorld.length / 2)]; + if (!arcMidpointWorld) return false; + const dotWorld = Cartesian3.midpoint( + auxiliaryPoint, + arcMidpointWorld, + new Cartesian3() + ); + const dotScreen = SceneTransforms.worldToWindowCoordinates( + scene, + dotWorld + ); + if (!defined(dotScreen)) return false; + + const arcPointsScreen = arcPointsWorld + .map((worldPoint) => + SceneTransforms.worldToWindowCoordinates(scene, worldPoint) + ) + .filter(defined); + if (arcPointsScreen.length < 2) { + return false; + } + + const minX = Math.min(...arcPointsScreen.map((point) => point.x)); + const maxX = Math.max(...arcPointsScreen.map((point) => point.x)); + const minY = Math.min(...arcPointsScreen.map((point) => point.y)); + const maxY = Math.max(...arcPointsScreen.map((point) => point.y)); + const width = Math.max( + CORNER_OVERLAY_MIN_BOX_PX, + maxX - minX + CORNER_OVERLAY_PADDING_PX * 2 + ); + const height = Math.max( + CORNER_OVERLAY_MIN_BOX_PX, + maxY - minY + CORNER_OVERLAY_PADDING_PX * 2 + ); + + const pathData = arcPointsScreen + .map((point, index) => { + const x = point.x - minX + CORNER_OVERLAY_PADDING_PX; + const y = point.y - minY + CORNER_OVERLAY_PADDING_PX; + return `${index === 0 ? "M" : "L"} ${x} ${y}`; + }) + .join(" "); + const isCornerClickable = Boolean(onDistanceRelationCornerClick); + + applyRightAngleCornerOverlayLayout({ + elementDiv, + pathData, + dotScreen: { + x: dotScreen.x, + y: dotScreen.y, + } as CssPixelPosition, + minX, + minY, + width, + height, + paddingPx: CORNER_OVERLAY_PADDING_PX, + clickable: isCornerClickable, + }); + + return true; + }, + }); + + nextCornerOverlayIds.push(overlayId); + }); + + cornerOverlayIdsRef.current = nextCornerOverlayIds; + + return () => { + nextCornerOverlayIds.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + cornerOverlayIdsRef.current = []; + }; + }, [ + addLabelOverlayElement, + onDistanceRelationCornerClick, + removeLabelOverlayElement, + resolvedRelations, + rightAngleCornerContent, + enabled, + scene, + ]); + + const midpointMarkerContent = useMemo( + () => + createElement(MidpointMarkerOverlay, { + tickLengthPx: MIDPOINT_MARKER_TICK_LENGTH_PX, + tickWidthPx: MIDPOINT_MARKER_TICK_WIDTH_PX, + tickColor: "rgba(255, 255, 255, 0.95)", + }), + [] + ); + + useEffect(() => { + midpointOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + midpointOverlayIdsRef.current = []; + + if (!enabled) { + return; + } + + if (!scene || scene.isDestroyed()) { + return; + } + + const nextMidpointOverlayIds: string[] = []; + + resolvedRelations + .filter(({ relation }) => { + if (midpointTickRelationIdSet.size === 0) { + return false; + } + if (!relation.showDirectLine) return false; + return midpointTickRelationIdSet.has(relation.id); + }) + .forEach(({ relation, pointA, pointB }) => { + const overlayId = `${MIDPOINT_OVERLAY_ID_PREFIX}-${relation.id}`; + addLabelOverlayElement({ + id: overlayId, + zIndex: 11, + content: midpointMarkerContent, + onClick: onDistanceRelationMidpointClick + ? () => onDistanceRelationMidpointClick(relation.id) + : undefined, + updatePosition: (elementDiv) => { + if (!scene || scene.isDestroyed()) return false; + const start = SceneTransforms.worldToWindowCoordinates( + scene, + pointA.geometryECEF + ); + const end = SceneTransforms.worldToWindowCoordinates( + scene, + pointB.geometryECEF + ); + if (!defined(start) || !defined(end)) return false; + + const midpointWorld = Cartesian3.midpoint( + pointA.geometryECEF, + pointB.geometryECEF, + new Cartesian3() + ); + const center = SceneTransforms.worldToWindowCoordinates( + scene, + midpointWorld + ); + if (!defined(center)) return false; + const angleDeg = + (Math.atan2(end.y - start.y, end.x - start.x) * 180) / Math.PI + + 90; + applyMidpointMarkerOverlayLayout({ + elementDiv, + center: { x: center.x, y: center.y } as CssPixelPosition, + angleDeg, + hitTargetPx: MIDPOINT_MARKER_HIT_TARGET_PX, + clickable: Boolean(onDistanceRelationMidpointClick), + }); + return true; + }, + }); + nextMidpointOverlayIds.push(overlayId); + }); + + midpointOverlayIdsRef.current = nextMidpointOverlayIds; + + return () => { + nextMidpointOverlayIds.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + midpointOverlayIdsRef.current = []; + }; + }, [ + addLabelOverlayElement, + midpointMarkerContent, + onDistanceRelationMidpointClick, + removeLabelOverlayElement, + resolvedRelations, + enabled, + scene, + midpointTickRelationIdSet, + ]); + + useEffect(() => { + return () => { + cornerOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + cornerOverlayIdsRef.current = []; + midpointOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + midpointOverlayIdsRef.current = []; + }; + }, [removeLabelOverlayElement]); +}; + +export default useEdgeComponentOverlayVisualizer; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/edge/useDistanceRelationInteractions.ts b/libraries/mapping/annotations/provider/src/lib/context/render/edge/useDistanceRelationInteractions.ts new file mode 100644 index 0000000000..908f383365 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/edge/useDistanceRelationInteractions.ts @@ -0,0 +1,190 @@ +import { useCallback } from "react"; + +import { + getConnectedOpenPolylineGroupIds, + getNextDirectLineLabelMode, + type DirectLineLabelMode, + type NodeChainAnnotation, + type PointDistanceRelation, + type ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +type UseDistanceRelationInteractionsParams = { + activeNodeChainAnnotationId: string | null; + focusedNodeChainAnnotationId: string | null; + nodeChainAnnotations: readonly NodeChainAnnotation[]; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; + defaultDirectLineLabelMode: DirectLineLabelMode; + setDistanceRelations: React.Dispatch< + React.SetStateAction + >; + selectRepresentativeNodeForMeasurementId: (id: string | null) => void; + getOwnerGroupIdsForEdgeRelationId: ( + relationId: string | null | undefined + ) => readonly string[]; +}; + +export const useDistanceRelationInteractions = ({ + activeNodeChainAnnotationId, + focusedNodeChainAnnotationId, + nodeChainAnnotations, + defaultDistanceRelationLabelVisibility, + defaultDirectLineLabelMode, + setDistanceRelations, + selectRepresentativeNodeForMeasurementId, + getOwnerGroupIdsForEdgeRelationId, +}: UseDistanceRelationInteractionsParams) => { + const toggleDistanceRelationLineLabelVisibility = useCallback( + (relationId: string, kind: ReferenceLineLabelKind) => { + if (!relationId) { + return; + } + + setDistanceRelations((previousRelations) => + previousRelations.map((relation) => { + if (relation.id !== relationId) { + return relation; + } + + const currentValue = + relation.labelVisibilityByKind?.[kind] ?? + defaultDistanceRelationLabelVisibility[kind]; + + return { + ...relation, + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + [kind]: !currentValue, + }, + }; + }) + ); + }, + [defaultDistanceRelationLabelVisibility, setDistanceRelations] + ); + + const handleDistanceRelationLineLabelToggle = useCallback( + (relationId: string, kind: ReferenceLineLabelKind) => { + if (!relationId) { + return; + } + + const ownerGroupIds = getOwnerGroupIdsForEdgeRelationId(relationId); + const focusedGroupOwnsRelation = + !!focusedNodeChainAnnotationId && + ownerGroupIds.includes(focusedNodeChainAnnotationId); + + if (ownerGroupIds.length > 0 && !focusedGroupOwnsRelation) { + const preferredOwnerGroupId = + (activeNodeChainAnnotationId && + ownerGroupIds.includes(activeNodeChainAnnotationId) + ? activeNodeChainAnnotationId + : ownerGroupIds[0]) ?? null; + selectRepresentativeNodeForMeasurementId(preferredOwnerGroupId); + return; + } + + if (kind === "direct" && focusedNodeChainAnnotationId) { + const connectedOpenGroupIds = getConnectedOpenPolylineGroupIds( + nodeChainAnnotations, + focusedNodeChainAnnotationId + ); + if (connectedOpenGroupIds.size > 0) { + const allRelationIds = new Set(); + nodeChainAnnotations.forEach((measurement) => { + if (!connectedOpenGroupIds.has(measurement.id)) { + return; + } + measurement.edgeRelationIds.forEach((edgeRelationId) => + allRelationIds.add(edgeRelationId) + ); + }); + + if (allRelationIds.size > 0) { + setDistanceRelations((previousRelations) => { + const currentMode: DirectLineLabelMode = + previousRelations.find((relation) => relation.id === relationId) + ?.directLabelMode ?? defaultDirectLineLabelMode; + const nextMode = getNextDirectLineLabelMode(currentMode); + + return previousRelations.map((relation) => { + if (!allRelationIds.has(relation.id)) { + return relation; + } + + return { + ...relation, + directLabelMode: nextMode, + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + direct: nextMode !== "none", + }, + }; + }); + }); + return; + } + } + } + + toggleDistanceRelationLineLabelVisibility(relationId, kind); + }, + [ + activeNodeChainAnnotationId, + defaultDirectLineLabelMode, + defaultDistanceRelationLabelVisibility, + focusedNodeChainAnnotationId, + getOwnerGroupIdsForEdgeRelationId, + nodeChainAnnotations, + selectRepresentativeNodeForMeasurementId, + setDistanceRelations, + toggleDistanceRelationLineLabelVisibility, + ] + ); + + const handleDistanceRelationLineClick = useCallback( + (relationId: string, kind: ReferenceLineLabelKind) => { + if (!relationId || kind !== "direct") { + return; + } + + const ownerGroupIds = getOwnerGroupIdsForEdgeRelationId(relationId); + const focusedGroupOwnsRelation = + !!focusedNodeChainAnnotationId && + ownerGroupIds.includes(focusedNodeChainAnnotationId); + + if (ownerGroupIds.length > 0) { + if (focusedGroupOwnsRelation) { + return; + } + + const preferredOwnerGroupId = + (activeNodeChainAnnotationId && + ownerGroupIds.includes(activeNodeChainAnnotationId) + ? activeNodeChainAnnotationId + : ownerGroupIds[0]) ?? null; + selectRepresentativeNodeForMeasurementId(preferredOwnerGroupId); + } + }, + [ + activeNodeChainAnnotationId, + focusedNodeChainAnnotationId, + getOwnerGroupIdsForEdgeRelationId, + selectRepresentativeNodeForMeasurementId, + ] + ); + + return { + handleDistanceRelationLineClick, + handleDistanceRelationLineLabelToggle, + }; +}; + +export type DistanceRelationInteractionsState = ReturnType< + typeof useDistanceRelationInteractions +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/index.ts b/libraries/mapping/annotations/provider/src/lib/context/render/index.ts new file mode 100644 index 0000000000..2726de9884 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/index.ts @@ -0,0 +1,5 @@ +export * from "./annotationBadgeTokens"; +export * from "./annotationVisualization.types"; +export * from "./useAnnotationPointMarkerBadges"; +export * from "./useAnnotationsVisualization"; +export * from "./usePointAnnotationIndex"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelAnchorState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelAnchorState.ts new file mode 100644 index 0000000000..07949380d0 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelAnchorState.ts @@ -0,0 +1,58 @@ +import { useMemo } from "react"; + +import { + buildDesiredPointLabelAnchorById, + formatNumber, + type DerivedPolylinePath, + type PointAnnotationEntry, +} from "@carma-mapping/annotations/core"; +import type { PointMarkerBadgeState } from "./usePointMarkerBadgeState"; +import type { StandaloneDistancePointState } from "./useStandaloneDistancePointState"; + +export const derivePointLabelAnchors = ( + pointEntries: readonly PointAnnotationEntry[], + polylines: readonly DerivedPolylinePath[], + focusedNodeChainAnnotationId: string | null, + pointMarkerBadgeByPointId: PointMarkerBadgeState["pointMarkerBadgeByPointId"], + standaloneDistancePointState: StandaloneDistancePointState +) => + buildDesiredPointLabelAnchorById({ + pointMeasurements: pointEntries, + polylines, + focusedNodeChainAnnotationId, + pointMarkerBadgeByPointId, + standaloneDistanceHighestPointIds: + standaloneDistancePointState.standaloneDistanceHighestPointIds, + unfocusedStandaloneDistanceNonHighestPointIds: + standaloneDistancePointState.unfocusedStandaloneDistanceNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds: + standaloneDistancePointState.focusedStandaloneDistanceNonHighestPointIds, + formatDistanceLabel: formatNumber, + }); + +export const usePointLabelAnchorState = ( + pointEntries: readonly PointAnnotationEntry[], + polylines: readonly DerivedPolylinePath[], + focusedNodeChainAnnotationId: string | null, + pointMarkerBadgeByPointId: PointMarkerBadgeState["pointMarkerBadgeByPointId"], + standaloneDistancePointState: StandaloneDistancePointState +) => + useMemo( + () => + derivePointLabelAnchors( + pointEntries, + polylines, + focusedNodeChainAnnotationId, + pointMarkerBadgeByPointId, + standaloneDistancePointState + ), + [ + focusedNodeChainAnnotationId, + pointEntries, + pointMarkerBadgeByPointId, + polylines, + standaloneDistancePointState.focusedStandaloneDistanceNonHighestPointIds, + standaloneDistancePointState.standaloneDistanceHighestPointIds, + standaloneDistancePointState.unfocusedStandaloneDistanceNonHighestPointIds, + ] + ); diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelVisibilityState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelVisibilityState.ts new file mode 100644 index 0000000000..c8b0531922 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointLabelVisibilityState.ts @@ -0,0 +1,36 @@ +import { useMemo } from "react"; + +import { + collectCollapsedPillPointIds, + collectLabelAnchorPointIdsWithForcedVisibility, + collectPointIdsWithoutSelfLabelAnchor, + type PointAnnotationEntry, +} from "@carma-mapping/annotations/core"; + +export const usePointLabelVisibilityState = ( + pointEntries: readonly PointAnnotationEntry[], + unselectedClosedAreaNodeIdSet: ReadonlySet +) => { + const collapsedPillPointIds = useMemo( + () => collectCollapsedPillPointIds(pointEntries), + [pointEntries] + ); + const pointIdsWithoutLabelAnchor = useMemo( + () => collectPointIdsWithoutSelfLabelAnchor(pointEntries), + [pointEntries] + ); + const labelAnchorPointIdsWithForcedVisibility = useMemo( + () => + collectLabelAnchorPointIdsWithForcedVisibility( + pointEntries, + unselectedClosedAreaNodeIdSet + ), + [pointEntries, unselectedClosedAreaNodeIdSet] + ); + + return { + collapsedPillPointIds, + pointIdsWithoutLabelAnchor, + labelAnchorPointIdsWithForcedVisibility, + } as const; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointMarkerBadgeState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointMarkerBadgeState.ts new file mode 100644 index 0000000000..067c6563fa --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/usePointMarkerBadgeState.ts @@ -0,0 +1,47 @@ +import { useMemo } from "react"; + +import { + useAnnotationPointMarkerBadges, + type AnnotationPointMarkerBadge, +} from "../../../render"; +import type { + NodeChainAnnotation, + PointAnnotationEntry, + PointDistanceRelation, + PointMeasurementEntry, +} from "@carma-mapping/annotations/core"; + +export const usePointMarkerBadgeState = ( + pointEntries: readonly PointAnnotationEntry[], + pointMeasureEntries: readonly PointMeasurementEntry[], + nodeChainAnnotations: readonly NodeChainAnnotation[], + distanceRelations: readonly PointDistanceRelation[] +) => { + const pointMeasureOrderById = useMemo( + () => + pointMeasureEntries + .filter((measurement) => !measurement.auxiliaryLabelAnchor) + .reduce>((orderById, measurement, index) => { + orderById[measurement.id] = index + 1; + return orderById; + }, {}), + [pointMeasureEntries] + ); + + const pointMarkerBadgeByPointId = useAnnotationPointMarkerBadges( + pointEntries, + nodeChainAnnotations, + distanceRelations, + pointMeasureOrderById + ); + + return { + pointMarkerBadgeByPointId, + } as const; +}; + +export type PointMarkerBadgeState = { + pointMarkerBadgeByPointId: Readonly< + Record + >; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useStandaloneDistancePointState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useStandaloneDistancePointState.ts new file mode 100644 index 0000000000..3157f978f5 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useStandaloneDistancePointState.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; + +import { buildStandaloneDistancePointSets } from "@carma-mapping/annotations/core"; +import type { + PointAnnotationEntry, + PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +export const deriveStandaloneDistancePointState = ( + pointEntries: readonly PointAnnotationEntry[], + distanceRelations: readonly PointDistanceRelation[], + selectedAnnotationId: string | null, + selectedAnnotationIds: readonly string[] +) => { + const selectedPointIdSet = new Set(selectedAnnotationIds); + if (selectedAnnotationId) { + selectedPointIdSet.add(selectedAnnotationId); + } + + const { + highestPointIds, + unfocusedNonHighestPointIds, + focusedNonHighestPointIds, + } = buildStandaloneDistancePointSets({ + pointMeasurements: pointEntries, + distanceRelations, + selectedPointIds: selectedPointIdSet, + }); + + return { + standaloneDistanceHighestPointIds: highestPointIds, + unfocusedStandaloneDistanceNonHighestPointIds: unfocusedNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds: focusedNonHighestPointIds, + } as const; +}; + +export const useStandaloneDistancePointState = ( + pointEntries: readonly PointAnnotationEntry[], + distanceRelations: readonly PointDistanceRelation[], + selectedAnnotationId: string | null, + selectedAnnotationIds: readonly string[] +) => + useMemo( + () => + deriveStandaloneDistancePointState( + pointEntries, + distanceRelations, + selectedAnnotationId, + selectedAnnotationIds + ), + [ + distanceRelations, + pointEntries, + selectedAnnotationId, + selectedAnnotationIds, + ] + ); + +export type StandaloneDistancePointState = ReturnType< + typeof useStandaloneDistancePointState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useSyncPointLabelAnchors.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useSyncPointLabelAnchors.ts new file mode 100644 index 0000000000..cc6b57cd54 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/label/useSyncPointLabelAnchors.ts @@ -0,0 +1,43 @@ +import { useEffect } from "react"; +import type { Dispatch, SetStateAction } from "react"; + +import { + applyDesiredPointLabelAnchors, + isPointAnnotationEntry, + type AnnotationCollection, + type AnnotationLabelAnchor, +} from "@carma-mapping/annotations/core"; + +export const applyPointLabelAnchors = ( + annotations: AnnotationCollection, + desiredLabelAnchorByPointId: Readonly< + Record + > +): AnnotationCollection => { + const { nextMeasurements, hasChanges } = applyDesiredPointLabelAnchors({ + annotations, + desiredLabelAnchorByPointId, + isPointMeasurement: isPointAnnotationEntry, + }); + + return hasChanges ? nextMeasurements : annotations; +}; + +export const useSyncPointLabelAnchors = ( + setAnnotations: Dispatch>, + desiredLabelAnchorByPointId: Readonly< + Record + > +) => { + useEffect( + function syncPointLabelAnchorsEffect() { + setAnnotations((previousAnnotations) => { + return applyPointLabelAnchors( + previousAnnotations, + desiredLabelAnchorByPointId + ); + }); + }, + [desiredLabelAnchorByPointId, setAnnotations] + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/point/useAnnotationPointDisplayState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/useAnnotationPointDisplayState.ts new file mode 100644 index 0000000000..b13568c52b --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/useAnnotationPointDisplayState.ts @@ -0,0 +1,101 @@ +import type { Dispatch, SetStateAction } from "react"; + +import type { + AnnotationCollection, + AnnotationMode, + DerivedPolylinePath, + NodeChainAnnotation, + PointAnnotationEntry, + PointMeasurementEntry, + PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +import { usePointVisibilityState } from "../usePointVisibilityState"; +import { usePointLabelAnchorState } from "./label/usePointLabelAnchorState"; +import { usePointLabelVisibilityState } from "./label/usePointLabelVisibilityState"; +import { usePointMarkerBadgeState } from "./label/usePointMarkerBadgeState"; +import { useStandaloneDistancePointState } from "./label/useStandaloneDistancePointState"; +import { useSyncPointLabelAnchors } from "./label/useSyncPointLabelAnchors"; +import { useLockedAnnotationIdSet } from "../../topology"; + +type UseAnnotationPointDisplayStateParams = { + annotations: AnnotationCollection; + pointEntries: PointAnnotationEntry[]; + pointMeasureEntries: PointMeasurementEntry[]; + nodeChainAnnotations: NodeChainAnnotation[]; + distanceRelations: PointDistanceRelation[]; + selectedAnnotationId: string | null; + selectedAnnotationIds: string[]; + polylines: DerivedPolylinePath[]; + focusedNodeChainAnnotationId: string | null; + unselectedClosedAreaNodeIdSet: ReadonlySet; + hideAnnotationsOfType: Set; + hideLabelsOfType: Set; + showLabels: boolean; + setAnnotations: Dispatch>; +}; + +export const useAnnotationPointDisplayState = ({ + annotations, + pointEntries, + pointMeasureEntries, + nodeChainAnnotations, + distanceRelations, + selectedAnnotationId, + selectedAnnotationIds, + polylines, + focusedNodeChainAnnotationId, + unselectedClosedAreaNodeIdSet, + hideAnnotationsOfType, + hideLabelsOfType, + showLabels, + setAnnotations, +}: UseAnnotationPointDisplayStateParams) => { + const { pointMarkerBadgeByPointId } = usePointMarkerBadgeState( + pointEntries, + pointMeasureEntries, + nodeChainAnnotations, + distanceRelations + ); + + const standaloneDistancePointState = useStandaloneDistancePointState( + pointEntries, + distanceRelations, + selectedAnnotationId, + selectedAnnotationIds + ); + + const desiredPointLabelAnchorById = usePointLabelAnchorState( + pointEntries, + polylines, + focusedNodeChainAnnotationId, + pointMarkerBadgeByPointId, + standaloneDistancePointState + ); + useSyncPointLabelAnchors(setAnnotations, desiredPointLabelAnchorById); + + const { + collapsedPillPointIds, + pointIdsWithoutLabelAnchor, + labelAnchorPointIdsWithForcedVisibility, + } = usePointLabelVisibilityState(pointEntries, unselectedClosedAreaNodeIdSet); + + const { showPoints, showPointLabels } = usePointVisibilityState( + hideAnnotationsOfType, + showLabels, + hideLabelsOfType + ); + + const lockedMeasurementIdSet = useLockedAnnotationIdSet(annotations); + + return { + pointMarkerBadgeByPointId, + standaloneDistancePointState, + collapsedPillPointIds, + pointIdsWithoutLabelAnchor, + labelAnchorPointIdsWithForcedVisibility, + showPoints, + showPointLabels, + lockedMeasurementIdSet, + }; +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointLabels.ts b/libraries/mapping/annotations/provider/src/lib/context/render/point/usePointLabelVisualizer.ts similarity index 82% rename from libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointLabels.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/point/usePointLabelVisualizer.ts index 9031abee0c..c25a6e7f27 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/useCesiumPointLabels.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/point/usePointLabelVisualizer.ts @@ -20,52 +20,40 @@ import { import { computePointLabelLayout, - DEFAULT_POINT_LABEL_LAYOUT_CONFIG, resolvePointLabelLayoutConfig, useLineVisualizers, usePointLabels, type LineVisualizerData, type LayoutPointInput, type PointLabelData, - type PointLabelLayoutConfig, type PointLabelLayoutConfigOverrides, type PointLabelLayoutResult, } from "@carma-providers/label-overlay"; import type { CssPixelPosition } from "@carma/units/types"; import { + DEFAULT_POINT_LABEL_METRIC_MODE, formatNumber, getCustomPointAnnotationName, -} from "@carma-mapping/annotations/core"; - -import { - DEFAULT_POINT_LABEL_METRIC_MODE, type PlanarPolygonPlane, - type PointLabelMetricMode, type PointAnnotationEntry, -} from "../types/AnnotationTypes"; -import { useCesiumSceneVisibilityIndex } from "./useCesiumSceneVisibilityIndex"; -import { usePointRectangleSelectionOverlay } from "./usePointRectangleSelectionOverlay"; - -export type CesiumLabelLayoutConfig = PointLabelLayoutConfig; -export type CesiumLabelLayoutConfigOverrides = PointLabelLayoutConfigOverrides; -export const DEFAULT_CESIUM_LABEL_LAYOUT_CONFIG = - DEFAULT_POINT_LABEL_LAYOUT_CONFIG; -export type PointMarkerBadge = { - text: string; - backgroundColor?: string; - textColor?: string; -}; + type PointLabelMetricMode, +} from "@carma-mapping/annotations/core"; +import type { AnnotationPointMarkerBadge } from "../../render"; +import type { AnnotationSelectionState } from "../../selection/annotationSelection.types"; + +import { useCesiumSceneVisibilityIndex } from "@carma-mapping/annotations/cesium"; + const ELEVATION_NEUTRAL_THRESHOLD_METERS = 0.03; const REFERENCE_POINT_DISTANCE_EPSILON_METERS = 0.001; const GLYPH_SIZE_EM = 1; const ELEVATION_GLYPH_UP = "↥"; const ELEVATION_GLYPH_DOWN = "↧"; const NORMAL_MARKER_SIZE_PX = 10; -const MOVE_GIZMO_MARKER_SIZE_PX = 36; -const MOVE_GIZMO_MARKER_SIZE_DRAGGING_PX = 40; -const MOVE_GIZMO_MARKER_INNER_SCALE_IDLE = 0; -const MOVE_GIZMO_MARKER_INNER_SCALE_DRAGGING = 0.68; -const MOVE_GIZMO_MARKER_INNER_COLOR = "rgba(255, 255, 255, 0.96)"; +const EDITING_POINT_MARKER_SIZE_PX = 36; +const EDITING_POINT_MARKER_SIZE_DRAGGING_PX = 40; +const EDITING_POINT_MARKER_INNER_SCALE_IDLE = 0; +const EDITING_POINT_MARKER_INNER_SCALE_DRAGGING = 0.68; +const EDITING_POINT_MARKER_INNER_COLOR = "rgba(255, 255, 255, 0.96)"; const LABEL_BADGE_GAP_PX = 4; const INPUT_CARET_BLINK_INTERVAL_MS = 530; const AREA_NODE_BADGE_REGEX = /^[ADF]\d+$/i; @@ -160,7 +148,9 @@ const getReferenceLabelBase = ( distanceToReferenceByPointId?: Readonly>, referenceLabelPointId?: string | null, pointLabelIndexByPointId?: Readonly>, - pointMarkerBadgeByPointId?: Readonly>, + pointMarkerBadgeByPointId?: Readonly< + Record + >, preferDefaultNaming: boolean = false ): string | undefined => { if (referenceLabelPointId) { @@ -206,12 +196,6 @@ const getReferenceLabelBase = ( ); }; -const formatNoneLabelText = ( - labelBase: string -): PointLabelTextRepresentation => ({ - layoutText: labelBase, -}); - const formatElevationLabelText = ( labelBase: string, pointHeight: number, @@ -245,13 +229,6 @@ const formatElevationLabelText = ( }; }; -const formatAbsoluteElevationLabelText = ( - labelBase: string, - pointHeight: number -): PointLabelTextRepresentation => ({ - layoutText: `${labelBase} ${formatMeters(pointHeight)}`, -}); - const formatOffsetElevationLabelText = ( labelBase: string, baseRelativeHeightMeters: number, @@ -275,11 +252,11 @@ const formatDistanceLabelText = ( Math.abs(distanceToReference) <= REFERENCE_POINT_DISTANCE_EPSILON_METERS; if (isReferencePointLabel || !referenceLabelBase) { - return formatNoneLabelText(labelBase); + return { layoutText: labelBase }; } if (referenceLabelBase === labelBase) { - return formatNoneLabelText(labelBase); + return { layoutText: labelBase }; } return { @@ -330,10 +307,12 @@ const formatPointLabelText = ( } if (pointLabelMetricMode === "absoluteElevation") { - return formatAbsoluteElevationLabelText(labelBase, pointHeight); + return { + layoutText: `${labelBase} ${formatMeters(pointHeight)}`, + }; } - return formatNoneLabelText(labelBase); + return { layoutText: labelBase }; }; const getLabelTextWithoutLeadingBadge = ( @@ -474,83 +453,144 @@ const getPlaneIntersectionForClientPosition = ( ); }; -export const useCesiumPointLabels = ( - scene: Scene | null, - points: PointAnnotationEntry[], - showLabels: boolean, - referenceElevation: number = 0, - selectedPointId: string | null = null, - selectedPointIds: string[] = [], - moveGizmoPointId: string | null = null, - moveGizmoIsDragging: boolean = false, - onPointClick?: (pointId: string) => void, - onPointDoubleClick?: (pointId: string) => void, - onPointLongPress?: (pointId: string) => void, - onPointHoverChange?: (pointId: string, hovered: boolean) => void, - onPointVerticalOffsetStemLongPress?: (pointId: string) => void, - selectionModeEnabled: boolean = false, - selectionRectangleModeEnabled: boolean = false, - selectionAdditiveMode: boolean = false, - onPointRectangleSelect?: (pointIds: string[], additive: boolean) => void, - pointLongPressDurationMs: number = 300, - occlusionChecksEnabled: boolean = true, - layoutConfigOverrides?: CesiumLabelLayoutConfigOverrides, - distanceToReferenceByPointId?: Readonly>, - pointLabelIndexByPointId?: Readonly>, - referenceLabelPointId?: string | null, - polylinePointLabelTextByPointId?: Readonly>, - hiddenPointLabelIds?: ReadonlySet, - fullyHiddenPointIds?: ReadonlySet, - markerlessPointIds?: ReadonlySet, - pillMarkerPointIds?: ReadonlySet, - pointDragPlaneByPointId?: Readonly>, - onPointPlaneDragStart?: (pointId: string) => void, +export type PointLabelDisplayState = { + enabled: boolean; + showLabels?: boolean; + referenceElevation?: number; + occlusionChecksEnabled?: boolean; + labelLayoutConfig?: PointLabelLayoutConfigOverrides; + distanceToReferenceByPointId?: Readonly>; + pointLabelIndexByPointId?: Readonly>; + referenceLabelPointId?: string | null; + polylinePointLabelTextByPointId?: Readonly>; + hiddenPointLabelIds?: ReadonlySet; + fullyHiddenPointIds?: ReadonlySet; + markerlessPointIds?: ReadonlySet; + pillMarkerPointIds?: ReadonlySet; + suppressCompactLabelPointIds?: ReadonlySet; + labelInputPromptPointId?: string | null; + pointMarkerBadgeByPointId?: Readonly< + Record + >; + markerOnlyOverlayNodeInteractions?: boolean; + interactivePointIds?: ReadonlySet; +}; + +export type PointLabelEditingState = { + editingPointId?: string | null; + editingPointIsDragging?: boolean; + pointDragPlaneByPointId?: Readonly>; + onPointPlaneDragStart?: (pointId: string) => void; onPointPlaneDragPositionChange?: ( pointId: string, nextPosition: Cartesian3 - ) => void, - onPointPlaneDragEnd?: (pointId: string) => void, - moveGizmoMarkerSizeScale: number = 1, - moveGizmoLabelDistanceScale: number = 1, - labelInputPromptPointId: string | null = null, - pointMarkerBadgeByPointId?: Readonly>, - suppressCompactLabelPointIds?: ReadonlySet, - markerOnlyOverlayNodeInteractions: boolean = false + ) => void; + onPointPlaneDragEnd?: (pointId: string) => void; + editingPointMarkerSizeScale?: number; + editingPointLabelDistanceScale?: number; +}; + +export type PointLabelInteractionState = { + onPointClick?: (pointId: string) => void; + onPointDoubleClick?: (pointId: string) => void; + onPointLongPress?: (pointId: string) => void; + onPointHoverChange?: ( + pointId: string, + hovered: boolean, + anchorPosition?: { x: number; y: number } | null + ) => void; + onPointVerticalOffsetStemLongPress?: (pointId: string) => void; + pointLongPressDurationMs?: number; +}; + +export type PointLabelVisualizerOptions = { + display: PointLabelDisplayState; + selection?: AnnotationSelectionState; + editing?: PointLabelEditingState; + interactions?: PointLabelInteractionState; +}; + +export const usePointLabelVisualizer = ( + scene: Scene | null, + points: PointAnnotationEntry[], + { display, selection, editing, interactions }: PointLabelVisualizerOptions ) => { + const { + enabled, + showLabels = true, + referenceElevation = 0, + occlusionChecksEnabled = true, + labelLayoutConfig, + distanceToReferenceByPointId, + pointLabelIndexByPointId, + referenceLabelPointId = null, + polylinePointLabelTextByPointId, + hiddenPointLabelIds, + fullyHiddenPointIds, + markerlessPointIds, + pillMarkerPointIds, + suppressCompactLabelPointIds, + labelInputPromptPointId = null, + pointMarkerBadgeByPointId, + markerOnlyOverlayNodeInteractions = false, + interactivePointIds, + } = display; + const { selectedAnnotationId = null, selectedAnnotationIds = [] } = + selection ?? {}; + const { + editingPointId = null, + editingPointIsDragging = false, + pointDragPlaneByPointId, + onPointPlaneDragStart, + onPointPlaneDragPositionChange, + onPointPlaneDragEnd, + editingPointMarkerSizeScale = 1, + editingPointLabelDistanceScale = 1, + } = editing ?? {}; + const { + onPointClick, + onPointDoubleClick, + onPointLongPress, + onPointHoverChange, + onPointVerticalOffsetStemLongPress, + pointLongPressDurationMs = 300, + } = interactions ?? {}; const [cameraPitch, setCameraPitch] = useState(-Math.PI / 4); const registeredPointIdSetRef = useRef>(new Set()); - const selectedPointIdSet = useMemo(() => { - const ids = new Set(selectedPointIds); - if (selectedPointId) { - ids.add(selectedPointId); + const selectedAnnotationIdSet = useMemo(() => { + const ids = new Set(selectedAnnotationIds); + if (selectedAnnotationId) { + ids.add(selectedAnnotationId); } return ids; - }, [selectedPointId, selectedPointIds]); + }, [selectedAnnotationId, selectedAnnotationIds]); const layoutConfig = useMemo( - () => resolvePointLabelLayoutConfig(layoutConfigOverrides), - [layoutConfigOverrides] + () => resolvePointLabelLayoutConfig(labelLayoutConfig), + [labelLayoutConfig] ); - const resolvedMoveGizmoMarkerSizeScale = useMemo( - () => sanitizePositiveScale(moveGizmoMarkerSizeScale), - [moveGizmoMarkerSizeScale] + const resolvedEditingPointMarkerSizeScale = useMemo( + () => sanitizePositiveScale(editingPointMarkerSizeScale), + [editingPointMarkerSizeScale] ); - const resolvedMoveGizmoLabelDistanceScale = useMemo( - () => sanitizePositiveScale(moveGizmoLabelDistanceScale), - [moveGizmoLabelDistanceScale] + const resolvedEditingPointLabelDistanceScale = useMemo( + () => sanitizePositiveScale(editingPointLabelDistanceScale), + [editingPointLabelDistanceScale] ); - const moveGizmoMarkerSizePx = useMemo( - () => MOVE_GIZMO_MARKER_SIZE_PX * resolvedMoveGizmoMarkerSizeScale, - [resolvedMoveGizmoMarkerSizeScale] + const editingPointMarkerSizePx = useMemo( + () => EDITING_POINT_MARKER_SIZE_PX * resolvedEditingPointMarkerSizeScale, + [resolvedEditingPointMarkerSizeScale] ); - const moveGizmoMarkerSizeDraggingPx = useMemo( - () => MOVE_GIZMO_MARKER_SIZE_DRAGGING_PX * resolvedMoveGizmoMarkerSizeScale, - [resolvedMoveGizmoMarkerSizeScale] + const editingPointMarkerSizeDraggingPx = useMemo( + () => + EDITING_POINT_MARKER_SIZE_DRAGGING_PX * + resolvedEditingPointMarkerSizeScale, + [resolvedEditingPointMarkerSizeScale] ); // Keep camera pitch in sync while the camera moves. useEffect(() => { - if (!scene || scene.isDestroyed() || !showLabels) return; + if (!scene || scene.isDestroyed() || !enabled) return; const camera = scene.camera; const updatePitch = () => { @@ -572,24 +612,26 @@ export const useCesiumPointLabels = ( removeMoveEndListener(); } }; - }, [scene, showLabels]); + }, [enabled, scene]); - const realtimeOcclusionPointIds = useMemo(() => { - if (!occlusionChecksEnabled || !moveGizmoIsDragging) return []; - if (!selectedPointId || selectedPointId !== moveGizmoPointId) return []; - return [selectedPointId]; + const realtimeOcclusionEditingPointIds = useMemo(() => { + if (!occlusionChecksEnabled || !editingPointIsDragging) return []; + if (!selectedAnnotationId || selectedAnnotationId !== editingPointId) { + return []; + } + return [selectedAnnotationId]; }, [ - moveGizmoIsDragging, - moveGizmoPointId, + editingPointIsDragging, + editingPointId, occlusionChecksEnabled, - selectedPointId, + selectedAnnotationId, ]); const { registerPoints, unregisterPointIds, visibilityStateById } = useCesiumSceneVisibilityIndex(scene, { - shouldTestVisibility: showLabels, + shouldTestVisibility: enabled, shouldTestOcclusion: occlusionChecksEnabled, - realtimeOcclusionPointIds, + realtimeOcclusionPointIds: realtimeOcclusionEditingPointIds, viewportPaddingHorizontal: VIEWPORT_PADDING_HORIZONTAL, viewportPaddingVertical: VIEWPORT_PADDING_VERTICAL, occlusionToleranceMeters: 1.0, @@ -793,8 +835,8 @@ export const useCesiumPointLabels = ( if (!anchor || visibilityState?.isHidden) return null; const labelTextRepresentation = pointLabelTextById[point.id]; if (!labelTextRepresentation) return null; - const isDraggedMoveGizmoPoint = - moveGizmoIsDragging && point.id === moveGizmoPointId; + const isDraggedEditingPoint = + editingPointIsDragging && point.id === editingPointId; const effectivePointIndex = pointLabelIndexByPointId?.[point.id] ?? index; const compactText = getPointLabelBase( @@ -816,7 +858,7 @@ export const useCesiumPointLabels = ( text: labelTextRepresentation.layoutText, compactText, index, - ...(isDraggedMoveGizmoPoint + ...(isDraggedEditingPoint ? { layoutPriority: Number.MAX_SAFE_INTEGER, lockPreferredPlacement: true, @@ -838,8 +880,8 @@ export const useCesiumPointLabels = ( points, visibilityStateById, pointLabelTextById, - moveGizmoPointId, - moveGizmoIsDragging, + editingPointId, + editingPointIsDragging, pointLabelIndexByPointId, pointMarkerBadgeByPointId, layoutConfig, @@ -853,24 +895,24 @@ export const useCesiumPointLabels = ( const pointMarkerBadge = pointMarkerBadgeByPointId?.[point.id]; const pointMarkerBadgeText = pointMarkerBadge?.text?.trim() ?? ""; const isAreaNodeBadge = AREA_NODE_BADGE_REGEX.test(pointMarkerBadgeText); - const isMoveGizmoPoint = point.id === moveGizmoPointId; + const isEditingPoint = point.id === editingPointId; const suppressCompactLabel = Boolean(suppressCompactLabelPointIds?.has(point.id)) || isAreaNodeBadge || - isMoveGizmoPoint; + isEditingPoint; const customPointName = getCustomPointAnnotationName(point.name); const labelTextRepresentation = pointLabelTextById[point.id] ?? (polylineOverrideText !== undefined ? { layoutText: polylineOverrideText } - : formatNoneLabelText( - getPointLabelBase( + : { + layoutText: getPointLabelBase( point.name, effectivePointIndex, Boolean(point.auxiliaryLabelAnchor), suppressCompactLabel ? undefined : pointMarkerBadge?.text - ) - )); + ), + }); const compactLabelText = getPointLabelBase( point.name, effectivePointIndex, @@ -878,7 +920,7 @@ export const useCesiumPointLabels = ( suppressCompactLabel ? undefined : pointMarkerBadge?.text ); const usesPillMarkerVariant = - !isMoveGizmoPoint && Boolean(pillMarkerPointIds?.has(point.id)); + !isEditingPoint && Boolean(pillMarkerPointIds?.has(point.id)); const isPolylineLabelPoint = polylineOverrideText !== undefined; const isFocusedPolylinePoint = pointLabelIndexByPointId?.[point.id] !== undefined; @@ -915,7 +957,7 @@ export const useCesiumPointLabels = ( pointMarkerBadge?.text && /^\d+$/.test(pointMarkerBadge.text.trim()) ); const useMarkerLabel = true; - const useBorderlessExtendedLabel = isAreaNodeBadge || isMoveGizmoPoint; + const useBorderlessExtendedLabel = isAreaNodeBadge || isEditingPoint; const isPreviewLabelPoint = Boolean(point.temporary); const compactLayoutBadgeText = pointMarkerBadge?.text?.trim().length && pointMarkerBadge.text @@ -985,7 +1027,7 @@ export const useCesiumPointLabels = ( !isAnnotationMarker; const showInlineLabelBadge = !useMarkerLabel && - !isMoveGizmoPoint && + !isEditingPoint && !Boolean(markerlessPointIds?.has(point.id)) && Boolean(pointMarkerBadge?.text); const inlineLabelBadgeContent = @@ -997,7 +1039,7 @@ export const useCesiumPointLabels = ( pointMarkerBadge.textColor ) : undefined; - const disableInteractionsForMoveGizmoPoint = isMoveGizmoPoint; + const disableInteractionsForEditingPoint = isEditingPoint; const dragPlane = pointDragPlaneByPointId?.[point.id]; const canDirectPlaneDrag = Boolean( dragPlane && onPointPlaneDragPositionChange @@ -1034,13 +1076,14 @@ export const useCesiumPointLabels = ( pitch: cameraPitch, labelAngleRad: layoutResult.placements[point.id]?.angleRad, labelDistance: - isMoveGizmoPoint && + isEditingPoint && layoutResult.placements[point.id]?.distance !== undefined ? layoutResult.placements[point.id].distance * - resolvedMoveGizmoLabelDistanceScale + resolvedEditingPointLabelDistanceScale : layoutResult.placements[point.id]?.distance, labelAttach: layoutResult.placements[point.id]?.attach, hideLabelAndStem: + !showLabels || layoutResult.hiddenByLayout.has(point.id) || Boolean(hiddenPointLabelIds?.has(point.id)), content: useMarkerLabel @@ -1053,15 +1096,11 @@ export const useCesiumPointLabels = ( labelTextRepresentation.layoutText }` : labelTextRepresentation.contentSignature, - hideMarker: - usesPillMarkerVariant || - declaredCollapseToCompact || - isDistanceMetricPoint || - Boolean(markerlessPointIds?.has(point.id)), - markerSize: isMoveGizmoPoint - ? moveGizmoIsDragging - ? moveGizmoMarkerSizeDraggingPx - : moveGizmoMarkerSizePx + hideMarker: Boolean(markerlessPointIds?.has(point.id)), + markerSize: isEditingPoint + ? editingPointIsDragging + ? editingPointMarkerSizeDraggingPx + : editingPointMarkerSizePx : undefined, fontSize: `${resolvedPointLabelFontSizePx}px`, textColor: resolvedPointLabelTextColor, @@ -1082,44 +1121,48 @@ export const useCesiumPointLabels = ( useMarkerLabel && !useBorderlessExtendedLabel && isDistanceMetricPoint, - markerInnerScale: isMoveGizmoPoint - ? moveGizmoIsDragging - ? MOVE_GIZMO_MARKER_INNER_SCALE_DRAGGING - : MOVE_GIZMO_MARKER_INNER_SCALE_IDLE + markerInnerScale: isEditingPoint + ? editingPointIsDragging + ? EDITING_POINT_MARKER_INNER_SCALE_DRAGGING + : EDITING_POINT_MARKER_INNER_SCALE_IDLE : undefined, - markerInnerColor: isMoveGizmoPoint - ? MOVE_GIZMO_MARKER_INNER_COLOR + markerInnerColor: isEditingPoint + ? EDITING_POINT_MARKER_INNER_COLOR : undefined, - markerInnerOpacity: isMoveGizmoPoint ? 1 : undefined, - stemReferenceMarkerSize: isMoveGizmoPoint + markerInnerOpacity: isEditingPoint ? 1 : undefined, + stemReferenceMarkerSize: isEditingPoint ? NORMAL_MARKER_SIZE_PX : undefined, - selected: selectedPointIdSet.has(point.id), + selected: selectedAnnotationIdSet.has(point.id), visible: true, isOccluded: visibilityStateById[point.id]?.isOccluded ?? false, isHidden: (visibilityStateById[point.id]?.isHidden ?? false) || Boolean(fullyHiddenPointIds?.has(point.id)), onClick: - !disableInteractionsForMoveGizmoPoint && onPointClick + !disableInteractionsForEditingPoint && onPointClick ? () => onPointClick(point.id) : undefined, onDoubleClick: - !disableInteractionsForMoveGizmoPoint && onPointDoubleClick + !disableInteractionsForEditingPoint && onPointDoubleClick ? () => onPointDoubleClick(point.id) : undefined, onLongPress: - !disableInteractionsForMoveGizmoPoint && + !disableInteractionsForEditingPoint && !canDirectPlaneDrag && onPointLongPress ? () => onPointLongPress(point.id) : undefined, onHoverChange: - !disableInteractionsForMoveGizmoPoint && onPointHoverChange - ? (hovered: boolean) => onPointHoverChange(point.id, hovered) + !disableInteractionsForEditingPoint && onPointHoverChange + ? (hovered: boolean, anchorPosition?: CssPixelPosition | null) => + onPointHoverChange(point.id, hovered, anchorPosition) : undefined, markerOnlyPointerEvents: markerOnlyOverlayNodeInteractions, attachOverlayClickHandlers: !markerOnlyOverlayNodeInteractions, + forceMarkerInteractionTarget: Boolean( + interactivePointIds?.has(point.id) + ), longPressDurationMs: pointLongPressDurationMs, onMarkerDragStart: canDirectPlaneDrag ? (clientX: number, clientY: number) => { @@ -1140,16 +1183,16 @@ export const useCesiumPointLabels = ( }, [ points, pointLabelTextById, - selectedPointIdSet, - moveGizmoPointId, - moveGizmoIsDragging, + selectedAnnotationIdSet, + editingPointId, + editingPointIsDragging, visibilityStateById, scene, cameraPitch, layoutResult, - moveGizmoMarkerSizePx, - moveGizmoMarkerSizeDraggingPx, - resolvedMoveGizmoLabelDistanceScale, + editingPointMarkerSizePx, + editingPointMarkerSizeDraggingPx, + resolvedEditingPointLabelDistanceScale, onPointClick, onPointDoubleClick, onPointLongPress, @@ -1168,22 +1211,11 @@ export const useCesiumPointLabels = ( markerlessPointIds, suppressCompactLabelPointIds, markerOnlyOverlayNodeInteractions, + interactivePointIds, + enabled, + showLabels, ]); - usePointRectangleSelectionOverlay({ - scene, - enabled: - showLabels && - selectionModeEnabled && - selectionRectangleModeEnabled && - Boolean(onPointRectangleSelect), - additiveMode: selectionAdditiveMode, - points: pointLabelData, - onSelect: (pointIds, additive) => { - onPointRectangleSelect?.(pointIds, additive); - }, - }); - const verticalOffsetStemLines = useMemo(() => { if (!scene || scene.isDestroyed()) return []; return points @@ -1233,11 +1265,13 @@ export const useCesiumPointLabels = ( scene, ]); - useLineVisualizers(verticalOffsetStemLines, showLabels); + useLineVisualizers(verticalOffsetStemLines, enabled && showLabels); - usePointLabels(pointLabelData, showLabels, undefined, undefined, { + usePointLabels(pointLabelData, enabled, undefined, undefined, { transitionDurationMs: layoutConfig.transitionDurationMs, }); + + return pointLabelData; }; -export default useCesiumPointLabels; +export default usePointLabelVisualizer; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/polyline/usePolylineOverlayVisualizer.tsx b/libraries/mapping/annotations/provider/src/lib/context/render/polyline/usePolylineOverlayVisualizer.tsx new file mode 100644 index 0000000000..ddfff34898 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/polyline/usePolylineOverlayVisualizer.tsx @@ -0,0 +1,112 @@ +import { createElement, useEffect, useMemo, useRef } from "react"; + +import { + SceneTransforms, + defined, + isValidScene, + type Scene, +} from "@carma/cesium"; +import { + buildPolylinePreviewCornerMarkers, + type PolylinePreviewMeasurement, +} from "@carma-mapping/annotations/core"; +import { useLabelOverlay } from "@carma-providers/label-overlay"; + +const VERTICAL_CORNER_OVERLAY_ID_PREFIX = "distance-vertical-corner"; +const VERTICAL_CORNER_MARKER_SIZE_PX = 10; +const VERTICAL_CORNER_MARKER_STROKE_WIDTH_PX = 1; + +export type PolylineOverlayVisualizerOptions = Record; + +export const usePolylineOverlayVisualizer = ( + scene: Scene | null, + polylineMeasurements: PolylinePreviewMeasurement[], + _options: PolylineOverlayVisualizerOptions = {} +) => { + const verticalCornerOverlayIdsRef = useRef([]); + const { addLabelOverlayElement, removeLabelOverlayElement } = + useLabelOverlay(); + + const verticalCornerMarkers = useMemo( + () => buildPolylinePreviewCornerMarkers(polylineMeasurements), + [polylineMeasurements] + ); + + const verticalCornerMarkerContent = useMemo( + () => + createElement("div", { + style: { + width: `${VERTICAL_CORNER_MARKER_SIZE_PX}px`, + height: `${VERTICAL_CORNER_MARKER_SIZE_PX}px`, + borderRadius: "50%", + border: `${VERTICAL_CORNER_MARKER_STROKE_WIDTH_PX}px solid rgba(255, 255, 255, 0.95)`, + background: "transparent", + boxSizing: "border-box", + pointerEvents: "none", + }, + }), + [] + ); + + useEffect(() => { + verticalCornerOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + verticalCornerOverlayIdsRef.current = []; + + if (!isValidScene(scene)) { + return; + } + + const nextOverlayIds: string[] = []; + verticalCornerMarkers.forEach((marker) => { + const overlayId = `${VERTICAL_CORNER_OVERLAY_ID_PREFIX}-${marker.id}`; + addLabelOverlayElement({ + id: overlayId, + zIndex: 9, + content: verticalCornerMarkerContent, + updatePosition: (elementDiv) => { + if (!isValidScene(scene)) return false; + const screenPosition = SceneTransforms.worldToWindowCoordinates( + scene, + marker.position + ); + if (!defined(screenPosition)) return false; + elementDiv.style.position = "absolute"; + elementDiv.style.left = `${screenPosition.x}px`; + elementDiv.style.top = `${screenPosition.y}px`; + elementDiv.style.transform = "translate(-50%, -50%)"; + elementDiv.style.pointerEvents = "none"; + return true; + }, + }); + nextOverlayIds.push(overlayId); + }); + + verticalCornerOverlayIdsRef.current = nextOverlayIds; + + return () => { + nextOverlayIds.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + verticalCornerOverlayIdsRef.current = []; + }; + }, [ + addLabelOverlayElement, + verticalCornerMarkerContent, + verticalCornerMarkers, + removeLabelOverlayElement, + scene, + ]); + + useEffect(() => { + return () => { + verticalCornerOverlayIdsRef.current.forEach((overlayId) => { + removeLabelOverlayElement(overlayId); + }); + verticalCornerOverlayIdsRef.current = []; + }; + }, [removeLabelOverlayElement]); +}; + +export default usePolylineOverlayVisualizer; diff --git a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationPointMarkerBadges.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationPointMarkerBadges.ts similarity index 76% rename from libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationPointMarkerBadges.ts rename to libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationPointMarkerBadges.ts index ba23554ac9..eb4595c33a 100644 --- a/libraries/mapping/annotations/core/src/lib/context/hooks/useAnnotationPointMarkerBadges.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationPointMarkerBadges.ts @@ -1,4 +1,5 @@ import { useMemo } from "react"; + import { DEFAULT_ANNOTATION_SHORT_LABEL_CONFIG, formatMeasurementShortLabelToken, @@ -9,7 +10,7 @@ import { ANNOTATION_TYPE_DISTANCE, ANNOTATION_TYPE_LABEL, ANNOTATION_TYPE_POINT, -} from "../../types/annotationTypes"; +} from "@carma-mapping/annotations/core"; export type AnnotationPointMarkerBadge = { text: string; @@ -17,15 +18,23 @@ export type AnnotationPointMarkerBadge = { textColor: string; }; +export type NodeChainBadgeKind = Exclude< + AnnotationShortLabelKind, + | typeof ANNOTATION_TYPE_POINT + | typeof ANNOTATION_TYPE_DISTANCE + | typeof ANNOTATION_TYPE_LABEL +>; + export type AnnotationPointMarkerBadgePointLike = { id: string; timestamp: number; index?: number | null; }; -export type AnnotationPointMarkerBadgePlanarGroupLike = { +export type AnnotationPointMarkerBadgeNodeChainLike = { id: string; - vertexPointIds: string[]; + type: NodeChainBadgeKind; + nodeIds: string[]; }; export type AnnotationPointMarkerBadgeDistanceRelationLike = { @@ -35,51 +44,30 @@ export type AnnotationPointMarkerBadgeDistanceRelationLike = { polygonGroupId?: string | null; }; -export type PlanarGroupBadgeKind = Exclude< - AnnotationShortLabelKind, - | typeof ANNOTATION_TYPE_POINT - | typeof ANNOTATION_TYPE_DISTANCE - | typeof ANNOTATION_TYPE_LABEL ->; - -type UseMeasurementPointMarkerBadgesParams< - TPoint extends AnnotationPointMarkerBadgePointLike, - TPlanarGroup extends AnnotationPointMarkerBadgePlanarGroupLike, - TDistanceRelation extends AnnotationPointMarkerBadgeDistanceRelationLike +type AnnotationPointMarkerBadgesOptions< + TPoint extends AnnotationPointMarkerBadgePointLike > = { - pointMeasurements: readonly TPoint[]; - planarPolygonGroups: readonly TPlanarGroup[]; - distanceRelations: readonly TDistanceRelation[]; - pointMeasureOrderById: Readonly>; - resolvePlanarGroupBadgeKind: (group: TPlanarGroup) => PlanarGroupBadgeKind; - isPointAutoCorner?: (point: TPoint) => boolean; configMap?: AnnotationShortLabelConfigMap; }; export const useAnnotationPointMarkerBadges = < TPoint extends AnnotationPointMarkerBadgePointLike, - TPlanarGroup extends AnnotationPointMarkerBadgePlanarGroupLike, + TNodeChainAnnotation extends AnnotationPointMarkerBadgeNodeChainLike, TDistanceRelation extends AnnotationPointMarkerBadgeDistanceRelationLike ->({ - pointMeasurements, - planarPolygonGroups, - distanceRelations, - pointMeasureOrderById, - resolvePlanarGroupBadgeKind, - isPointAutoCorner, - configMap = DEFAULT_ANNOTATION_SHORT_LABEL_CONFIG, -}: UseMeasurementPointMarkerBadgesParams< - TPoint, - TPlanarGroup, - TDistanceRelation ->): Readonly> => +>( + pointEntries: readonly TPoint[], + nodeChainAnnotations: readonly TNodeChainAnnotation[], + distanceRelations: readonly TDistanceRelation[], + pointMeasureOrderById: Readonly>, + { + configMap = DEFAULT_ANNOTATION_SHORT_LABEL_CONFIG, + }: AnnotationPointMarkerBadgesOptions = {} +): Readonly> => useMemo>>(() => { const badgesByPointId: Record = {}; const assignedPointIds = new Set(); const pointById = new Map( - pointMeasurements.map( - (measurement) => [measurement.id, measurement] as const - ) + pointEntries.map((measurement) => [measurement.id, measurement] as const) ); const assignBadge = ( @@ -93,10 +81,10 @@ export const useAnnotationPointMarkerBadges = < assignedPointIds.add(pointId); }; - const getGroupSortTuple = (group: TPlanarGroup) => { + const getGroupSortTuple = (group: TNodeChainAnnotation) => { let minIndex = Number.POSITIVE_INFINITY; let minTimestamp = Number.POSITIVE_INFINITY; - group.vertexPointIds.forEach((pointId) => { + group.nodeIds.forEach((pointId) => { const point = pointById.get(pointId); if (!point) return; minIndex = Math.min(minIndex, point.index ?? Number.POSITIVE_INFINITY); @@ -106,7 +94,7 @@ export const useAnnotationPointMarkerBadges = < return { minIndex, minTimestamp }; }; - const sortedGroups = [...planarPolygonGroups].sort((left, right) => { + const sortedGroups = [...nodeChainAnnotations].sort((left, right) => { const leftKey = getGroupSortTuple(left); const rightKey = getGroupSortTuple(right); const indexDelta = leftKey.minIndex - rightKey.minIndex; @@ -116,7 +104,7 @@ export const useAnnotationPointMarkerBadges = < return left.id.localeCompare(right.id); }); - const badgeCounterByKind: Record = { + const badgeCounterByKind: Record = { polyline: 1, area: 1, planar: 1, @@ -124,7 +112,7 @@ export const useAnnotationPointMarkerBadges = < }; sortedGroups.forEach((group) => { - const badgeKind = resolvePlanarGroupBadgeKind(group); + const badgeKind = group.type; const badgeConfig = configMap[badgeKind]; const badge: AnnotationPointMarkerBadge = { text: formatMeasurementShortLabelToken( @@ -135,7 +123,7 @@ export const useAnnotationPointMarkerBadges = < backgroundColor: badgeConfig.backgroundColor, textColor: badgeConfig.textColor, }; - group.vertexPointIds.forEach((pointId) => { + group.nodeIds.forEach((pointId) => { assignBadge(pointId, badge, true); }); }); @@ -197,11 +185,14 @@ export const useAnnotationPointMarkerBadges = < componentPointIds.forEach((pointId) => assignBadge(pointId, badge)); }); - const standalonePoints = [...pointMeasurements] + const standalonePointMeasureIdSet = new Set( + Object.keys(pointMeasureOrderById) + ); + const standalonePoints = [...pointEntries] .filter((measurement) => { if (assignedPointIds.has(measurement.id)) return false; - if (!isPointAutoCorner) return true; - return !isPointAutoCorner(measurement); + if (!standalonePointMeasureIdSet.has(measurement.id)) return false; + return true; }) .sort((left, right) => { const indexDelta = (left.index ?? 0) - (right.index ?? 0); @@ -229,9 +220,7 @@ export const useAnnotationPointMarkerBadges = < }, [ configMap, distanceRelations, - isPointAutoCorner, - planarPolygonGroups, + nodeChainAnnotations, + pointEntries, pointMeasureOrderById, - pointMeasurements, - resolvePlanarGroupBadgeKind, ]); diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationVisibilityFilterState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationVisibilityFilterState.ts new file mode 100644 index 0000000000..0bd8ddd2bd --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationVisibilityFilterState.ts @@ -0,0 +1,19 @@ +import { useState } from "react"; + +import type { AnnotationMode } from "@carma-mapping/annotations/core"; + +export const useAnnotationVisibilityFilterState = () => { + const [hideAnnotationsOfType, setHideAnnotationsOfType] = useState< + Set + >(new Set()); + const [hideLabelsOfType, setHideLabelsOfType] = useState>( + new Set() + ); + + return { + hideAnnotationsOfType, + setHideAnnotationsOfType, + hideLabelsOfType, + setHideLabelsOfType, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsRenderState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsRenderState.ts new file mode 100644 index 0000000000..8a931fcffb --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsRenderState.ts @@ -0,0 +1,379 @@ +import { useMemo } from "react"; + +import { + ANNOTATION_TYPE_AREA_VERTICAL, + isPointAnnotationEntry, + type AnnotationCollection, + type NodeChainAnnotation, + type PointDistanceRelation, + type ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationsRenderStateOptions = { + selectedAnnotationId: string | null; + selectedAnnotationIds: string[]; + pointIdsWithoutLabelAnchor: ReadonlySet; + unselectedClosedAreaNodeIdSet: ReadonlySet; + unfocusedStandaloneDistanceNonHighestPointIds: ReadonlySet; + focusedStandaloneDistanceNonHighestPointIds: ReadonlySet; + labelAnchorPointIdsWithForcedVisibility: ReadonlySet; + unfocusedPolylineNonLastIds: ReadonlySet; + annotationCursorEnabled: boolean; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; +}; + +export const useAnnotationsRenderState = ( + annotations: AnnotationCollection, + distanceRelations: PointDistanceRelation[], + nodeChainAnnotations: NodeChainAnnotation[], + { + selectedAnnotationId, + selectedAnnotationIds, + pointIdsWithoutLabelAnchor, + unselectedClosedAreaNodeIdSet, + unfocusedStandaloneDistanceNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds, + labelAnchorPointIdsWithForcedVisibility, + unfocusedPolylineNonLastIds, + annotationCursorEnabled, + defaultDistanceRelationLabelVisibility, + }: UseAnnotationsRenderStateOptions +) => { + const hiddenMeasurementIdSet = useMemo( + () => + new Set( + annotations + .filter( + (measurement) => + measurement.hidden && !measurement.auxiliaryLabelAnchor + ) + .map((measurement) => measurement.id) + ), + [annotations] + ); + + const auxiliaryLabelAnchorIdSet = useMemo( + () => + new Set( + annotations + .filter((measurement) => measurement.auxiliaryLabelAnchor) + .map((measurement) => measurement.id) + ), + [annotations] + ); + + const openVerticalSingleNodeIdSet = useMemo(() => { + const ids = new Set(); + nodeChainAnnotations.forEach((group) => { + if (group.closed) return; + if (group.type !== ANNOTATION_TYPE_AREA_VERTICAL) return; + if (group.nodeIds.length !== 1) return; + const onlyPointId = group.nodeIds[0]; + if (onlyPointId) { + ids.add(onlyPointId); + } + }); + return ids; + }, [nodeChainAnnotations]); + + const closedVerticalRectangleVertexIdSet = useMemo(() => { + const ids = new Set(); + nodeChainAnnotations.forEach((group) => { + if (!group.closed) return; + if (group.type !== ANNOTATION_TYPE_AREA_VERTICAL) return; + if (group.nodeIds.length !== 4) return; + group.nodeIds.forEach((pointId) => { + if (pointId) { + ids.add(pointId); + } + }); + }); + return ids; + }, [nodeChainAnnotations]); + + const markerlessPointIds = useMemo(() => { + const ids = new Set(auxiliaryLabelAnchorIdSet); + closedVerticalRectangleVertexIdSet.forEach((pointId) => { + ids.delete(pointId); + }); + return ids; + }, [auxiliaryLabelAnchorIdSet, closedVerticalRectangleVertexIdSet]); + + const hiddenPolygonAnnotationIdSet = useMemo( + () => + new Set( + nodeChainAnnotations + .filter((group) => group.hidden) + .map((group) => group.id) + ), + [nodeChainAnnotations] + ); + + const visibleMeasurementsForRendering = useMemo( + () => + annotations.filter( + (measurement) => !measurement.hidden || measurement.auxiliaryLabelAnchor + ), + [annotations] + ); + + const visiblePolygonAnnotationsForRendering = useMemo( + () => nodeChainAnnotations.filter((group) => !group.hidden), + [nodeChainAnnotations] + ); + + const visibleDistanceRelationsForRendering = useMemo( + () => + distanceRelations.filter((relation) => { + if ( + relation.polygonGroupId && + hiddenPolygonAnnotationIdSet.has(relation.polygonGroupId) + ) { + return false; + } + return ( + !hiddenMeasurementIdSet.has(relation.pointAId) && + !hiddenMeasurementIdSet.has(relation.pointBId) + ); + }), + [distanceRelations, hiddenMeasurementIdSet, hiddenPolygonAnnotationIdSet] + ); + + const selectedStandaloneDistanceRelationIdSet = useMemo(() => { + const selectedPointIdSet = new Set(selectedAnnotationIds); + if (selectedAnnotationId) { + selectedPointIdSet.add(selectedAnnotationId); + } + if (selectedPointIdSet.size === 0) { + return new Set(); + } + + const standaloneRelations = visibleDistanceRelationsForRendering.filter( + (relation) => !relation.polygonGroupId + ); + if (standaloneRelations.length === 0) { + return new Set(); + } + + const neighborPointIdsByPointId = new Map>(); + const relationIdsByPointId = new Map>(); + + standaloneRelations.forEach((relation) => { + const { pointAId, pointBId, id } = relation; + if (!neighborPointIdsByPointId.has(pointAId)) { + neighborPointIdsByPointId.set(pointAId, new Set()); + } + if (!neighborPointIdsByPointId.has(pointBId)) { + neighborPointIdsByPointId.set(pointBId, new Set()); + } + neighborPointIdsByPointId.get(pointAId)?.add(pointBId); + neighborPointIdsByPointId.get(pointBId)?.add(pointAId); + + if (!relationIdsByPointId.has(pointAId)) { + relationIdsByPointId.set(pointAId, new Set()); + } + if (!relationIdsByPointId.has(pointBId)) { + relationIdsByPointId.set(pointBId, new Set()); + } + relationIdsByPointId.get(pointAId)?.add(id); + relationIdsByPointId.get(pointBId)?.add(id); + }); + + const queue: string[] = []; + selectedPointIdSet.forEach((pointId) => { + if (neighborPointIdsByPointId.has(pointId)) { + queue.push(pointId); + } + }); + if (queue.length === 0) { + return new Set(); + } + + const visitedPointIds = new Set(); + const selectedRelationIds = new Set(); + + while (queue.length > 0) { + const pointId = queue.shift(); + if (!pointId || visitedPointIds.has(pointId)) continue; + visitedPointIds.add(pointId); + + relationIdsByPointId.get(pointId)?.forEach((relationId) => { + selectedRelationIds.add(relationId); + }); + neighborPointIdsByPointId.get(pointId)?.forEach((neighborPointId) => { + if (!visitedPointIds.has(neighborPointId)) { + queue.push(neighborPointId); + } + }); + } + + return selectedRelationIds; + }, [ + selectedAnnotationId, + selectedAnnotationIds, + visibleDistanceRelationsForRendering, + ]); + + const polygonOnlyPointIdSet = useMemo(() => { + const displayReadyPolygonGroupIds = new Set( + nodeChainAnnotations + .filter( + (group) => + group.closed || (group.planeLocked && group.nodeIds.length >= 4) + ) + .map((group) => group.id) + ); + + const polygonVertexIds = new Set(); + nodeChainAnnotations.forEach((group) => { + if (!displayReadyPolygonGroupIds.has(group.id)) { + return; + } + group.nodeIds.forEach((id) => polygonVertexIds.add(id)); + }); + + const nonPolygonRelationPointIds = new Set(); + distanceRelations.forEach((relation) => { + if ( + relation.polygonGroupId && + displayReadyPolygonGroupIds.has(relation.polygonGroupId) + ) { + return; + } + nonPolygonRelationPointIds.add(relation.pointAId); + nonPolygonRelationPointIds.add(relation.pointBId); + }); + + const ids = new Set(); + polygonVertexIds.forEach((id) => { + if (!nonPolygonRelationPointIds.has(id)) { + ids.add(id); + } + }); + + if (selectedAnnotationId) { + ids.delete(selectedAnnotationId); + } + + return ids; + }, [distanceRelations, nodeChainAnnotations, selectedAnnotationId]); + + const effectiveDistanceRelationsForRendering = useMemo< + PointDistanceRelation[] + >(() => { + const planarPolygonGroupById = new Map( + nodeChainAnnotations.map((group) => [group.id, group] as const) + ); + + return visibleDistanceRelationsForRendering.map((relation) => { + const owningGroup = relation.polygonGroupId + ? planarPolygonGroupById.get(relation.polygonGroupId) ?? null + : null; + const isStandaloneDistanceRelation = !relation.polygonGroupId; + const isSelectedStandaloneDistanceRelation = + isStandaloneDistanceRelation && + selectedStandaloneDistanceRelationIdSet.has(relation.id); + const isDistanceMeasureRelation = !owningGroup || !owningGroup.closed; + if (!isDistanceMeasureRelation) { + return relation; + } + + if (isSelectedStandaloneDistanceRelation) { + return { + ...relation, + directLabelMode: "segment", + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + direct: true, + vertical: true, + horizontal: true, + }, + }; + } + + return { + ...relation, + directLabelMode: "none", + labelVisibilityByKind: { + ...defaultDistanceRelationLabelVisibility, + ...(relation.labelVisibilityByKind ?? {}), + direct: false, + vertical: false, + horizontal: false, + }, + }; + }); + }, [ + defaultDistanceRelationLabelVisibility, + visibleDistanceRelationsForRendering, + nodeChainAnnotations, + selectedStandaloneDistanceRelationIdSet, + ]); + + const hiddenPointLabelIds = useMemo(() => { + const ids = new Set([ + ...polygonOnlyPointIdSet, + ...hiddenMeasurementIdSet, + ...pointIdsWithoutLabelAnchor, + ...unselectedClosedAreaNodeIdSet, + ...unfocusedStandaloneDistanceNonHighestPointIds, + ...focusedStandaloneDistanceNonHighestPointIds, + ]); + openVerticalSingleNodeIdSet.forEach((pointId) => { + ids.add(pointId); + }); + labelAnchorPointIdsWithForcedVisibility.forEach((pointId) => { + ids.delete(pointId); + }); + return ids; + }, [ + annotations, + polygonOnlyPointIdSet, + hiddenMeasurementIdSet, + pointIdsWithoutLabelAnchor, + unselectedClosedAreaNodeIdSet, + unfocusedStandaloneDistanceNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds, + openVerticalSingleNodeIdSet, + labelAnchorPointIdsWithForcedVisibility, + ]); + + const fullyHiddenPointIds = useMemo(() => { + const ids = new Set([ + ...unfocusedPolylineNonLastIds, + ...unfocusedStandaloneDistanceNonHighestPointIds, + ...hiddenMeasurementIdSet, + ]); + closedVerticalRectangleVertexIdSet.forEach((pointId) => { + ids.delete(pointId); + }); + return ids; + }, [ + annotations, + unfocusedPolylineNonLastIds, + unfocusedStandaloneDistanceNonHighestPointIds, + hiddenMeasurementIdSet, + closedVerticalRectangleVertexIdSet, + ]); + + const effectiveFullyHiddenPointIds = useMemo(() => { + if (!annotationCursorEnabled) { + return fullyHiddenPointIds; + } + + return new Set(hiddenMeasurementIdSet); + }, [fullyHiddenPointIds, hiddenMeasurementIdSet, annotationCursorEnabled]); + + return { + markerlessPointIds, + visibleMeasurementsForRendering, + visiblePolygonAnnotationsForRendering, + effectiveDistanceRelationsForRendering, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualization.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualization.ts new file mode 100644 index 0000000000..0a7b7b080a --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualization.ts @@ -0,0 +1,383 @@ +import { useMemo } from "react"; + +import { + ANNOTATION_TYPE_POINT, + type AnnotationCollection, + type AnnotationToolType, + type CandidateConnectionPreview, + type NodeChainAnnotation, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; +import { + useCesiumCoplanarPolygonPrimitives, + useCesiumEdgeVisualizer, + useCesiumGroundPolygonPrimitives, + useCesiumViewProjector, +} from "@carma-mapping/annotations/cesium"; + +import { + useGroundAreaLabelVisualizer, + usePlanarAreaLabelVisualizer, + useVerticalAreaLabelVisualizer, +} from "./area/labels"; +import { usePointCandidateDomOverlay } from "../interaction/candidate/usePointCandidateDomOverlay"; +import { usePointCandidateRingIndicator } from "../interaction/candidate/usePointCandidateRingIndicator"; +import { + buildCandidatePreviewEdgeRenderModels, + buildEdgeSceneLineRenderModels, +} from "./edge/buildEdgeSceneLineRenderModels"; +import { useEdgeComponentOverlayVisualizer } from "./edge/overlay/useEdgeComponentOverlayVisualizer"; +import { buildEdgeRelationRenderContext } from "./edge/buildEdgeRelationRenderContext"; +import { useNodeChainPreviewModels } from "./useNodeChainPreviewModels"; +import { usePointLabelVisualizer } from "./point/usePointLabelVisualizer"; +import { usePolylineOverlayVisualizer } from "./polyline/usePolylineOverlayVisualizer"; +import { useAnnotationCursorOverlay } from "../interaction/useAnnotationCursorOverlay"; +import { useRectangleSelectionOverlay } from "../selection/useRectangleSelectionOverlay"; +import type { AnnotationsEditingState } from "../interaction/editing/useAnnotationsEditing"; +import type { AnnotationsUserInteractionState } from "../interaction/useAnnotationsUserInteraction"; +import { usePointAnnotationIndex } from "./usePointAnnotationIndex"; +import type { Cartesian3, Scene } from "@carma/cesium"; +import type { AnnotationsOptions } from "../AnnotationsProvider"; +import type { AnnotationPointMarkerBadge } from "./useAnnotationPointMarkerBadges"; +import type { AnnotationSelectionState } from "../selection/annotationSelection.types"; +import type { RectangleSelectionState } from "../selection/useRectangleSelectionOverlay"; + +const POINT_LABEL_LONG_PRESS_DURATION_MS = 300; + +export const useAnnotationsVisualization = ( + managedAnnotations: { + scene: Scene; + visibleMeasurementsForRendering: AnnotationCollection; + effectiveDistanceRelationsForRendering: PointDistanceRelation[]; + visiblePolygonAnnotationsForRendering: NodeChainAnnotation[]; + focusedNodeChainAnnotationId: string | null; + activeNodeChainAnnotationId: string | null; + cumulativeDistanceByRelationId: Readonly>; + showPoints: boolean; + showPointLabels: boolean; + effectiveReferenceElevation: number; + occlusionChecksEnabled: boolean; + options?: AnnotationsOptions; + effectiveDistanceToReferenceByPointId: Readonly>; + pointMarkerBadgeByPointId: Readonly< + Record + >; + hiddenPointLabelIds: ReadonlySet; + effectiveFullyHiddenPointIds: ReadonlySet; + markerlessPointIds: ReadonlySet; + collapsedPillPointIds: ReadonlySet; + labelInputPromptPointId: string | null; + moveGizmoPointId: string | null; + moveGizmoOptions: Partial<{ + markerSizeScale: number; + labelDistanceScale: number; + }>; + isMoveGizmoDragging: boolean; + annotationCursorEnabled: boolean; + activeCandidateNodeECEF: Cartesian3 | null; + cursorScreenPosition: { x: number; y: number } | null; + activeCandidateNodeSurfaceNormalECEF: Cartesian3 | null; + activeCandidateNodeVerticalOffsetAnchorECEF: Cartesian3 | null; + activeToolType: AnnotationToolType; + candidateConnectionPreview: CandidateConnectionPreview | null; + candidatePreviewDistanceMeters: number | undefined; + referencePoint: Cartesian3 | null; + pointRadius: number; + annotationSelection: AnnotationSelectionState; + rectangleSelection: RectangleSelectionState; + }, + annotationUserInteraction: AnnotationsUserInteractionState, + annotationEditing: AnnotationsEditingState +) => { + const { + scene, + visibleMeasurementsForRendering, + effectiveDistanceRelationsForRendering, + visiblePolygonAnnotationsForRendering, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + cumulativeDistanceByRelationId, + showPoints, + showPointLabels, + effectiveReferenceElevation, + occlusionChecksEnabled, + options, + effectiveDistanceToReferenceByPointId, + pointMarkerBadgeByPointId, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + markerlessPointIds, + collapsedPillPointIds, + labelInputPromptPointId, + moveGizmoPointId, + moveGizmoOptions, + isMoveGizmoDragging, + annotationCursorEnabled, + activeCandidateNodeECEF, + cursorScreenPosition, + activeCandidateNodeSurfaceNormalECEF, + activeCandidateNodeVerticalOffsetAnchorECEF, + activeToolType, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + referencePoint, + pointRadius, + annotationSelection, + rectangleSelection, + } = managedAnnotations; + const { + interactivePointIds, + handlePointLabelClick, + handlePointLabelDoubleClick, + handlePointLabelHoverChange, + isPointMeasureLabelModeActive, + isPointMeasureLabelInputPending, + } = annotationUserInteraction; + const { + handleDistanceRelationLineClick, + handleDistanceRelationLineLabelToggle, + handleDistanceRelationCornerClick, + handleDistanceRelationMidpointClick, + requestStartEdit, + } = annotationEditing; + const selectedAnnotations = annotationSelection; + const isPointMeasurementToolActive = activeToolType === ANNOTATION_TYPE_POINT; + const showMeasurementGeometry = true; + const showAnnotationLabels = true; + + const markerOnlyOverlayNodeInteractions = + (annotationCursorEnabled && !isPointMeasureLabelModeActive) || + isPointMeasureLabelInputPending; + const suppressCandidateLabelOverlay = isPointMeasureLabelModeActive; + const { points, pointsById } = usePointAnnotationIndex( + visibleMeasurementsForRendering + ); + const viewProjector = useCesiumViewProjector(scene); + const nodeChainPreviewModels = useNodeChainPreviewModels( + visiblePolygonAnnotationsForRendering, + { + enabled: showMeasurementGeometry, + pointsById, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + pointMarkerBadgeByPointId, + candidateConnectionPreview, + } + ); + const previewEdges = useMemo( + () => [ + ...buildCandidatePreviewEdgeRenderModels({ + candidateConnection: candidateConnectionPreview, + }), + ...nodeChainPreviewModels.verticalPreviewEdges, + ...nodeChainPreviewModels.polygonClosurePreviewEdges, + ], + [ + candidateConnectionPreview, + nodeChainPreviewModels.polygonClosurePreviewEdges, + nodeChainPreviewModels.verticalPreviewEdges, + ] + ); + + const edgeRelationRenderContext = useMemo( + () => + buildEdgeRelationRenderContext({ + nodeChainAnnotations: visiblePolygonAnnotationsForRendering, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + pointsById, + }), + [ + activeNodeChainAnnotationId, + focusedNodeChainAnnotationId, + pointsById, + visiblePolygonAnnotationsForRendering, + ] + ); + + const edgeSceneLines = useMemo( + () => + buildEdgeSceneLineRenderModels({ + pointsById, + distanceRelations: showMeasurementGeometry + ? effectiveDistanceRelationsForRendering + : [], + previewEdges: showMeasurementGeometry ? previewEdges : [], + }), + [ + effectiveDistanceRelationsForRendering, + pointsById, + previewEdges, + showMeasurementGeometry, + ] + ); + + useEdgeComponentOverlayVisualizer(scene, points, { + distanceRelations: showMeasurementGeometry + ? [...effectiveDistanceRelationsForRendering] + : [], + onDistanceLineLabelToggle: handleDistanceRelationLineLabelToggle, + onDistanceLineClick: handleDistanceRelationLineClick, + onDistanceRelationMidpointClick: handleDistanceRelationMidpointClick, + onDistanceRelationCornerClick: handleDistanceRelationCornerClick, + cumulativeDistanceByRelationId, + pointMarkerBadgeByPointId, + previewEdges: showMeasurementGeometry ? previewEdges : [], + distanceRelationRenderContext: edgeRelationRenderContext, + enabled: showMeasurementGeometry, + }); + + useCesiumEdgeVisualizer(scene, edgeSceneLines, { + enabled: showMeasurementGeometry, + }); + + usePolylineOverlayVisualizer( + scene, + showMeasurementGeometry + ? [...nodeChainPreviewModels.polylineMeasurements] + : [] + ); + + useGroundAreaLabelVisualizer( + viewProjector, + showAnnotationLabels + ? [...nodeChainPreviewModels.groundPolygonPreviewGroups] + : [], + { + focusedPolygonGroupId: nodeChainPreviewModels.focusedPolygonGroupId, + polygonAreaBadgeByGroupId: + nodeChainPreviewModels.polygonAreaBadgeByGroupId, + } + ); + + useVerticalAreaLabelVisualizer( + viewProjector, + showAnnotationLabels + ? [...nodeChainPreviewModels.verticalPolygonPreviewGroups] + : [], + { + focusedPolygonGroupId: nodeChainPreviewModels.focusedPolygonGroupId, + polygonAreaBadgeByGroupId: + nodeChainPreviewModels.polygonAreaBadgeByGroupId, + } + ); + + usePlanarAreaLabelVisualizer( + viewProjector, + showAnnotationLabels + ? [...nodeChainPreviewModels.planarPolygonPreviewGroups] + : [], + { + focusedPolygonGroupId: nodeChainPreviewModels.focusedPolygonGroupId, + polygonAreaBadgeByGroupId: + nodeChainPreviewModels.polygonAreaBadgeByGroupId, + } + ); + + useCesiumGroundPolygonPrimitives( + scene, + showMeasurementGeometry + ? nodeChainPreviewModels.groundPolygonPrimitives + : [] + ); + useCesiumCoplanarPolygonPrimitives( + scene, + showMeasurementGeometry + ? nodeChainPreviewModels.verticalPolygonPrimitives + : [] + ); + useCesiumCoplanarPolygonPrimitives( + scene, + showMeasurementGeometry + ? nodeChainPreviewModels.planarPolygonPrimitives + : [] + ); + + usePointCandidateRingIndicator( + scene, + { + pointECEF: annotationCursorEnabled ? activeCandidateNodeECEF : null, + surfaceNormalECEF: annotationCursorEnabled + ? activeCandidateNodeSurfaceNormalECEF + : null, + verticalOffsetAnchorECEF: + annotationCursorEnabled && isPointMeasurementToolActive + ? activeCandidateNodeVerticalOffsetAnchorECEF + : null, + }, + { + radius: pointRadius, + } + ); + + useAnnotationCursorOverlay( + annotationCursorEnabled ? cursorScreenPosition : null, + { + enabled: + showPoints && + annotationCursorEnabled && + Boolean(activeCandidateNodeECEF) && + !( + annotationCursorEnabled && + isPointMeasurementToolActive && + activeCandidateNodeVerticalOffsetAnchorECEF + ), + } + ); + + const pointLabelData = usePointLabelVisualizer(scene, [...points], { + display: { + enabled: showPoints, + showLabels: showAnnotationLabels && showPointLabels, + referenceElevation: effectiveReferenceElevation, + occlusionChecksEnabled, + labelLayoutConfig: options?.labels, + distanceToReferenceByPointId: effectiveDistanceToReferenceByPointId, + hiddenPointLabelIds, + fullyHiddenPointIds: effectiveFullyHiddenPointIds, + markerlessPointIds, + pillMarkerPointIds: collapsedPillPointIds, + pointMarkerBadgeByPointId, + labelInputPromptPointId, + markerOnlyOverlayNodeInteractions, + interactivePointIds, + }, + selection: selectedAnnotations, + editing: { + editingPointId: moveGizmoPointId, + editingPointIsDragging: isMoveGizmoDragging, + editingPointMarkerSizeScale: moveGizmoOptions.markerSizeScale ?? 1, + editingPointLabelDistanceScale: moveGizmoOptions.labelDistanceScale ?? 1, + }, + interactions: { + onPointClick: handlePointLabelClick, + onPointDoubleClick: handlePointLabelDoubleClick, + onPointLongPress: (pointId) => + requestStartEdit({ kind: "point-label", pointId }), + onPointHoverChange: handlePointLabelHoverChange, + onPointVerticalOffsetStemLongPress: (pointId) => + requestStartEdit({ kind: "point-vertical-offset-stem", pointId }), + pointLongPressDurationMs: POINT_LABEL_LONG_PRESS_DURATION_MS, + }, + }); + + useRectangleSelectionOverlay(scene, pointLabelData, rectangleSelection); + + usePointCandidateDomOverlay( + scene, + { + pointECEF: annotationCursorEnabled ? activeCandidateNodeECEF : null, + verticalOffsetAnchorECEF: + annotationCursorEnabled && isPointMeasurementToolActive + ? activeCandidateNodeVerticalOffsetAnchorECEF + : null, + previewDistanceMeters: candidatePreviewDistanceMeters, + referenceElevation: effectiveReferenceElevation, + hasReferenceElevation: Boolean(referencePoint), + suppressLabelOverlay: suppressCandidateLabelOverlay, + }, + { + labelLayoutConfig: options?.labels, + } + ); +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualizationState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualizationState.ts new file mode 100644 index 0000000000..98df3225bf --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useAnnotationsVisualizationState.ts @@ -0,0 +1,233 @@ +import type { Dispatch, SetStateAction } from "react"; + +import { type Cartesian3, type Scene } from "@carma/cesium"; +import type { + AnnotationCollection, + AnnotationMode, + AnnotationToolType, + LinearSegmentLineMode, + NodeChainAnnotation, + PointAnnotationEntry, + PointDistanceRelation, + PointMeasurementEntry, + ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +import { useCandidatePreviewState } from "../interaction/candidate/useCandidatePreviewState"; +import { useClosedAreaSelectionState } from "../selection/useClosedAreaSelectionState"; +import { useDerivedPolylineState } from "../topology/polyline/useDerivedPolylineState"; +import { useAnnotationsPolylineState } from "../topology/polyline/useAnnotationsPolylineState"; +import { useAnnotationPointDisplayState } from "./point/useAnnotationPointDisplayState"; +import { useAnnotationsRenderState } from "./useAnnotationsRenderState"; + +const POLYLINE_VERTICAL_OFFSET_VISUAL_ONLY = true; + +type UseAnnotationsVisualizationStateParams = { + scene: Scene; + annotations: AnnotationCollection; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; + pointEntries: PointAnnotationEntry[]; + pointMeasureEntries: PointMeasurementEntry[]; + referencePoint: Cartesian3 | null; + defaultPolylineVerticalOffsetMeters: number; + hideMeasurementsOfType: Set; + hideLabelsOfType: Set; + showLabels: boolean; + setAnnotations: Dispatch>; + selectedAnnotationId: string | null; + selectedAnnotationIds: string[]; + focusedNodeChainAnnotationId: string | null; + activeNodeChainAnnotationId: string | null; + annotationCursorEnabled: boolean; + activeToolType: AnnotationToolType; + distanceModeStickyToFirstPoint: boolean; + referencePointMeasurementId: string | null; + doubleClickChainSourcePointId: string | null; + selectablePointIds: ReadonlySet; + moveGizmoPointId: string | null; + activeCandidateNodeECEF: Cartesian3 | null; + candidateSupportsEdgeLine: boolean; + resolveDistanceRelationSourcePointId: ( + targetPointId: string + ) => string | null; + candidateForcesDirectEdgeLine: boolean; + candidateUsesPolylineEdgeRules: boolean; + polylineSegmentLineMode: LinearSegmentLineMode; + distanceCreationLineVisibility: { + direct: boolean; + vertical: boolean; + horizontal: boolean; + }; + isPolylineCandidateMode: boolean; + defaultDistanceRelationLabelVisibility: Record< + ReferenceLineLabelKind, + boolean + >; +}; + +export const useAnnotationsVisualizationState = ({ + scene, + annotations, + distanceRelations, + nodeChainAnnotations, + pointEntries, + pointMeasureEntries, + referencePoint, + defaultPolylineVerticalOffsetMeters, + hideMeasurementsOfType, + hideLabelsOfType, + showLabels, + setAnnotations, + selectedAnnotationId, + selectedAnnotationIds, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + annotationCursorEnabled, + activeToolType, + distanceModeStickyToFirstPoint, + referencePointMeasurementId, + doubleClickChainSourcePointId, + selectablePointIds, + moveGizmoPointId, + activeCandidateNodeECEF, + candidateSupportsEdgeLine, + resolveDistanceRelationSourcePointId, + candidateForcesDirectEdgeLine, + candidateUsesPolylineEdgeRules, + polylineSegmentLineMode, + distanceCreationLineVisibility, + isPolylineCandidateMode, + defaultDistanceRelationLabelVisibility, +}: UseAnnotationsVisualizationStateParams) => { + const { polylines, referenceElevation } = useDerivedPolylineState( + scene, + annotations, + nodeChainAnnotations, + defaultPolylineVerticalOffsetMeters, + POLYLINE_VERTICAL_OFFSET_VISUAL_ONLY, + referencePoint + ); + + const { + focusedPolylineDistanceToStartByPointId, + cumulativeDistanceByRelationId, + effectiveReferenceElevation, + effectiveDistanceToReferenceByPointId, + unfocusedPolylineNonLastIds, + } = useAnnotationsPolylineState(annotations, polylines, { + focusedNodeChainAnnotationId, + referencePoint, + referenceElevation, + }); + + const { unselectedClosedAreaNodeIdSet } = useClosedAreaSelectionState( + nodeChainAnnotations, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId + ); + + const { + pointMarkerBadgeByPointId, + standaloneDistancePointState, + collapsedPillPointIds, + pointIdsWithoutLabelAnchor, + labelAnchorPointIdsWithForcedVisibility, + showPoints, + showPointLabels, + lockedMeasurementIdSet, + } = useAnnotationPointDisplayState({ + annotations, + pointEntries, + pointMeasureEntries, + nodeChainAnnotations, + distanceRelations, + selectedAnnotationId, + selectedAnnotationIds, + polylines, + focusedNodeChainAnnotationId, + unselectedClosedAreaNodeIdSet, + hideAnnotationsOfType: hideMeasurementsOfType, + hideLabelsOfType, + showLabels, + setAnnotations, + }); + + const { + unfocusedStandaloneDistanceNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds, + } = standaloneDistancePointState; + + const { + markerlessPointIds, + visibleMeasurementsForRendering, + visiblePolygonAnnotationsForRendering, + effectiveDistanceRelationsForRendering, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + } = useAnnotationsRenderState( + annotations, + distanceRelations, + nodeChainAnnotations, + { + selectedAnnotationId, + selectedAnnotationIds, + pointIdsWithoutLabelAnchor, + unselectedClosedAreaNodeIdSet, + unfocusedStandaloneDistanceNonHighestPointIds, + focusedStandaloneDistanceNonHighestPointIds, + labelAnchorPointIdsWithForcedVisibility, + unfocusedPolylineNonLastIds, + annotationCursorEnabled, + defaultDistanceRelationLabelVisibility, + } + ); + + const { + activeMeasurementId, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + } = useCandidatePreviewState({ + activeToolType, + distanceModeStickyToFirstPoint, + referencePointMeasurementId, + doubleClickChainSourcePointId, + selectablePointIds, + moveGizmoPointId, + selectedAnnotationId, + candidateSupportsEdgeLine, + resolveDistanceRelationSourcePointId, + activeCandidateNodeECEF, + annotations, + candidateForcesDirectEdgeLine, + candidateUsesPolylineEdgeRules, + polylineSegmentLineMode, + distanceCreationLineVisibility, + isPolylineCandidateMode, + focusedPolylineDistanceToStartByPointId, + }); + + return { + cumulativeDistanceByRelationId, + effectiveReferenceElevation, + effectiveDistanceToReferenceByPointId, + pointMarkerBadgeByPointId, + collapsedPillPointIds, + showPoints, + showPointLabels, + lockedMeasurementIdSet, + markerlessPointIds, + visibleMeasurementsForRendering, + visiblePolygonAnnotationsForRendering, + effectiveDistanceRelationsForRendering, + hiddenPointLabelIds, + effectiveFullyHiddenPointIds, + activeMeasurementId, + candidateConnectionPreview, + candidatePreviewDistanceMeters, + }; +}; + +export type AnnotationsVisualizationState = ReturnType< + typeof useAnnotationsVisualizationState +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/useNodeChainPreviewModels.ts b/libraries/mapping/annotations/provider/src/lib/context/render/useNodeChainPreviewModels.ts new file mode 100644 index 0000000000..3557e741b4 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/useNodeChainPreviewModels.ts @@ -0,0 +1,359 @@ +import { useMemo } from "react"; + +import { Cartesian3, Color } from "@carma/cesium"; +import { + ANNOTATION_TYPE_AREA_GROUND, + ANNOTATION_TYPE_AREA_PLANAR, + ANNOTATION_TYPE_POLYLINE, + ANNOTATION_TYPE_AREA_VERTICAL, + type CandidateConnectionPreview, + POLYGON_PREVIEW_STROKE, + POLYGON_PREVIEW_STROKE_WIDTH_PX, + buildGroundPolygonPreviewGroups, + buildPlanarPolygonPreviewGroups, + buildPolylinePreviewEdgeSegments, + buildPolylinePreviewMeasurements, + buildVerticalPolygonPreviewGroups, + type NodeChainAnnotation, + type PointAnnotationEntry, +} from "@carma-mapping/annotations/core"; +import type { AnnotationPointMarkerBadge } from "./useAnnotationPointMarkerBadges"; + +import type { + EdgeSceneLineRenderModel, + PolygonPrimitiveRenderModel, +} from "./annotationVisualization.types"; +import type { PolygonAreaBadge } from "./area/labels"; + +const POLYGON_FILL_ALPHA = 0.25; +const POLYGON_FILL_SELECTED_ALPHA = 0.35; + +const POLYGON_FILL_RGB_BY_MEASUREMENT_TYPE = { + [ANNOTATION_TYPE_AREA_VERTICAL]: [0.44, 0.66, 1.0], + [ANNOTATION_TYPE_AREA_GROUND]: [0.42, 0.74, 0.48], + [ANNOTATION_TYPE_AREA_PLANAR]: [0.94, 0.87, 0.57], +} as const; + +const getPolygonFillColor = ( + type: + | typeof ANNOTATION_TYPE_AREA_VERTICAL + | typeof ANNOTATION_TYPE_AREA_GROUND + | typeof ANNOTATION_TYPE_AREA_PLANAR, + isSelected: boolean +) => { + const [red, green, blue] = POLYGON_FILL_RGB_BY_MEASUREMENT_TYPE[type]; + return new Color( + red, + green, + blue, + isSelected ? POLYGON_FILL_SELECTED_ALPHA : POLYGON_FILL_ALPHA + ); +}; + +const toPolygonAreaBadgeByGroupId = ( + nodeChainAnnotations: readonly NodeChainAnnotation[], + pointMarkerBadgeByPointId: Readonly< + Record + > +): Readonly> => { + const byGroupId: Record = {}; + + nodeChainAnnotations.forEach((group) => { + const firstNodeId = group.nodeIds[0] ?? null; + if (!firstNodeId) return; + + const badge = pointMarkerBadgeByPointId[firstNodeId]; + const badgeText = badge?.text?.trim(); + if (!badgeText) return; + + byGroupId[group.id] = { + text: badgeText, + backgroundColor: badge?.backgroundColor, + textColor: badge?.textColor, + }; + }); + + return byGroupId; +}; + +const toGroundPolygonPrimitives = ( + enabled: boolean, + focusedPolygonGroupId: string | null, + polygonPreviewGroups: ReturnType +): readonly PolygonPrimitiveRenderModel[] => + (enabled ? polygonPreviewGroups : []).map(({ group, vertexPoints }) => ({ + id: group.id, + vertexPoints, + fillColor: getPolygonFillColor( + group.type as typeof ANNOTATION_TYPE_AREA_GROUND, + group.id === focusedPolygonGroupId + ), + })); + +const toCoplanarPolygonPrimitives = ( + enabled: boolean, + focusedPolygonGroupId: string | null, + polygonPreviewGroups: + | ReturnType + | ReturnType, + type: + | typeof ANNOTATION_TYPE_AREA_VERTICAL + | typeof ANNOTATION_TYPE_AREA_PLANAR +): readonly PolygonPrimitiveRenderModel[] => + (enabled ? polygonPreviewGroups : []).map(({ group, vertexPoints }) => ({ + id: group.id, + vertexPoints, + fillColor: getPolygonFillColor(type, group.id === focusedPolygonGroupId), + })); + +export type NodeChainPreviewModelsOptions = { + enabled: boolean; + pointsById: ReadonlyMap; + focusedNodeChainAnnotationId: string | null; + activeNodeChainAnnotationId: string | null; + pointMarkerBadgeByPointId: Readonly< + Record + >; + candidateConnectionPreview: CandidateConnectionPreview | null; +}; + +export const useNodeChainPreviewModels = ( + nodeChainAnnotations: readonly NodeChainAnnotation[], + { + enabled, + pointsById, + focusedNodeChainAnnotationId, + activeNodeChainAnnotationId, + pointMarkerBadgeByPointId, + candidateConnectionPreview, + }: NodeChainPreviewModelsOptions +) => { + const verticalRectanglePreviewOppositeByGroupId = useMemo(() => { + if (!enabled || !candidateConnectionPreview) { + return undefined; + } + + const target = candidateConnectionPreview.targetPointECEF; + if (!target) { + return undefined; + } + + const activeGroup = activeNodeChainAnnotationId + ? nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId + ) + : null; + if (!activeGroup || activeGroup.closed) { + return undefined; + } + if (activeGroup.type !== ANNOTATION_TYPE_AREA_VERTICAL) { + return undefined; + } + if (activeGroup.nodeIds.length !== 1) { + return undefined; + } + + return { + [activeGroup.id]: Cartesian3.clone(target), + }; + }, [ + activeNodeChainAnnotationId, + enabled, + candidateConnectionPreview, + nodeChainAnnotations, + ]); + + const polylineMeasurements = useMemo( + () => + buildPolylinePreviewMeasurements({ + nodeChainAnnotations: [...nodeChainAnnotations], + pointsById, + verticalRectanglePreviewOppositeByGroupId, + }), + [ + verticalRectanglePreviewOppositeByGroupId, + nodeChainAnnotations, + pointsById, + ] + ); + + const polygonAreaBadgeByGroupId = useMemo( + () => + toPolygonAreaBadgeByGroupId( + nodeChainAnnotations, + pointMarkerBadgeByPointId + ), + [nodeChainAnnotations, pointMarkerBadgeByPointId] + ); + + const focusedPolygonGroupId = + focusedNodeChainAnnotationId ?? activeNodeChainAnnotationId; + + const groundPolygonPreviewGroups = useMemo( + () => + buildGroundPolygonPreviewGroups({ + nodeChainAnnotations: [...nodeChainAnnotations], + pointsById, + verticalRectanglePreviewOppositeByGroupId, + activeNodeChainAnnotationId: activeNodeChainAnnotationId, + candidateConnection: candidateConnectionPreview, + }), + [ + activeNodeChainAnnotationId, + verticalRectanglePreviewOppositeByGroupId, + candidateConnectionPreview, + nodeChainAnnotations, + pointsById, + ] + ); + + const verticalPolygonPreviewGroups = useMemo( + () => + buildVerticalPolygonPreviewGroups({ + nodeChainAnnotations: [...nodeChainAnnotations], + pointsById, + verticalRectanglePreviewOppositeByGroupId, + activeNodeChainAnnotationId: activeNodeChainAnnotationId, + candidateConnection: candidateConnectionPreview, + }), + [ + activeNodeChainAnnotationId, + verticalRectanglePreviewOppositeByGroupId, + candidateConnectionPreview, + nodeChainAnnotations, + pointsById, + ] + ); + + const planarPolygonPreviewGroups = useMemo( + () => + buildPlanarPolygonPreviewGroups({ + nodeChainAnnotations: [...nodeChainAnnotations], + pointsById, + verticalRectanglePreviewOppositeByGroupId, + activeNodeChainAnnotationId: activeNodeChainAnnotationId, + candidateConnection: candidateConnectionPreview, + }), + [ + activeNodeChainAnnotationId, + verticalRectanglePreviewOppositeByGroupId, + candidateConnectionPreview, + nodeChainAnnotations, + pointsById, + ] + ); + + const groundPolygonPrimitives = useMemo( + () => + toGroundPolygonPrimitives( + enabled, + focusedPolygonGroupId, + groundPolygonPreviewGroups + ), + [enabled, focusedPolygonGroupId, groundPolygonPreviewGroups] + ); + + const verticalPolygonPrimitives = useMemo( + () => + toCoplanarPolygonPrimitives( + enabled, + focusedPolygonGroupId, + verticalPolygonPreviewGroups, + ANNOTATION_TYPE_AREA_VERTICAL + ), + [enabled, focusedPolygonGroupId, verticalPolygonPreviewGroups] + ); + + const planarPolygonPrimitives = useMemo( + () => + toCoplanarPolygonPrimitives( + enabled, + focusedPolygonGroupId, + planarPolygonPreviewGroups, + ANNOTATION_TYPE_AREA_PLANAR + ), + [enabled, focusedPolygonGroupId, planarPolygonPreviewGroups] + ); + + const polygonClosurePreviewEdges = useMemo< + readonly EdgeSceneLineRenderModel[] + >(() => { + if ( + !enabled || + !candidateConnectionPreview || + !activeNodeChainAnnotationId + ) { + return []; + } + + const activeGroup = + nodeChainAnnotations.find( + (group) => group.id === activeNodeChainAnnotationId + ) ?? null; + if (!activeGroup || activeGroup.closed) { + return []; + } + if (activeGroup.type === ANNOTATION_TYPE_POLYLINE) { + return []; + } + if (activeGroup.nodeIds.length < 2) { + return []; + } + + const firstNodeId = activeGroup.nodeIds[0] ?? null; + const firstNodePosition = firstNodeId + ? pointsById.get(firstNodeId)?.geometryECEF + : null; + const previewTarget = candidateConnectionPreview.targetPointECEF; + if (!firstNodePosition || !previewTarget) { + return []; + } + if (Cartesian3.distanceSquared(firstNodePosition, previewTarget) <= 1e-6) { + return []; + } + + return [ + { + id: `${activeGroup.id}:preview-closure`, + start: Cartesian3.clone(previewTarget), + end: Cartesian3.clone(firstNodePosition), + stroke: POLYGON_PREVIEW_STROKE, + strokeWidth: POLYGON_PREVIEW_STROKE_WIDTH_PX, + dashed: false, + }, + ]; + }, [ + activeNodeChainAnnotationId, + enabled, + candidateConnectionPreview, + nodeChainAnnotations, + pointsById, + ]); + + const verticalPreviewEdges = useMemo( + () => + buildPolylinePreviewEdgeSegments(polylineMeasurements).map((segment) => ({ + id: segment.id, + start: Cartesian3.clone(segment.start), + end: Cartesian3.clone(segment.end), + stroke: POLYGON_PREVIEW_STROKE, + strokeWidth: POLYGON_PREVIEW_STROKE_WIDTH_PX, + dashed: false, + })), + [polylineMeasurements] + ); + + return { + focusedPolygonGroupId, + polylineMeasurements, + verticalPreviewEdges, + polygonAreaBadgeByGroupId, + groundPolygonPreviewGroups, + verticalPolygonPreviewGroups, + planarPolygonPreviewGroups, + groundPolygonPrimitives, + verticalPolygonPrimitives, + planarPolygonPrimitives, + polygonClosurePreviewEdges, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/usePointAnnotationIndex.ts b/libraries/mapping/annotations/provider/src/lib/context/render/usePointAnnotationIndex.ts new file mode 100644 index 0000000000..71aef2fe16 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/usePointAnnotationIndex.ts @@ -0,0 +1,32 @@ +import { useMemo } from "react"; + +import { + isPointAnnotationEntry, + type AnnotationCollection, + type PointAnnotationEntry, +} from "@carma-mapping/annotations/core"; + +export const usePointAnnotationIndex = (annotations: AnnotationCollection) => { + const points = useMemo( + () => annotations.filter(isPointAnnotationEntry), + [annotations] + ); + const pointIds = useMemo( + () => new Set(points.map((point) => point.id)), + [points] + ); + + const pointsById = useMemo(() => { + const map = new Map(); + points.forEach((point) => { + map.set(point.id, point); + }); + return map; + }, [points]); + + return { + points, + pointIds, + pointsById, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/render/usePointVisibilityState.ts b/libraries/mapping/annotations/provider/src/lib/context/render/usePointVisibilityState.ts new file mode 100644 index 0000000000..16a54f0d29 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/render/usePointVisibilityState.ts @@ -0,0 +1,26 @@ +import { + ANNOTATION_TYPE_DISTANCE, + type AnnotationMode, +} from "@carma-mapping/annotations/core"; + +export const derivePointVisibility = ( + hideMeasurementsOfType: ReadonlySet, + showLabels: boolean, + hideLabelsOfType: ReadonlySet +) => { + const showPoints = !hideMeasurementsOfType.has(ANNOTATION_TYPE_DISTANCE); + const showPointLabels = + showPoints && showLabels && !hideLabelsOfType.has(ANNOTATION_TYPE_DISTANCE); + + return { + showPoints, + showPointLabels, + }; +}; + +export const usePointVisibilityState = ( + hideMeasurementsOfType: ReadonlySet, + showLabels: boolean, + hideLabelsOfType: ReadonlySet +) => + derivePointVisibility(hideMeasurementsOfType, showLabels, hideLabelsOfType); diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/annotationSelection.types.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/annotationSelection.types.ts new file mode 100644 index 0000000000..2c2f86a950 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/annotationSelection.types.ts @@ -0,0 +1,4 @@ +export type AnnotationSelectionState = { + selectedAnnotationId?: string | null; + selectedAnnotationIds?: readonly string[]; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/index.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/index.ts new file mode 100644 index 0000000000..16fb417725 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/index.ts @@ -0,0 +1,7 @@ +export * from "./selectionSet"; +export * from "./useAnnotationSelection"; +export * from "./useAnnotationsSelectionState"; +export * from "./annotationSelection.types"; +export * from "./useClosedAreaSelectionState"; +export * from "./useRectangleSelectionOverlay"; +export * from "./useSelectionToolState"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/selectionSet.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/selectionSet.ts new file mode 100644 index 0000000000..a85011056f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/selectionSet.ts @@ -0,0 +1 @@ +export const getUniqueIds = (ids: string[]) => Array.from(new Set(ids)); diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationSelection.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationSelection.ts new file mode 100644 index 0000000000..b01136ff36 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationSelection.ts @@ -0,0 +1,581 @@ +import { + useCallback, + useEffect, + useMemo, + useState, + type Dispatch, + type SetStateAction, +} from "react"; + +import { type Scene } from "@carma/cesium"; +import { useStoreSelector } from "@carma-commons/react-store"; + +import { getUniqueIds } from "./selectionSet"; +import type { RectangleSelectionState } from "./useRectangleSelectionOverlay"; +import type { AnnotationSelectionState } from "./annotationSelection.types"; +import type { AnnotationSelectionStoreState, AnnotationsStore } from "../store"; + +const areIdListsEqual = ( + left: readonly string[], + right: readonly string[] +): boolean => { + if (left.length !== right.length) { + return false; + } + + return left.every((id, index) => id === right[index]); +}; + +const resolveSetStateAction = ( + action: SetStateAction, + previousValue: TValue +): TValue => + typeof action === "function" + ? (action as (previousValue: TValue) => TValue)(previousValue) + : action; + +const getPrimarySelectedAnnotationId = ( + selectedAnnotationIds: readonly string[] +): string | null => + selectedAnnotationIds[selectedAnnotationIds.length - 1] ?? null; + +export const useAnnotationSelection = ( + annotationsStore: AnnotationsStore, + scene: Scene | null, + selectableAnnotationIds: ReadonlySet +) => { + const { + selectedAnnotationIds, + previousSelectedAnnotationId, + selectionModeActive, + selectModeAdditive, + selectModeRectangle, + } = useStoreSelector(annotationsStore, (state) => state.selectionState); + const selectedAnnotationId = getPrimarySelectedAnnotationId( + selectedAnnotationIds + ); + const [selectModeShiftHeld, setSelectModeShiftHeld] = useState(false); + + const setSelectionState = useCallback( + ( + updater: + | AnnotationSelectionStoreState + | (( + previousState: AnnotationSelectionStoreState + ) => AnnotationSelectionStoreState) + ) => { + annotationsStore.setState((previousStoreState) => { + const nextSelectionState = + typeof updater === "function" + ? updater(previousStoreState.selectionState) + : updater; + + return Object.is(nextSelectionState, previousStoreState.selectionState) + ? previousStoreState + : { + ...previousStoreState, + selectionState: nextSelectionState, + }; + }); + }, + [annotationsStore] + ); + + const setSelectionModeActive = useCallback>>( + (nextValueOrUpdater) => { + setSelectionState((previousState) => { + const nextSelectionModeActive = resolveSetStateAction( + nextValueOrUpdater, + previousState.selectionModeActive + ); + + return nextSelectionModeActive === previousState.selectionModeActive + ? previousState + : { + ...previousState, + selectionModeActive: nextSelectionModeActive, + }; + }); + }, + [setSelectionState] + ); + + const setSelectModeAdditive = useCallback>>( + (nextValueOrUpdater) => { + setSelectionState((previousState) => { + const nextSelectModeAdditive = resolveSetStateAction( + nextValueOrUpdater, + previousState.selectModeAdditive + ); + + return nextSelectModeAdditive === previousState.selectModeAdditive + ? previousState + : { + ...previousState, + selectModeAdditive: nextSelectModeAdditive, + }; + }); + }, + [setSelectionState] + ); + + const setSelectModeRectangle = useCallback>>( + (nextValueOrUpdater) => { + setSelectionState((previousState) => { + const nextSelectModeRectangle = resolveSetStateAction( + nextValueOrUpdater, + previousState.selectModeRectangle + ); + + return nextSelectModeRectangle === previousState.selectModeRectangle + ? previousState + : { + ...previousState, + selectModeRectangle: nextSelectModeRectangle, + }; + }); + }, + [setSelectionState] + ); + + useEffect( + function effectTrackSelectionShiftKeyState() { + if (!selectionModeActive) { + setSelectModeShiftHeld(false); + return; + } + + const handleShiftKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Shift") { + return; + } + + setSelectModeShiftHeld((previousValue) => + previousValue ? previousValue : true + ); + }; + + const handleShiftKeyUp = (event: KeyboardEvent) => { + if (event.key !== "Shift") { + return; + } + + setSelectModeShiftHeld((previousValue) => + previousValue ? false : previousValue + ); + }; + + const handleWindowBlur = () => { + setSelectModeShiftHeld(false); + }; + + window.addEventListener("keydown", handleShiftKeyDown, true); + window.addEventListener("keyup", handleShiftKeyUp, true); + window.addEventListener("blur", handleWindowBlur, true); + + return () => { + window.removeEventListener("keydown", handleShiftKeyDown, true); + window.removeEventListener("keyup", handleShiftKeyUp, true); + window.removeEventListener("blur", handleWindowBlur, true); + }; + }, + [selectionModeActive] + ); + + const effectiveSelectModeAdditive = useMemo( + () => selectModeAdditive || (selectionModeActive && selectModeShiftHeld), + [selectModeAdditive, selectionModeActive, selectModeShiftHeld] + ); + + useEffect( + function effectToggleSelectionAdditiveCursor() { + if ( + !scene || + scene.isDestroyed() || + !selectionModeActive || + !effectiveSelectModeAdditive + ) { + return; + } + + const plusCursor = document.createElement("div"); + plusCursor.textContent = "+"; + plusCursor.style.position = "fixed"; + plusCursor.style.pointerEvents = "none"; + plusCursor.style.userSelect = "none"; + plusCursor.style.zIndex = "10000"; + plusCursor.style.fontSize = "16px"; + plusCursor.style.fontWeight = "700"; + plusCursor.style.lineHeight = "1"; + plusCursor.style.color = "rgba(255, 255, 255, 0.95)"; + plusCursor.style.textShadow = "0 0 2px rgba(0, 0, 0, 0.85)"; + plusCursor.style.display = "none"; + document.body.appendChild(plusCursor); + + const updatePlusCursorPosition = (event: PointerEvent) => { + const canvasRect = scene.canvas.getBoundingClientRect(); + const insideCanvas = + event.clientX >= canvasRect.left && + event.clientX <= canvasRect.right && + event.clientY >= canvasRect.top && + event.clientY <= canvasRect.bottom; + + if (!insideCanvas) { + plusCursor.style.display = "none"; + return; + } + + plusCursor.style.left = `${event.clientX + 10}px`; + plusCursor.style.top = `${event.clientY + 8}px`; + plusCursor.style.display = "block"; + }; + + const hidePlusCursor = () => { + plusCursor.style.display = "none"; + }; + + window.addEventListener("pointermove", updatePlusCursorPosition, true); + scene.canvas.addEventListener("pointerleave", hidePlusCursor); + window.addEventListener("blur", hidePlusCursor, true); + + return () => { + window.removeEventListener( + "pointermove", + updatePlusCursorPosition, + true + ); + scene.canvas.removeEventListener("pointerleave", hidePlusCursor); + window.removeEventListener("blur", hidePlusCursor, true); + plusCursor.remove(); + }; + }, + [scene, selectionModeActive, effectiveSelectModeAdditive] + ); + + useEffect( + function effectPruneSelectedAnnotationIds() { + setSelectionState((previousState) => { + if (previousState.selectedAnnotationIds.length === 0) { + return previousState; + } + + const nextSelectedAnnotationIds = + previousState.selectedAnnotationIds.filter((id) => + selectableAnnotationIds.has(id) + ); + const previousSelectedAnnotationId = getPrimarySelectedAnnotationId( + previousState.selectedAnnotationIds + ); + const nextSelectedAnnotationId = getPrimarySelectedAnnotationId( + nextSelectedAnnotationIds + ); + const nextPreviousSelectedAnnotationId = + previousState.previousSelectedAnnotationId && + !selectableAnnotationIds.has( + previousState.previousSelectedAnnotationId + ) + ? null + : previousState.previousSelectedAnnotationId; + + if ( + areIdListsEqual( + previousState.selectedAnnotationIds, + nextSelectedAnnotationIds + ) && + previousSelectedAnnotationId === nextSelectedAnnotationId && + previousState.previousSelectedAnnotationId === + nextPreviousSelectedAnnotationId + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: nextSelectedAnnotationIds, + previousSelectedAnnotationId: nextPreviousSelectedAnnotationId, + }; + }); + }, + [selectableAnnotationIds, setSelectionState] + ); + + const clearPointSelection = useCallback(() => { + setSelectionState((previousState) => { + if ( + previousState.selectedAnnotationIds.length === 0 && + previousState.previousSelectedAnnotationId === null + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: [], + previousSelectedAnnotationId: null, + }; + }); + }, [setSelectionState]); + + const clearAnnotationSelection = useCallback(() => { + clearPointSelection(); + }, [clearPointSelection]); + + const pruneSelectionByRemovedIds = useCallback( + (removedIds: ReadonlySet) => { + if (removedIds.size === 0) { + return; + } + + setSelectionState((previousState) => { + if (previousState.selectedAnnotationIds.length === 0) { + return previousState; + } + + const nextSelectedAnnotationIds = + previousState.selectedAnnotationIds.filter( + (id) => !removedIds.has(id) && selectableAnnotationIds.has(id) + ); + const previousSelectedAnnotationId = getPrimarySelectedAnnotationId( + previousState.selectedAnnotationIds + ); + const nextSelectedAnnotationId = getPrimarySelectedAnnotationId( + nextSelectedAnnotationIds + ); + const nextPreviousSelectedAnnotationId = + previousState.previousSelectedAnnotationId && + removedIds.has(previousState.previousSelectedAnnotationId) + ? null + : previousState.previousSelectedAnnotationId; + + if ( + areIdListsEqual( + previousState.selectedAnnotationIds, + nextSelectedAnnotationIds + ) && + previousSelectedAnnotationId === nextSelectedAnnotationId && + previousState.previousSelectedAnnotationId === + nextPreviousSelectedAnnotationId + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: nextSelectedAnnotationIds, + previousSelectedAnnotationId: nextPreviousSelectedAnnotationId, + }; + }); + }, + [selectableAnnotationIds, setSelectionState] + ); + + const selectAnnotationIds = useCallback( + (ids: string[], additive: boolean = false) => { + const uniqueIncomingIds = getUniqueIds( + ids.filter((id) => selectableAnnotationIds.has(id)) + ); + + setSelectionState((previousState) => { + const previousSelectedAnnotationId = getPrimarySelectedAnnotationId( + previousState.selectedAnnotationIds + ); + const nextSelectedAnnotationIds = additive + ? getUniqueIds([ + ...previousState.selectedAnnotationIds, + ...uniqueIncomingIds, + ]) + : uniqueIncomingIds; + const nextSelectedAnnotationId = getPrimarySelectedAnnotationId( + nextSelectedAnnotationIds + ); + const nextPreviousSelectedAnnotationId = + previousSelectedAnnotationId && + nextSelectedAnnotationId && + previousSelectedAnnotationId !== nextSelectedAnnotationId + ? previousSelectedAnnotationId + : previousState.previousSelectedAnnotationId; + + if ( + areIdListsEqual( + previousState.selectedAnnotationIds, + nextSelectedAnnotationIds + ) && + previousSelectedAnnotationId === nextSelectedAnnotationId && + previousState.previousSelectedAnnotationId === + nextPreviousSelectedAnnotationId + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: nextSelectedAnnotationIds, + previousSelectedAnnotationId: nextPreviousSelectedAnnotationId, + }; + }); + }, + [selectableAnnotationIds, setSelectionState] + ); + + const selectAnnotationById = useCallback( + (id: string | null) => { + if (id !== null && !selectableAnnotationIds.has(id)) { + return; + } + + setSelectionState((previousState) => { + const previousSelectedAnnotationId = getPrimarySelectedAnnotationId( + previousState.selectedAnnotationIds + ); + const nextSelectedAnnotationIds = + id === null + ? [] + : previousState.selectedAnnotationIds.length === 1 && + previousState.selectedAnnotationIds[0] === id + ? previousState.selectedAnnotationIds + : [id]; + const nextPreviousSelectedAnnotationId = + previousSelectedAnnotationId && + id && + previousSelectedAnnotationId !== id + ? previousSelectedAnnotationId + : previousState.previousSelectedAnnotationId; + + if ( + previousSelectedAnnotationId === id && + areIdListsEqual( + previousState.selectedAnnotationIds, + nextSelectedAnnotationIds + ) && + previousState.previousSelectedAnnotationId === + nextPreviousSelectedAnnotationId + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: nextSelectedAnnotationIds, + previousSelectedAnnotationId: nextPreviousSelectedAnnotationId, + }; + }); + }, + [selectableAnnotationIds, setSelectionState] + ); + + const selectAnnotationByIdImmediate = useCallback( + (id: string | null) => { + setSelectionState((previousState) => { + const previousSelectedAnnotationId = getPrimarySelectedAnnotationId( + previousState.selectedAnnotationIds + ); + const nextSelectedAnnotationIds = + id === null + ? [] + : previousState.selectedAnnotationIds.length === 1 && + previousState.selectedAnnotationIds[0] === id + ? previousState.selectedAnnotationIds + : [id]; + const nextPreviousSelectedAnnotationId = + previousSelectedAnnotationId && + id && + previousSelectedAnnotationId !== id + ? previousSelectedAnnotationId + : previousState.previousSelectedAnnotationId; + + if ( + previousSelectedAnnotationId === id && + areIdListsEqual( + previousState.selectedAnnotationIds, + nextSelectedAnnotationIds + ) && + previousState.previousSelectedAnnotationId === + nextPreviousSelectedAnnotationId + ) { + return previousState; + } + + return { + ...previousState, + selectedAnnotationIds: nextSelectedAnnotationIds, + previousSelectedAnnotationId: nextPreviousSelectedAnnotationId, + }; + }); + }, + [setSelectionState] + ); + + const annotationSelection = useMemo( + () => ({ + selectedAnnotationId, + selectedAnnotationIds, + }), + [selectedAnnotationId, selectedAnnotationIds] + ); + + const rectangleSelection = useMemo( + () => ({ + enabled: selectionModeActive && selectModeRectangle, + additiveMode: effectiveSelectModeAdditive, + onSelect: selectAnnotationIds, + }), + [ + effectiveSelectModeAdditive, + selectAnnotationIds, + selectModeRectangle, + selectionModeActive, + ] + ); + + const selectedDistancePair = useMemo(() => { + if (!selectedAnnotationId || !previousSelectedAnnotationId) { + return null; + } + + if (selectedAnnotationId === previousSelectedAnnotationId) { + return null; + } + + if ( + !selectableAnnotationIds.has(selectedAnnotationId) || + !selectableAnnotationIds.has(previousSelectedAnnotationId) + ) { + return null; + } + + return { + activePointId: selectedAnnotationId, + previousPointId: previousSelectedAnnotationId, + }; + }, [ + previousSelectedAnnotationId, + selectableAnnotationIds, + selectedAnnotationId, + ]); + + return { + selectedAnnotationId, + selectedAnnotationIds, + previousSelectedAnnotationId, + selectionModeActive, + setSelectionModeActive, + selectModeAdditive, + setSelectModeAdditive, + selectModeRectangle, + setSelectModeRectangle, + effectiveSelectModeAdditive, + annotationSelection, + rectangleSelection, + selectedDistancePair, + selectAnnotationIds, + selectAnnotationById, + selectAnnotationByIdImmediate, + clearPointSelection, + clearAnnotationSelection, + pruneSelectionByRemovedIds, + }; +}; + +export type AnnotationSelectionController = ReturnType< + typeof useAnnotationSelection +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationsSelectionState.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationsSelectionState.ts new file mode 100644 index 0000000000..9a6efd639f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useAnnotationsSelectionState.ts @@ -0,0 +1,37 @@ +import type { Scene } from "@carma/cesium"; + +import { useFocusedNodeChainAnnotationId } from "./useFocusedNodeChainAnnotationId"; +import { + useAnnotationSelection, + type AnnotationSelectionController, +} from "./useAnnotationSelection"; +import type { AnnotationsStore } from "../store"; + +export type AnnotationsSelectionState = AnnotationSelectionController & { + focusedNodeChainAnnotationId: string | null; +}; + +export const useAnnotationsSelectionState = ( + annotationsStore: AnnotationsStore, + scene: Scene | null, + selectableAnnotationIds: ReadonlySet, + getOwnerGroupIdsForPointId: (pointId: string) => readonly string[], + activeNodeChainAnnotationId: string | null +): AnnotationsSelectionState => { + const annotationSelection = useAnnotationSelection( + annotationsStore, + scene, + selectableAnnotationIds + ); + const focusedNodeChainAnnotationId = useFocusedNodeChainAnnotationId( + annotationSelection.selectedAnnotationId, + annotationSelection.selectedAnnotationIds, + getOwnerGroupIdsForPointId, + activeNodeChainAnnotationId + ); + + return { + ...annotationSelection, + focusedNodeChainAnnotationId, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/useClosedAreaSelectionState.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useClosedAreaSelectionState.ts new file mode 100644 index 0000000000..b6c55e01fd --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useClosedAreaSelectionState.ts @@ -0,0 +1,81 @@ +import { useMemo } from "react"; + +import type { NodeChainAnnotation } from "@carma-mapping/annotations/core"; + +export const useClosedAreaSelectionState = ( + nodeChainAnnotations: readonly NodeChainAnnotation[], + focusedNodeChainAnnotationId: string | null, + activeNodeChainAnnotationId: string | null +) => { + const selectedClosedAreaGroupIdSet = useMemo(() => { + const ids = new Set(); + if (!focusedNodeChainAnnotationId && !activeNodeChainAnnotationId) { + return ids; + } + + nodeChainAnnotations.forEach((group) => { + if (!group.closed) { + return; + } + if ( + group.id === focusedNodeChainAnnotationId || + group.id === activeNodeChainAnnotationId + ) { + ids.add(group.id); + } + }); + return ids; + }, [ + activeNodeChainAnnotationId, + focusedNodeChainAnnotationId, + nodeChainAnnotations, + ]); + + const closedAreaNodeIdSet = useMemo(() => { + const ids = new Set(); + nodeChainAnnotations.forEach((group) => { + if (!group.closed) { + return; + } + group.nodeIds.forEach((pointId) => { + if (pointId) { + ids.add(pointId); + } + }); + }); + return ids; + }, [nodeChainAnnotations]); + + const selectedClosedAreaNodeIdSet = useMemo(() => { + const ids = new Set(); + if (selectedClosedAreaGroupIdSet.size === 0) { + return ids; + } + + nodeChainAnnotations.forEach((group) => { + if (!group.closed || !selectedClosedAreaGroupIdSet.has(group.id)) { + return; + } + group.nodeIds.forEach((pointId) => { + if (pointId) { + ids.add(pointId); + } + }); + }); + return ids; + }, [nodeChainAnnotations, selectedClosedAreaGroupIdSet]); + + const unselectedClosedAreaNodeIdSet = useMemo(() => { + const ids = new Set(); + closedAreaNodeIdSet.forEach((pointId) => { + if (!selectedClosedAreaNodeIdSet.has(pointId)) { + ids.add(pointId); + } + }); + return ids; + }, [closedAreaNodeIdSet, selectedClosedAreaNodeIdSet]); + + return { + unselectedClosedAreaNodeIdSet, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/useFocusedNodeChainAnnotationId.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useFocusedNodeChainAnnotationId.ts new file mode 100644 index 0000000000..2051945ff0 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useFocusedNodeChainAnnotationId.ts @@ -0,0 +1,25 @@ +import { useMemo } from "react"; + +export const useFocusedNodeChainAnnotationId = ( + selectedAnnotationId: string | null, + selectedAnnotationIds: string[], + getOwnerGroupIdsForPointId: (pointId: string) => readonly string[], + activeNodeChainAnnotationId: string | null +) => { + const focusedSelectedNodeChainAnnotationId = useMemo(() => { + if (selectedAnnotationId) { + return getOwnerGroupIdsForPointId(selectedAnnotationId)[0] ?? null; + } + + for (const selectedId of selectedAnnotationIds) { + const ownerGroupId = getOwnerGroupIdsForPointId(selectedId)[0] ?? null; + if (ownerGroupId) { + return ownerGroupId; + } + } + + return null; + }, [getOwnerGroupIdsForPointId, selectedAnnotationId, selectedAnnotationIds]); + + return focusedSelectedNodeChainAnnotationId ?? activeNodeChainAnnotationId; +}; diff --git a/libraries/mapping/annotations/cesium/src/lib/hooks/usePointRectangleSelectionOverlay.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useRectangleSelectionOverlay.ts similarity index 83% rename from libraries/mapping/annotations/cesium/src/lib/hooks/usePointRectangleSelectionOverlay.ts rename to libraries/mapping/annotations/provider/src/lib/context/selection/useRectangleSelectionOverlay.ts index cf6df9b9e2..d41487100c 100644 --- a/libraries/mapping/annotations/cesium/src/lib/hooks/usePointRectangleSelectionOverlay.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useRectangleSelectionOverlay.ts @@ -1,6 +1,6 @@ import { useEffect, useRef } from "react"; -import { type Scene } from "@carma/cesium"; +import { isValidScene, type Scene } from "@carma/cesium"; import type { CssPixelPosition, CssPixels } from "@carma/units/types"; import { POINT_LABEL_SELECTED_BACKGROUND_COLOR, @@ -13,23 +13,19 @@ import { selectPointIdsInScreenRectangle, } from "@carma-mapping/annotations/core"; -type UsePointRectangleSelectionOverlayParams = { - scene: Scene | null; +export type RectangleSelectionState = { enabled: boolean; additiveMode: boolean; - points: PointLabelData[]; onSelect: (pointIds: string[], additive: boolean) => void; }; const MIN_RECTANGLE_SELECTION_SIZE_PX = 4; -export const usePointRectangleSelectionOverlay = ({ - scene, - enabled, - additiveMode, - points, - onSelect, -}: UsePointRectangleSelectionOverlayParams) => { +export const useRectangleSelectionOverlay = ( + scene: Scene | null, + points: PointLabelData[], + { enabled, additiveMode, onSelect }: RectangleSelectionState +) => { const pointsRef = useRef(points); const onSelectRef = useRef(onSelect); const additiveModeRef = useRef(additiveMode); @@ -47,7 +43,7 @@ export const usePointRectangleSelectionOverlay = ({ }, [additiveMode]); useEffect(() => { - if (!scene || scene.isDestroyed() || !enabled) { + if (!isValidScene(scene) || !enabled) { return; } @@ -219,82 +215,69 @@ export const usePointRectangleSelectionOverlay = ({ }; const handlePointerMove = (event: PointerEvent) => { - if (activePointerId === null || event.pointerId !== activePointerId) { + if (activePointerId !== event.pointerId || !dragStart) { return; } - stopDragEvent(event); - if (!dragStart) return; - const nextDragAdditive = event.shiftKey || additiveModeRef.current; - if (nextDragAdditive !== dragAdditive) { - dragAdditive = nextDragAdditive; - lastSelectionSignature = null; - } - dragCurrent = getOverlayLocalPoint(event); + dragCurrent = getOverlayLocalPoint(event); const rectangle = buildScreenRectangle(dragStart, dragCurrent); const size = getScreenRectangleSize(rectangle); - if ( - !isDragging && - (size.width >= MIN_RECTANGLE_SELECTION_SIZE_PX || - size.height >= MIN_RECTANGLE_SELECTION_SIZE_PX) - ) { + const shouldDrag = + size.width >= MIN_RECTANGLE_SELECTION_SIZE_PX && + size.height >= MIN_RECTANGLE_SELECTION_SIZE_PX; + + if (shouldDrag) { isDragging = true; setCameraInputsSuppressed(true); - } - if (isDragging) { updateOverlay(); emitLiveSelection(); + } else { + selectionOverlay.style.display = "none"; } + + stopDragEvent(event); }; - const handlePointerUp = (event: PointerEvent) => { - if (activePointerId === null || event.pointerId !== activePointerId) { - return; - } + const finalizeDrag = () => { if (isDragging) { - stopDragEvent(event); emitLiveSelection(); } resetDrag(); }; - const handlePointerCancel = (event: PointerEvent) => { - if (activePointerId !== null && event.pointerId === activePointerId) { - if (isDragging) { - stopDragEvent(event); - } - resetDrag(); - } + const handlePointerUp = (event: PointerEvent) => { + if (activePointerId !== event.pointerId) return; + stopDragEvent(event); + finalizeDrag(); }; - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key !== "Escape") return; - if (activePointerId === null) return; - if (isDragging) { - stopDragEvent(event); - } + const handlePointerCancel = (event: PointerEvent) => { + if (activePointerId !== event.pointerId) return; + stopDragEvent(event); resetDrag(); }; - window.addEventListener("pointerdown", handlePointerDown, true); + overlayContainer.addEventListener("pointerdown", handlePointerDown, true); window.addEventListener("pointermove", handlePointerMove, true); window.addEventListener("pointerup", handlePointerUp, true); window.addEventListener("pointercancel", handlePointerCancel, true); - window.addEventListener("keydown", handleKeyDown, true); return () => { - window.removeEventListener("pointerdown", handlePointerDown, true); + overlayContainer.removeEventListener( + "pointerdown", + handlePointerDown, + true + ); window.removeEventListener("pointermove", handlePointerMove, true); window.removeEventListener("pointerup", handlePointerUp, true); window.removeEventListener("pointercancel", handlePointerCancel, true); - window.removeEventListener("keydown", handleKeyDown, true); - setCameraInputsSuppressed(false); + resetDrag(); + selectionOverlay.remove(); overlayContainer.style.pointerEvents = previousPointerEvents; overlayContainer.style.touchAction = previousTouchAction; - overlayContainer.style.cursor = previousCursor; overlayContainer.style.userSelect = previousUserSelect; + overlayContainer.style.cursor = previousCursor; canvas.style.cursor = previousCanvasCursor; - selectionOverlay.remove(); }; - }, [scene, enabled]); + }, [additiveMode, enabled, onSelect, points, scene]); }; diff --git a/libraries/mapping/annotations/provider/src/lib/context/selection/useSelectedDistanceRelationState.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useSelectedDistanceRelationState.ts new file mode 100644 index 0000000000..a2152ebbb2 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useSelectedDistanceRelationState.ts @@ -0,0 +1,48 @@ +import { useMemo } from "react"; + +import { + isSameDistanceRelationPair, + type PointDistanceRelation, +} from "@carma-mapping/annotations/core"; + +type SelectedDistancePair = { + activePointId: string; + previousPointId: string; +} | null; + +export const useSelectedDistanceRelationState = ( + distanceRelations: PointDistanceRelation[], + selectedDistancePair: SelectedDistancePair +) => { + const selectedDistanceRelation = useMemo(() => { + if (!selectedDistancePair) return null; + return ( + distanceRelations.find((relation) => + isSameDistanceRelationPair( + relation, + selectedDistancePair.activePointId, + selectedDistancePair.previousPointId + ) + ) ?? null + ); + }, [distanceRelations, selectedDistancePair]); + + const showSelectedReferenceLine = + selectedDistanceRelation?.showDirectLine ?? false; + const selectedVerticalLineVisible = + selectedDistanceRelation?.showVerticalLine ?? + selectedDistanceRelation?.showComponentLines ?? + false; + const selectedHorizontalLineVisible = + selectedDistanceRelation?.showHorizontalLine ?? + selectedDistanceRelation?.showComponentLines ?? + false; + const showSelectedReferenceLineComponents = + selectedVerticalLineVisible || selectedHorizontalLineVisible; + + return { + selectedDistanceRelation, + showSelectedReferenceLine, + showSelectedReferenceLineComponents, + }; +}; diff --git a/libraries/mapping/annotations/core/src/lib/tools/useSelectionToolState.ts b/libraries/mapping/annotations/provider/src/lib/context/selection/useSelectionToolState.ts similarity index 93% rename from libraries/mapping/annotations/core/src/lib/tools/useSelectionToolState.ts rename to libraries/mapping/annotations/provider/src/lib/context/selection/useSelectionToolState.ts index d6382e5483..aa2bc50a1d 100644 --- a/libraries/mapping/annotations/core/src/lib/tools/useSelectionToolState.ts +++ b/libraries/mapping/annotations/provider/src/lib/context/selection/useSelectionToolState.ts @@ -17,8 +17,12 @@ export type SelectionToolState = { effectiveSelectModeAdditive: boolean; }; -export const useSelectionToolState = (): SelectionToolState => { - const [selectionModeActive, setSelectionModeActive] = useState(false); +export const useSelectionToolState = ( + initialSelectionModeActive: boolean = false +): SelectionToolState => { + const [selectionModeActive, setSelectionModeActive] = useState( + initialSelectionModeActive + ); const [selectModeAdditive, setSelectModeAdditive] = useState(false); const [selectModeRectangle, setSelectModeRectangle] = useState(false); const [selectModeShiftHeld, setSelectModeShiftHeld] = useState(false); diff --git a/libraries/mapping/annotations/provider/src/lib/context/store/annotationsStore.types.ts b/libraries/mapping/annotations/provider/src/lib/context/store/annotationsStore.types.ts new file mode 100644 index 0000000000..2d369a70f0 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/store/annotationsStore.types.ts @@ -0,0 +1,87 @@ +import type { Cartesian3 } from "@carma/cesium"; +import type { Store } from "@carma-commons/react-store"; + +import type { + AnnotationCollection, + AnnotationEntry, + AnnotationToolType, + DirectLineLabelMode, + LinearSegmentLineMode, + NodeChainAnnotation, + PointDistanceRelation, + ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; + +import type { AnnotationsContextType } from "../annotationsContext.types"; +import type { + AnnotationEditTarget, + MoveGizmoSession, +} from "../interaction/editing/annotationEdit.types"; + +export type AnnotationsStoreSnapshot = { + tools: AnnotationsContextType["tools"]; + selection: AnnotationsContextType["selection"]; + annotations: AnnotationsContextType["annotations"]; + edit: AnnotationsContextType["edit"]; + settings: AnnotationsContextType["settings"]; +}; + +export type AnnotationSelectionStoreState = { + selectedAnnotationIds: string[]; + previousSelectedAnnotationId: string | null; + selectionModeActive: boolean; + selectModeAdditive: boolean; + selectModeRectangle: boolean; +}; + +export type AnnotationSettingsStoreState = { + pointQuery: { + radius: number; + heightOffset: number; + }; + point: { + verticalOffsetMeters: number; + temporaryMode: boolean; + }; + distance: { + stickyToFirstPoint: boolean; + creationLineVisibility: { + direct: boolean; + vertical: boolean; + horizontal: boolean; + }; + defaultLabelVisibilityByKind: Record; + defaultDirectLineLabelMode: DirectLineLabelMode; + }; + polyline: { + defaultVerticalOffsetMeters: number; + defaultSegmentLineMode: LinearSegmentLineMode; + }; +}; + +export type AnnotationEditStoreState = { + activeTarget: AnnotationEditTarget | null; + moveGizmo: MoveGizmoSession; +}; + +export type AnnotationsStoreState = AnnotationsStoreSnapshot & { + annotationToolType: AnnotationToolType; + selectionState: AnnotationSelectionStoreState; + createdPointIds: readonly string[]; + createdRelationIds: readonly string[]; + activeNodeChainAnnotationId: string | null; + pendingLabelPlacementAnnotationId: string | null; + openChainPointId: string | null; + pendingPolylineRingPromotionPointId: string | null; + settingsState: AnnotationSettingsStoreState; + showLabels: boolean; + occlusionChecksEnabled: boolean; + editState: AnnotationEditStoreState; + annotationEntries: AnnotationCollection; + candidateAnnotation: AnnotationEntry | null; + referencePoint: Cartesian3 | null; + distanceRelations: PointDistanceRelation[]; + nodeChainAnnotations: NodeChainAnnotation[]; +}; + +export type AnnotationsStore = Store; diff --git a/libraries/mapping/annotations/provider/src/lib/context/store/createAnnotationsStore.ts b/libraries/mapping/annotations/provider/src/lib/context/store/createAnnotationsStore.ts new file mode 100644 index 0000000000..40a76ed82b --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/store/createAnnotationsStore.ts @@ -0,0 +1,258 @@ +import { createStore } from "@carma-commons/react-store"; +import { + DEFAULT_LINEAR_SEGMENT_LINE_MODE, + SELECT_TOOL_TYPE, +} from "@carma-mapping/annotations/core"; +import type { + AnnotationEntry, + AnnotationMode, + AnnotationToolType, + DirectLineLabelMode, + LinearSegmentLineMode, + ReferenceLineLabelKind, +} from "@carma-mapping/annotations/core"; +import type { + AnnotationEditStoreState, + AnnotationSettingsStoreState, + AnnotationsStore, + AnnotationsStoreState, + AnnotationsStoreSnapshot, +} from "./annotationsStore.types"; + +type CreateInitialAnnotationsStoreStateOptions = { + initialToolType?: AnnotationToolType; + initialPointRadius?: number; + initialPointVerticalOffsetMeters?: number; + initialPointTemporaryMode?: boolean; + initialDistanceStickyToFirstPoint?: boolean; + initialDistanceCreationLineVisibility?: Partial< + Record + >; + initialDistanceLabelVisibilityByKind?: Partial< + Record + >; + initialDistanceDirectLineLabelMode?: DirectLineLabelMode; + initialPolylineVerticalOffsetMeters?: number; + initialPolylineSegmentLineMode?: LinearSegmentLineMode; + initialHeightOffset?: number; +}; + +const noop = () => undefined; +const noopString = () => ""; +const noopNumber = () => 0; +const noopNull = () => null; + +const DEFAULT_DISTANCE_TOOL_SETTINGS = { + stickyToFirstPoint: false, + creationLineVisibility: { + direct: true, + vertical: true, + horizontal: true, + }, + defaultLabelVisibilityByKind: { + direct: true, + vertical: true, + horizontal: true, + } satisfies Record, + defaultDirectLineLabelMode: "segment" as DirectLineLabelMode, +}; + +const createInitialSettingsState = ({ + initialPointRadius = 1, + initialPointVerticalOffsetMeters = 0, + initialPointTemporaryMode = false, + initialDistanceStickyToFirstPoint = DEFAULT_DISTANCE_TOOL_SETTINGS.stickyToFirstPoint, + initialDistanceCreationLineVisibility, + initialDistanceLabelVisibilityByKind, + initialDistanceDirectLineLabelMode = DEFAULT_DISTANCE_TOOL_SETTINGS.defaultDirectLineLabelMode, + initialPolylineVerticalOffsetMeters = 0, + initialPolylineSegmentLineMode = DEFAULT_LINEAR_SEGMENT_LINE_MODE, + initialHeightOffset = 1.5, +}: CreateInitialAnnotationsStoreStateOptions): AnnotationSettingsStoreState => ({ + pointQuery: { + radius: initialPointRadius, + heightOffset: initialHeightOffset, + }, + point: { + verticalOffsetMeters: initialPointVerticalOffsetMeters, + temporaryMode: initialPointTemporaryMode, + }, + distance: { + stickyToFirstPoint: initialDistanceStickyToFirstPoint, + creationLineVisibility: { + ...DEFAULT_DISTANCE_TOOL_SETTINGS.creationLineVisibility, + ...(initialDistanceCreationLineVisibility ?? {}), + }, + defaultLabelVisibilityByKind: { + ...DEFAULT_DISTANCE_TOOL_SETTINGS.defaultLabelVisibilityByKind, + ...(initialDistanceLabelVisibilityByKind ?? {}), + }, + defaultDirectLineLabelMode: initialDistanceDirectLineLabelMode, + }, + polyline: { + defaultVerticalOffsetMeters: initialPolylineVerticalOffsetMeters, + defaultSegmentLineMode: initialPolylineSegmentLineMode, + }, +}); + +const createInitialAnnotationsSnapshot = ( + initialToolType: AnnotationToolType, + initialSettingsState: AnnotationSettingsStoreState +): AnnotationsStoreSnapshot => ({ + tools: { + activeToolType: initialToolType, + requestModeChange: noop, + requestStartMeasurement: noop, + requestCloseActiveMeasurement: noop, + }, + selection: { + activeAnnotationId: null, + ids: [], + mode: { + active: false, + additive: false, + rectangle: false, + }, + setModeActive: noop, + setAdditiveMode: noop, + setRectangleMode: noop, + set: noop, + clear: noop, + }, + annotations: { + items: [], + byType: (() => []) as (type: AnnotationMode) => AnnotationEntry[], + getNavigationItems: () => [], + getIndexByType: noopNumber, + getOrderByType: noopNull, + getNextOrderByType: noopNumber, + add: noopString, + updateById: noop, + updateNameById: noop, + updateVisualizerOptionsById: noop, + updatePointLabelAppearanceById: noop, + removeByIds: noop, + removeSelection: noop, + removeAll: noop, + removeByType: noop, + toggleLockByIds: noop, + toggleVisibilityByIds: noop, + setReferencePointId: noop, + confirmLabelPlacementById: noop, + flyToById: noop, + focusById: noop, + flyToAll: noop, + }, + edit: { + activeTarget: null, + requestStart: noop, + requestStop: noop, + requestUpdateTarget: (() => false) as (target: unknown) => boolean, + }, + settings: { + point: { + verticalOffsetMeters: initialSettingsState.point.verticalOffsetMeters, + setVerticalOffsetMeters: noop, + temporaryMode: initialSettingsState.point.temporaryMode, + setTemporaryMode: noop, + }, + distance: { + stickyToFirstPoint: initialSettingsState.distance.stickyToFirstPoint, + setStickyToFirstPoint: noop, + creationLineVisibility: + initialSettingsState.distance.creationLineVisibility, + setCreationLineVisibilityByKind: noop, + }, + polyline: { + verticalOffsetMeters: + initialSettingsState.polyline.defaultVerticalOffsetMeters, + setVerticalOffsetMeters: noop, + segmentLineMode: initialSettingsState.polyline.defaultSegmentLineMode, + setSegmentLineMode: noop, + }, + }, +}); + +const createInitialEditState = (): AnnotationEditStoreState => ({ + activeTarget: null, + moveGizmo: { + pointId: null, + axisDirection: null, + axisTitle: null, + axisCandidates: null, + preferredAxisId: null, + verticalOffsetEditMode: null, + verticalOffsetNodeChainAnnotationId: null, + isDragging: false, + }, +}); + +export const createInitialAnnotationsStoreState = ({ + initialToolType = "point", + initialPointRadius = 1, + initialPointVerticalOffsetMeters = 0, + initialPointTemporaryMode = false, + initialDistanceStickyToFirstPoint, + initialDistanceCreationLineVisibility, + initialDistanceLabelVisibilityByKind, + initialDistanceDirectLineLabelMode, + initialPolylineVerticalOffsetMeters = 0, + initialPolylineSegmentLineMode = DEFAULT_LINEAR_SEGMENT_LINE_MODE, + initialHeightOffset = 1.5, +}: CreateInitialAnnotationsStoreStateOptions = {}): AnnotationsStoreState => { + const initialSettingsState = createInitialSettingsState({ + initialPointRadius, + initialPointVerticalOffsetMeters, + initialPointTemporaryMode, + initialDistanceStickyToFirstPoint, + initialDistanceCreationLineVisibility, + initialDistanceLabelVisibilityByKind, + initialDistanceDirectLineLabelMode, + initialPolylineVerticalOffsetMeters, + initialPolylineSegmentLineMode, + initialHeightOffset, + }); + const initialSelectionModeActive = initialToolType === SELECT_TOOL_TYPE; + const initialSnapshot = createInitialAnnotationsSnapshot( + initialToolType, + initialSettingsState + ); + + return { + ...initialSnapshot, + selection: { + ...initialSnapshot.selection, + mode: { + ...initialSnapshot.selection.mode, + active: initialSelectionModeActive, + }, + }, + annotationToolType: initialToolType, + selectionState: { + selectedAnnotationIds: [], + previousSelectedAnnotationId: null, + selectionModeActive: initialSelectionModeActive, + selectModeAdditive: false, + selectModeRectangle: false, + }, + createdPointIds: [], + createdRelationIds: [], + activeNodeChainAnnotationId: null, + pendingLabelPlacementAnnotationId: null, + openChainPointId: null, + pendingPolylineRingPromotionPointId: null, + settingsState: initialSettingsState, + showLabels: true, + occlusionChecksEnabled: true, + editState: createInitialEditState(), + annotationEntries: [], + candidateAnnotation: null, + referencePoint: null, + distanceRelations: [], + nodeChainAnnotations: [], + }; +}; + +export const createAnnotationsStore = ( + initialState: AnnotationsStoreState +): AnnotationsStore => createStore(initialState); diff --git a/libraries/mapping/annotations/provider/src/lib/context/store/index.ts b/libraries/mapping/annotations/provider/src/lib/context/store/index.ts new file mode 100644 index 0000000000..da3c063924 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/store/index.ts @@ -0,0 +1,2 @@ +export * from "./annotationsStore.types"; +export * from "./createAnnotationsStore"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/index.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/index.ts new file mode 100644 index 0000000000..712173ecb4 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/index.ts @@ -0,0 +1,2 @@ +export * from "./useLockedAnnotationIdSet"; +export * from "./useAnnotationTopologyIndex"; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/point/usePointMeasurementCollections.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/point/usePointMeasurementCollections.ts new file mode 100644 index 0000000000..64be36d5e6 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/point/usePointMeasurementCollections.ts @@ -0,0 +1,29 @@ +import { useMemo } from "react"; + +import { + isPointMeasurementEntry, + type AnnotationCollection, +} from "@carma-mapping/annotations/core"; + +import { usePointAnnotationIndex } from "../../render/usePointAnnotationIndex"; + +export const usePointMeasurementCollections = ( + annotations: AnnotationCollection +) => { + const { points: pointEntries, pointIds: selectablePointIds } = + usePointAnnotationIndex(annotations); + const pointMeasureEntries = useMemo( + () => annotations.filter(isPointMeasurementEntry), + [annotations] + ); + + return { + pointEntries, + pointMeasureEntries, + selectablePointIds, + }; +}; + +export type PointMeasurementCollections = ReturnType< + typeof usePointMeasurementCollections +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useAnnotationsPolylineState.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useAnnotationsPolylineState.ts new file mode 100644 index 0000000000..4c5fa05680 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useAnnotationsPolylineState.ts @@ -0,0 +1,164 @@ +import { useMemo } from "react"; + +import { Cartesian3 } from "@carma/cesium"; +import { + isPointAnnotationEntry, + type AnnotationCollection, + type DerivedPolylinePath, +} from "@carma-mapping/annotations/core"; + +type UseAnnotationsPolylineStateOptions = { + focusedNodeChainAnnotationId: string | null; + referencePoint: Cartesian3 | null; + referenceElevation: number; +}; + +export const useAnnotationsPolylineState = ( + annotations: AnnotationCollection, + polylines: DerivedPolylinePath[], + { + focusedNodeChainAnnotationId, + referencePoint, + referenceElevation, + }: UseAnnotationsPolylineStateOptions +) => { + const focusedPolyline = useMemo(() => { + if (!focusedNodeChainAnnotationId) { + return null; + } + + return ( + polylines.find( + (polyline) => polyline.id === focusedNodeChainAnnotationId + ) ?? null + ); + }, [focusedNodeChainAnnotationId, polylines]); + + const focusedPolylineStartPointId = + focusedPolyline?.distanceMeasurementStartPointId ?? + focusedPolyline?.nodeIds[0] ?? + null; + + const focusedPolylineDistanceToStartByPointId = useMemo(() => { + if (!focusedPolyline) { + return {}; + } + + const byId: Record = {}; + focusedPolyline.nodeIds.forEach((pointId, index) => { + byId[pointId] = + focusedPolyline.segmentLengthsCumulativeMeters[index] ?? 0; + }); + return byId; + }, [focusedPolyline]); + + const cumulativeDistanceByRelationId = useMemo(() => { + const byRelationId: Record = {}; + polylines.forEach((polyline) => { + polyline.edgeRelationIds.forEach((relationId, segmentIndex) => { + byRelationId[relationId] = + polyline.segmentLengthsCumulativeMeters[segmentIndex + 1] ?? + polyline.segmentLengthsCumulativeMeters[segmentIndex] ?? + 0; + }); + }); + return byRelationId; + }, [polylines]); + + const effectiveReferenceElevation = useMemo(() => { + if (!focusedPolylineStartPointId) { + return referenceElevation; + } + + if (focusedPolyline) { + const focusedStartPointIndex = focusedPolyline.nodeIds.findIndex( + (pointId) => pointId === focusedPolylineStartPointId + ); + if (focusedStartPointIndex >= 0) { + return focusedPolyline.nodeHeightsMeters[focusedStartPointIndex] ?? 0; + } + } + + return referenceElevation; + }, [focusedPolyline, focusedPolylineStartPointId, referenceElevation]); + + const distanceToReferenceByPointId = useMemo(() => { + if (!referencePoint) { + return {}; + } + + const distances: Record = {}; + annotations.forEach((measurement) => { + if (!isPointAnnotationEntry(measurement)) { + return; + } + distances[measurement.id] = Cartesian3.distance( + measurement.geometryECEF, + referencePoint + ); + }); + return distances; + }, [annotations, referencePoint]); + + const effectiveDistanceToReferenceByPointId = useMemo(() => { + if (!focusedPolyline) { + return distanceToReferenceByPointId; + } + + return { + ...distanceToReferenceByPointId, + ...focusedPolylineDistanceToStartByPointId, + }; + }, [ + distanceToReferenceByPointId, + focusedPolyline, + focusedPolylineDistanceToStartByPointId, + ]); + + const unfocusedPolylineMarkerOnlyPointIds = useMemo(() => { + const ids = new Set(); + polylines.forEach((polyline) => { + if (polyline.id === focusedNodeChainAnnotationId) { + return; + } + const first = polyline.nodeIds[0]; + const last = polyline.nodeIds[polyline.nodeIds.length - 1]; + if (first && first !== last) { + ids.add(first); + } + }); + return ids; + }, [focusedNodeChainAnnotationId, polylines]); + + const unfocusedPolylineInteriorIds = useMemo(() => { + const ids = new Set(); + polylines.forEach((polyline) => { + if (polyline.id === focusedNodeChainAnnotationId) { + return; + } + polyline.nodeIds.forEach((pointId, index) => { + if (index === 0 || index === polyline.nodeIds.length - 1) { + return; + } + ids.add(pointId); + }); + }); + return ids; + }, [focusedNodeChainAnnotationId, polylines]); + + const unfocusedPolylineNonLastIds = useMemo(() => { + const ids = new Set(unfocusedPolylineMarkerOnlyPointIds); + unfocusedPolylineInteriorIds.forEach((pointId) => { + ids.add(pointId); + }); + return ids; + }, [unfocusedPolylineInteriorIds, unfocusedPolylineMarkerOnlyPointIds]); + + return { + focusedPolylineDistanceToStartByPointId, + cumulativeDistanceByRelationId, + effectiveReferenceElevation, + effectiveDistanceToReferenceByPointId, + unfocusedPolylineNonLastIds, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useDerivedPolylineState.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useDerivedPolylineState.ts new file mode 100644 index 0000000000..408c8724ee --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/useDerivedPolylineState.ts @@ -0,0 +1,45 @@ +import { useMemo } from "react"; +import { Cartesian3, type Scene } from "@carma/cesium"; +import { + buildDerivedPolylinePaths, + type AnnotationCollection, + type DerivedPolylinePath, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +export const useDerivedPolylineState = ( + scene: Scene, + annotations: AnnotationCollection, + nodeChainAnnotations: NodeChainAnnotation[], + defaultVerticalOffsetMeters: number, + useOffsetAnchors: boolean, + referencePoint: Cartesian3 | null +): { polylines: DerivedPolylinePath[]; referenceElevation: number } => { + const referenceElevation = useMemo(() => { + if (!referencePoint || !scene) return 0; + const cartographic = + scene.globe.ellipsoid.cartesianToCartographic(referencePoint); + return cartographic?.height ?? 0; + }, [referencePoint, scene]); + + const polylines = useMemo( + () => + buildDerivedPolylinePaths({ + annotations, + nodeChainAnnotations, + defaultVerticalOffsetMeters, + useOffsetAnchors, + }), + [ + defaultVerticalOffsetMeters, + annotations, + nodeChainAnnotations, + useOffsetAnchors, + ] + ); + + return { + polylines, + referenceElevation, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/usePolylineMeasureState.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/usePolylineMeasureState.ts new file mode 100644 index 0000000000..7fed77d90e --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/polyline/usePolylineMeasureState.ts @@ -0,0 +1,206 @@ +import { + useCallback, + useMemo, + type Dispatch, + type SetStateAction, +} from "react"; +import { + Cartesian3, + getDegreesFromCartesian, + getEllipsoidalAltitudeOrZero, + getPositionWithVerticalOffsetFromAnchor, +} from "@carma/cesium"; +import { + isPointAnnotationEntry, + type AnnotationCollection, + type LinearSegmentLineMode, + type NodeChainAnnotation, +} from "@carma-mapping/annotations/core"; + +type UsePolylineMeasureStateParams = { + focusedNodeChainAnnotationId: string | null; + nodeChainAnnotations: NodeChainAnnotation[]; + defaultPolylineVerticalOffsetMeters: number; + defaultPolylineSegmentLineMode: LinearSegmentLineMode; + setDefaultPolylineVerticalOffsetMeters: Dispatch>; + setDefaultPolylineSegmentLineMode: Dispatch< + SetStateAction + >; + setNodeChainAnnotations: Dispatch>; + setAnnotations: Dispatch>; +}; + +export const usePolylineMeasureState = ({ + focusedNodeChainAnnotationId, + nodeChainAnnotations, + defaultPolylineVerticalOffsetMeters, + defaultPolylineSegmentLineMode, + setDefaultPolylineVerticalOffsetMeters, + setDefaultPolylineSegmentLineMode, + setNodeChainAnnotations, + setAnnotations, +}: UsePolylineMeasureStateParams) => { + const polylineVerticalOffsetMeters = useMemo(() => { + if (!focusedNodeChainAnnotationId) { + return defaultPolylineVerticalOffsetMeters; + } + const focusedGroup = nodeChainAnnotations.find( + (group) => group.id === focusedNodeChainAnnotationId + ); + return ( + focusedGroup?.verticalOffsetMeters ?? defaultPolylineVerticalOffsetMeters + ); + }, [ + defaultPolylineVerticalOffsetMeters, + focusedNodeChainAnnotationId, + nodeChainAnnotations, + ]); + + const setPolylineVerticalOffsetMeters = useCallback< + Dispatch> + >( + (nextOffsetOrUpdater) => { + const nextOffsetMeters = + typeof nextOffsetOrUpdater === "function" + ? nextOffsetOrUpdater(polylineVerticalOffsetMeters) + : nextOffsetOrUpdater; + + if (!Number.isFinite(nextOffsetMeters)) { + return; + } + + if (Math.abs(nextOffsetMeters - polylineVerticalOffsetMeters) <= 1e-9) { + return; + } + + setDefaultPolylineVerticalOffsetMeters(nextOffsetMeters); + + if (!focusedNodeChainAnnotationId) { + return; + } + + const focusedGroup = nodeChainAnnotations.find( + (group) => group.id === focusedNodeChainAnnotationId + ); + if (!focusedGroup) { + return; + } + + setNodeChainAnnotations((prev) => + prev.map((group) => + group.id === focusedNodeChainAnnotationId + ? { + ...group, + verticalOffsetMeters: nextOffsetMeters, + } + : group + ) + ); + + const focusedVertexIdSet = new Set(focusedGroup.nodeIds); + if (focusedVertexIdSet.size === 0) { + return; + } + + setAnnotations((prev) => + prev.map((measurement) => { + if ( + !isPointAnnotationEntry(measurement) || + !focusedVertexIdSet.has(measurement.id) || + !measurement.verticalOffsetAnchorECEF + ) { + return measurement; + } + + const anchorECEF = new Cartesian3( + measurement.verticalOffsetAnchorECEF.x, + measurement.verticalOffsetAnchorECEF.y, + measurement.verticalOffsetAnchorECEF.z + ); + const nextPointPosition = getPositionWithVerticalOffsetFromAnchor( + anchorECEF, + nextOffsetMeters + ); + const nextWGS84 = getDegreesFromCartesian(nextPointPosition); + + return { + ...measurement, + geometryECEF: nextPointPosition, + geometryWGS84: { + longitude: nextWGS84.longitude, + latitude: nextWGS84.latitude, + altitude: getEllipsoidalAltitudeOrZero(nextWGS84.altitude), + }, + }; + }) + ); + }, + [ + focusedNodeChainAnnotationId, + nodeChainAnnotations, + polylineVerticalOffsetMeters, + setAnnotations, + setDefaultPolylineVerticalOffsetMeters, + setNodeChainAnnotations, + ] + ); + + const polylineSegmentLineMode = useMemo(() => { + if (!focusedNodeChainAnnotationId) { + return defaultPolylineSegmentLineMode; + } + const activeGroup = nodeChainAnnotations.find( + (group) => group.id === focusedNodeChainAnnotationId + ); + return activeGroup?.segmentLineMode ?? defaultPolylineSegmentLineMode; + }, [ + focusedNodeChainAnnotationId, + defaultPolylineSegmentLineMode, + nodeChainAnnotations, + ]); + + const setPolylineSegmentLineMode = useCallback< + Dispatch> + >( + (nextModeOrUpdater) => { + const nextMode = + typeof nextModeOrUpdater === "function" + ? nextModeOrUpdater(polylineSegmentLineMode) + : nextModeOrUpdater; + + if (!nextMode || nextMode === polylineSegmentLineMode) { + return; + } + + setDefaultPolylineSegmentLineMode(nextMode); + + if (!focusedNodeChainAnnotationId) { + return; + } + + setNodeChainAnnotations((prev) => + prev.map((group) => + group.id === focusedNodeChainAnnotationId + ? { + ...group, + segmentLineMode: nextMode, + } + : group + ) + ); + }, + [ + focusedNodeChainAnnotationId, + polylineSegmentLineMode, + setDefaultPolylineSegmentLineMode, + setNodeChainAnnotations, + ] + ); + + return { + polylineVerticalOffsetMeters, + setPolylineVerticalOffsetMeters, + polylineSegmentLineMode, + setPolylineSegmentLineMode, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/useAnnotationTopologyIndex.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/useAnnotationTopologyIndex.ts new file mode 100644 index 0000000000..fa4d19527f --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/useAnnotationTopologyIndex.ts @@ -0,0 +1,114 @@ +import { useCallback, useMemo } from "react"; + +import type { NodeChainAnnotation } from "@carma-mapping/annotations/core"; + +const EMPTY_OWNER_GROUP_IDS: readonly string[] = []; + +const appendOwnerGroupId = ( + ownershipTable: Map, + key: string, + ownerGroupId: string +) => { + const previousOwnerGroupIds = ownershipTable.get(key); + if (!previousOwnerGroupIds) { + ownershipTable.set(key, [ownerGroupId]); + return; + } + + if (previousOwnerGroupIds.includes(ownerGroupId)) { + return; + } + + previousOwnerGroupIds.push(ownerGroupId); +}; + +export const useAnnotationTopologyIndex = ( + planarMeasurementGroups: readonly NodeChainAnnotation[] +) => { + const { + ownerGroupIdsByPointId, + ownerGroupIdsByEdgeRelationId, + representativePointIdByGroupId, + } = useMemo(() => { + const nextOwnerGroupIdsByPointId = new Map(); + const nextOwnerGroupIdsByEdgeRelationId = new Map(); + const nextRepresentativePointIdByGroupId = new Map(); + + planarMeasurementGroups.forEach((group) => { + group.nodeIds.forEach((pointId) => { + if (!pointId) { + return; + } + + appendOwnerGroupId(nextOwnerGroupIdsByPointId, pointId, group.id); + if (!nextRepresentativePointIdByGroupId.has(group.id)) { + nextRepresentativePointIdByGroupId.set(group.id, pointId); + } + }); + + group.edgeRelationIds.forEach((edgeRelationId) => { + if (!edgeRelationId) { + return; + } + + appendOwnerGroupId( + nextOwnerGroupIdsByEdgeRelationId, + edgeRelationId, + group.id + ); + }); + }); + + return { + ownerGroupIdsByPointId: nextOwnerGroupIdsByPointId, + ownerGroupIdsByEdgeRelationId: nextOwnerGroupIdsByEdgeRelationId, + representativePointIdByGroupId: nextRepresentativePointIdByGroupId, + }; + }, [planarMeasurementGroups]); + + const getOwnerGroupIdsForPointId = useCallback( + (pointId: string | null | undefined): readonly string[] => { + if (!pointId) { + return EMPTY_OWNER_GROUP_IDS; + } + + return ownerGroupIdsByPointId.get(pointId) ?? EMPTY_OWNER_GROUP_IDS; + }, + [ownerGroupIdsByPointId] + ); + + const getOwnerGroupIdsForEdgeRelationId = useCallback( + (edgeRelationId: string | null | undefined): readonly string[] => { + if (!edgeRelationId) { + return EMPTY_OWNER_GROUP_IDS; + } + + return ( + ownerGroupIdsByEdgeRelationId.get(edgeRelationId) ?? + EMPTY_OWNER_GROUP_IDS + ); + }, + [ownerGroupIdsByEdgeRelationId] + ); + + const getRepresentativePointIdForGroupId = useCallback( + (groupId: string | null | undefined): string | null => { + if (!groupId) { + return null; + } + + return representativePointIdByGroupId.get(groupId) ?? null; + }, + [representativePointIdByGroupId] + ); + + return { + getOwnerGroupIdsForPointId, + getOwnerGroupIdsForEdgeRelationId, + getRepresentativePointIdForGroupId, + }; +}; + +export type MeasurementOwnershipIndex = ReturnType< + typeof useAnnotationTopologyIndex +>; diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/useLockedAnnotationIdSet.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/useLockedAnnotationIdSet.ts new file mode 100644 index 0000000000..7dba677d51 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/useLockedAnnotationIdSet.ts @@ -0,0 +1,19 @@ +import { useMemo } from "react"; + +type LockableAnnotationLike = { + id: string; + locked?: boolean; +}; + +export const useLockedAnnotationIdSet = ( + annotations: readonly LockableAnnotationLike[] +) => + useMemo(() => { + const ids = new Set(); + annotations.forEach((annotation) => { + if (annotation.locked) { + ids.add(annotation.id); + } + }); + return ids; + }, [annotations]); diff --git a/libraries/mapping/annotations/provider/src/lib/context/topology/useNodeChainPlaneDerivation.ts b/libraries/mapping/annotations/provider/src/lib/context/topology/useNodeChainPlaneDerivation.ts new file mode 100644 index 0000000000..76e827f706 --- /dev/null +++ b/libraries/mapping/annotations/provider/src/lib/context/topology/useNodeChainPlaneDerivation.ts @@ -0,0 +1,35 @@ +import { useCallback } from "react"; +import { Cartesian3, isValidScene, type Scene } from "@carma/cesium"; +import { + computePolygonGroupDerivedData, + orientPlaneNormalTowardPosition, + type NodeChainAnnotation, + type PlanarPolygonPlane, +} from "@carma-mapping/annotations/core"; + +export const useNodeChainPlaneDerivation = (scene: Scene) => { + const getPreferredPlaneFacingPosition = useCallback((): Cartesian3 | null => { + if (!isValidScene(scene)) return null; + return scene.camera.positionWC; + }, [scene]); + + const orientPlaneTowardSceneCamera = useCallback( + (plane: PlanarPolygonPlane): PlanarPolygonPlane => + orientPlaneNormalTowardPosition(plane, getPreferredPlaneFacingPosition()), + [getPreferredPlaneFacingPosition] + ); + + const computePolygonGroupDerivedDataWithCamera = useCallback( + (group: NodeChainAnnotation, pointById: Map) => + computePolygonGroupDerivedData(group, pointById, { + preferredFacingPositionECEF: getPreferredPlaneFacingPosition(), + }), + [getPreferredPlaneFacingPosition] + ); + + return { + getPreferredPlaneFacingPosition, + orientPlaneTowardSceneCamera, + computePolygonGroupDerivedDataWithCamera, + }; +}; diff --git a/libraries/mapping/annotations/provider/src/lib/context/utils/pointLabelInteractions.ts b/libraries/mapping/annotations/provider/src/lib/context/utils/pointLabelInteractions.ts deleted file mode 100644 index f3982b6403..0000000000 --- a/libraries/mapping/annotations/provider/src/lib/context/utils/pointLabelInteractions.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - POINT_LABEL_METRIC_MODES, - DEFAULT_POINT_LABEL_METRIC_MODE, - type PointLabelMetricMode, -} from "@carma-mapping/annotations/core"; - -export const getNextPointLabelMetricMode = ( - currentMode: PointLabelMetricMode = DEFAULT_POINT_LABEL_METRIC_MODE -): PointLabelMetricMode => { - const currentIndex = POINT_LABEL_METRIC_MODES.indexOf(currentMode); - const nextIndex = (currentIndex + 1) % POINT_LABEL_METRIC_MODES.length; - return POINT_LABEL_METRIC_MODES[nextIndex]; -}; - -type RunPointLabelClickInteractionParams = { - pointId: string; - selectedMeasurementId: string | null; - selectMeasurementById: (id: string | null) => void; - cyclePointLabelMetricModeByMeasurementId: (id: string) => void; -}; - -export const runPointLabelClickInteraction = ({ - pointId, - selectedMeasurementId, - selectMeasurementById, - cyclePointLabelMetricModeByMeasurementId, -}: RunPointLabelClickInteractionParams) => { - if (selectedMeasurementId === pointId) { - cyclePointLabelMetricModeByMeasurementId(pointId); - return; - } - - selectMeasurementById(pointId); -}; diff --git a/libraries/mapping/engines/cesium/api/src/lib/Cartesian3.ts b/libraries/mapping/engines/cesium/api/src/lib/Cartesian3.ts index 5fd5d0a4d9..11ac7b9de5 100644 --- a/libraries/mapping/engines/cesium/api/src/lib/Cartesian3.ts +++ b/libraries/mapping/engines/cesium/api/src/lib/Cartesian3.ts @@ -56,3 +56,14 @@ export const cartesian3ToJson = (cartesian3: Cartesian3): Cartesian3Json => { export const cartesian3FromJson = ({ x, y, z }: Cartesian3Json): Cartesian3 => { return new Cartesian3(x, y, z); }; + +/** + * Apply a constant offset to a list of Cartesian3 positions. + */ +export const offsetCartesian3Positions = ( + positions: readonly Cartesian3[], + offset: Cartesian3 +): Cartesian3[] => + positions.map((position) => + Cartesian3.add(position, offset, new Cartesian3()) + ); diff --git a/libraries/mapping/engines/cesium/api/src/lib/GuidePrimitives.ts b/libraries/mapping/engines/cesium/api/src/lib/GuidePrimitives.ts new file mode 100644 index 0000000000..10b7a68ff5 --- /dev/null +++ b/libraries/mapping/engines/cesium/api/src/lib/GuidePrimitives.ts @@ -0,0 +1,181 @@ +import { + Cartesian3, + Matrix4, + Primitive, + SceneTransforms, + Transforms, + defined, + type Scene, +} from "cesium"; + +import { + createBasisScaleTranslationMatrix, + matrix4ColumnToCartesian3, +} from "./CarmaTransforms"; + +export const GUIDE_NORMAL_EPSILON_SQUARED = 1e-8; + +const DISC_MIN_WORLD_RADIUS = 1e-3; +const DISC_MIN_PROJECTED_PIXEL_PER_WORLD = 1e-6; +const DISC_PROJECTION_SCALE_SAMPLE_COUNT = 16; +const LOCAL_UP_ENU_FRAME_SCRATCH = new Matrix4(); + +export const safeRemovePrimitive = ( + scene: Scene | null, + primitive: Primitive | null | undefined +) => { + if (!scene || !primitive) return; + try { + if (!scene.isDestroyed()) { + scene.primitives.remove(primitive); + } + } catch { + // Scene/primitive teardown may race while effects are cleaning up. + } +}; + +export const safeCall = (callback: (() => void) | null | undefined) => { + if (!callback) return; + try { + callback(); + } catch { + // Listener removal can race with scene/widget teardown. + } +}; + +export const getLocalUpDirectionAtPosition = ( + positionECEF: Cartesian3, + result: Cartesian3 = new Cartesian3() +): Cartesian3 => { + const localEnuFrame = Transforms.eastNorthUpToFixedFrame( + positionECEF, + undefined, + LOCAL_UP_ENU_FRAME_SCRATCH + ); + const upDirection = matrix4ColumnToCartesian3(localEnuFrame, 2, result); + + if ( + Cartesian3.magnitudeSquared(upDirection) <= GUIDE_NORMAL_EPSILON_SQUARED + ) { + return Cartesian3.normalize(positionECEF, result); + } + + return Cartesian3.normalize(upDirection, result); +}; + +export const createPlaneBasis = (normal: Cartesian3) => { + const up = Cartesian3.normalize(normal, new Cartesian3()); + const reference = + Math.abs(Cartesian3.dot(up, Cartesian3.UNIT_Z)) > 0.9 + ? Cartesian3.UNIT_X + : Cartesian3.UNIT_Z; + const xAxis = Cartesian3.normalize( + Cartesian3.cross(up, reference, new Cartesian3()), + new Cartesian3() + ); + const yAxis = Cartesian3.normalize( + Cartesian3.cross(xAxis, up, new Cartesian3()), + new Cartesian3() + ); + return { xAxis, yAxis }; +}; + +export const resolveDiscNormal = ( + origin: Cartesian3, + preferredNormal: Cartesian3 | null | undefined +): Cartesian3 => { + if ( + preferredNormal && + Cartesian3.magnitudeSquared(preferredNormal) > GUIDE_NORMAL_EPSILON_SQUARED + ) { + return Cartesian3.normalize(preferredNormal, new Cartesian3()); + } + return getLocalUpDirectionAtPosition(origin); +}; + +export const createOrientedDiscModelMatrix = ( + origin: Cartesian3, + planeNormal: Cartesian3, + radius: number, + result: Matrix4 = new Matrix4() +): Matrix4 => { + const safeRadius = Math.max(radius, DISC_MIN_WORLD_RADIUS); + const normalizedNormal = Cartesian3.normalize(planeNormal, new Cartesian3()); + const planeBasis = createPlaneBasis(normalizedNormal); + return createBasisScaleTranslationMatrix( + origin, + planeBasis.xAxis, + planeBasis.yAxis, + normalizedNormal, + safeRadius, + safeRadius, + 1, + result + ); +}; + +export const getDiscWorldRadius = ( + scene: Scene, + origin: Cartesian3, + planeNormal: Cartesian3, + configuredWorldRadius: number, + fixedScreenRadiusPx?: number +): number => { + const baseRadius = Math.max(configuredWorldRadius, DISC_MIN_WORLD_RADIUS); + if (fixedScreenRadiusPx === undefined) { + return baseRadius; + } + + const anchorCanvasPosition = SceneTransforms.worldToWindowCoordinates( + scene, + origin + ); + if (!defined(anchorCanvasPosition)) { + return baseRadius; + } + + const planeBasis = createPlaneBasis(planeNormal); + let pixelPerWorldMax = 0; + for (let i = 0; i < DISC_PROJECTION_SCALE_SAMPLE_COUNT; i += 1) { + const t = (i / DISC_PROJECTION_SCALE_SAMPLE_COUNT) * Math.PI * 2; + const sampleDirection = Cartesian3.add( + Cartesian3.multiplyByScalar( + planeBasis.xAxis, + Math.cos(t), + new Cartesian3() + ), + Cartesian3.multiplyByScalar( + planeBasis.yAxis, + Math.sin(t), + new Cartesian3() + ), + new Cartesian3() + ); + const sampleWorld = Cartesian3.add( + origin, + sampleDirection, + new Cartesian3() + ); + const sampleCanvas = SceneTransforms.worldToWindowCoordinates( + scene, + sampleWorld + ); + if (!defined(sampleCanvas)) continue; + + const dx = sampleCanvas.x - anchorCanvasPosition.x; + const dy = sampleCanvas.y - anchorCanvasPosition.y; + const d = Math.hypot(dx, dy); + if (Number.isFinite(d) && d > pixelPerWorldMax) { + pixelPerWorldMax = d; + } + } + + if (pixelPerWorldMax <= DISC_MIN_PROJECTED_PIXEL_PER_WORLD) { + return baseRadius; + } + + return Math.max( + fixedScreenRadiusPx / pixelPerWorldMax, + DISC_MIN_WORLD_RADIUS + ); +}; diff --git a/libraries/mapping/engines/cesium/api/src/lib/index.ts b/libraries/mapping/engines/cesium/api/src/lib/index.ts index 3ef6c94854..da4501d222 100644 --- a/libraries/mapping/engines/cesium/api/src/lib/index.ts +++ b/libraries/mapping/engines/cesium/api/src/lib/index.ts @@ -72,6 +72,7 @@ export * from "./Color"; export * from "./CustomShader"; export * from "./EllipsoidTerrainProvider"; export * from "./Globe"; +export * from "./GuidePrimitives"; export * from "./GroundPrimitive"; export * from "./HeadingPitchRange"; export * from "./HeadingPitchRoll"; diff --git a/libraries/mapping/gizmo/cesium/src/lib/cesiumPointMoveGizmoMath.ts b/libraries/mapping/gizmo/cesium/src/lib/cesiumPointMoveGizmoMath.ts index 953748878a..803bec2d2e 100644 --- a/libraries/mapping/gizmo/cesium/src/lib/cesiumPointMoveGizmoMath.ts +++ b/libraries/mapping/gizmo/cesium/src/lib/cesiumPointMoveGizmoMath.ts @@ -8,12 +8,9 @@ import { defined, type Scene, } from "@carma/cesium"; -import { clamp } from "@carma-commons/math"; -import { - AXIS_NUMERIC_EPSILON, - getClosestAxisParamToRay, - type GizmoVec3, -} from "@carma-mapping/gizmo/core"; +import { clamp, getClosestLineParamToRay } from "@carma-commons/math"; +import { AXIS_NUMERIC_EPSILON } from "@carma-mapping/gizmo/core"; +import { Ray, Vector3 } from "three"; export type PlaneBasis = { xAxis: Cartesian3; @@ -25,11 +22,8 @@ export type ScreenPoint2 = { y: number; }; -export const toGizmoVec3 = (vector: Cartesian3): GizmoVec3 => ({ - x: vector.x, - y: vector.y, - z: vector.z, -}); +const toThreeVector3 = (vector: Cartesian3): Vector3 => + new Vector3(vector.x, vector.y, vector.z); const ENU_FRAME_SCRATCH = new Matrix4(); @@ -206,13 +200,11 @@ export const getAxisParamFromClientPosition = ( ); const ray = scene.camera.getPickRay(windowPosition); if (!ray) return null; - return getClosestAxisParamToRay( - { - origin: toGizmoVec3(ray.origin), - direction: toGizmoVec3(ray.direction), - }, - toGizmoVec3(axisOrigin), - toGizmoVec3(axisDirection) + + return getClosestLineParamToRay( + new Ray(toThreeVector3(ray.origin), toThreeVector3(ray.direction)), + toThreeVector3(axisOrigin), + toThreeVector3(axisDirection) ); }; diff --git a/libraries/mapping/gizmo/cesium/src/lib/hooks/useCesiumPointMoveGizmo.ts b/libraries/mapping/gizmo/cesium/src/lib/hooks/useCesiumPointMoveGizmo.ts index c5d1e9fccc..c3b4ea0f7a 100644 --- a/libraries/mapping/gizmo/cesium/src/lib/hooks/useCesiumPointMoveGizmo.ts +++ b/libraries/mapping/gizmo/cesium/src/lib/hooks/useCesiumPointMoveGizmo.ts @@ -157,6 +157,7 @@ const DISC_OUTLINE_SEGMENTS = 72; const DISC_SVG_EXTENT = 320; const DISC_SVG_HALF_EXTENT = DISC_SVG_EXTENT / 2; const DISC_PROJECTION_SCALE_SAMPLE_COUNT = 16; +const OPEN_GIZMO_SCENE_CLICK_GUARD_MS = 220; const AXIS_SCREEN_SAMPLE_TARGET_PX = 48; const AXIS_SCREEN_SAMPLE_MIN_WORLD = 0.25; const AXIS_SCREEN_SAMPLE_MAX_WORLD = 500; @@ -273,15 +274,6 @@ const createOrientedDiscModelMatrix = ( ); }; -const toCartesian3Json = (value: Cartesian3) => ({ - x: value.x, - y: value.y, - z: value.z, -}); - -const toMatrix4Array = (value: Matrix4): number[] => - Array.from(value as unknown as ArrayLike); - const updateTrianglePathAppearance = ( pathElement: SVGPathElement | null, edgeLengthPx: number @@ -376,6 +368,7 @@ export const useCesiumPointMoveGizmo = ( const dragStateRef = useRef(null); const isDraggingRef = useRef(false); const suppressNextSceneClickRef = useRef(false); + const clearInitialSceneClickGuardTimeoutRef = useRef(null); const movePointRef = useRef(null); const rotationStateRef = useRef(null); const rotationFrameRef = useRef(null); @@ -485,6 +478,34 @@ export const useCesiumPointMoveGizmo = ( axisAnchorDistanceRef.current = {}; }, [movePoint]); + useEffect(() => { + if (clearInitialSceneClickGuardTimeoutRef.current !== null) { + window.clearTimeout(clearInitialSceneClickGuardTimeoutRef.current); + clearInitialSceneClickGuardTimeoutRef.current = null; + } + + if (!movePoint) { + suppressNextSceneClickRef.current = false; + return; + } + + // Opening the gizmo is usually triggered by a DOM long-press/click. + // Ignore the trailing scene click briefly so the newly opened gizmo + // does not immediately exit on the same interaction. + suppressNextSceneClickRef.current = true; + clearInitialSceneClickGuardTimeoutRef.current = window.setTimeout(() => { + suppressNextSceneClickRef.current = false; + clearInitialSceneClickGuardTimeoutRef.current = null; + }, OPEN_GIZMO_SCENE_CLICK_GUARD_MS); + + return () => { + if (clearInitialSceneClickGuardTimeoutRef.current !== null) { + window.clearTimeout(clearInitialSceneClickGuardTimeoutRef.current); + clearInitialSceneClickGuardTimeoutRef.current = null; + } + }; + }, [movePoint]); + useEffect(() => { axisDirectionRef.current = axisDirection; }, [axisDirection]); @@ -760,20 +781,6 @@ export const useCesiumPointMoveGizmo = ( return; } - console.info("[gizmo-drag-start:axis]", { - pointId: activePoint.id, - activeAxisId: activeAxisCandidate.id, - activePoint: toCartesian3Json(activePoint.geometryECEF), - axisOrigin: toCartesian3Json(axisOrigin), - axisDirection: toCartesian3Json(axisDirection), - startAxisParam, - cameraPosition: toCartesian3Json(scene.camera.position), - cameraDirection: toCartesian3Json(scene.camera.direction), - cameraUp: toCartesian3Json(scene.camera.up), - cameraRight: toCartesian3Json(scene.camera.right), - cameraViewMatrix: toMatrix4Array(scene.camera.viewMatrix), - }); - const onWindowMouseMove = (mouseMoveEvent: MouseEvent) => { const dragState = dragStateRef.current; if ( @@ -907,70 +914,6 @@ export const useCesiumPointMoveGizmo = ( return; } - const startBasisXWorld = Cartesian3.add( - axisOrigin, - planeBasis.xAxis, - new Cartesian3() - ); - const startBasisYWorld = Cartesian3.add( - axisOrigin, - planeBasis.yAxis, - new Cartesian3() - ); - const startOriginCanvas = SceneTransforms.worldToWindowCoordinates( - scene, - axisOrigin - ); - const startBasisXCanvas = SceneTransforms.worldToWindowCoordinates( - scene, - startBasisXWorld - ); - const startBasisYCanvas = SceneTransforms.worldToWindowCoordinates( - scene, - startBasisYWorld - ); - let startProjectedDeterminant = Number.NaN; - if ( - defined(startOriginCanvas) && - defined(startBasisXCanvas) && - defined(startBasisYCanvas) - ) { - const pxX = startBasisXCanvas.x - startOriginCanvas.x; - const pxY = startBasisXCanvas.y - startOriginCanvas.y; - const pyX = startBasisYCanvas.x - startOriginCanvas.x; - const pyY = startBasisYCanvas.y - startOriginCanvas.y; - startProjectedDeterminant = pxX * pyY - pxY * pyX; - } - - console.info("[gizmo-drag-start:rotate]", { - pointId: activePoint.id, - activeAxisId: activeAxisCandidate.id, - activePoint: toCartesian3Json(activePoint.geometryECEF), - axisOrigin: toCartesian3Json(axisOrigin), - rotationNormal: toCartesian3Json(rotationNormal), - planeNormal: toCartesian3Json(rotationNormal), - planeBasisX: toCartesian3Json(planeBasis.xAxis), - planeBasisY: toCartesian3Json(planeBasis.yAxis), - startPlaneAngleRad, - cameraPosition: toCartesian3Json(scene.camera.position), - cameraDirection: toCartesian3Json(scene.camera.direction), - cameraUp: toCartesian3Json(scene.camera.up), - cameraRight: toCartesian3Json(scene.camera.right), - cameraViewMatrix: toMatrix4Array(scene.camera.viewMatrix), - cameraSignedFacingToPlane: Cartesian3.dot( - Cartesian3.normalize(rotationNormal, new Cartesian3()), - Cartesian3.normalize( - Cartesian3.subtract( - scene.camera.position, - axisOrigin, - new Cartesian3() - ), - new Cartesian3() - ) - ), - projectedBasisDeterminant: startProjectedDeterminant, - }); - const currentRotationState = rotationStateRef.current; const baseRotationAngleRad = currentRotationState?.pointId === activePoint.id @@ -1138,22 +1081,6 @@ export const useCesiumPointMoveGizmo = ( } if (!startPlanePoint) return; - console.info("[gizmo-drag-start:plane]", { - pointId: activePoint.id, - activeAxisId: activeAxisCandidate.id, - activePoint: toCartesian3Json(activePoint.geometryECEF), - planeOrigin: toCartesian3Json(planeOrigin), - planeNormal: toCartesian3Json(planeNormal), - planeBasisX: toCartesian3Json(planeBasisX), - planeBasisY: toCartesian3Json(planeBasisY), - startPlanePoint: toCartesian3Json(startPlanePoint), - cameraPosition: toCartesian3Json(scene.camera.position), - cameraDirection: toCartesian3Json(scene.camera.direction), - cameraUp: toCartesian3Json(scene.camera.up), - cameraRight: toCartesian3Json(scene.camera.right), - cameraViewMatrix: toMatrix4Array(scene.camera.viewMatrix), - }); - const onWindowMouseMove = (mouseMoveEvent: MouseEvent) => { const dragState = dragStateRef.current; if (!dragState || dragState.mode !== "plane-translate") return; @@ -2297,6 +2224,10 @@ export const useCesiumPointMoveGizmo = ( useEffect( () => () => { + if (clearInitialSceneClickGuardTimeoutRef.current !== null) { + window.clearTimeout(clearInitialSceneClickGuardTimeoutRef.current); + clearInitialSceneClickGuardTimeoutRef.current = null; + } stopDragging(false); removeLabelOverlayElement(OVERLAY_HANDLE_ID); if (axisVisualizerRef.current) { diff --git a/libraries/mapping/gizmo/core/src/index.ts b/libraries/mapping/gizmo/core/src/index.ts index 2444906f57..592a8bbeb0 100644 --- a/libraries/mapping/gizmo/core/src/index.ts +++ b/libraries/mapping/gizmo/core/src/index.ts @@ -1,4 +1,4 @@ -export * from "./lib/gizmoMath"; +export * from "./lib/constants"; export * from "./lib/axisDragConnector"; export * from "./lib/cssAxisDragController"; export * from "./lib/cssAxisGizmoView"; diff --git a/libraries/mapping/gizmo/core/src/lib/axisDragConnector.ts b/libraries/mapping/gizmo/core/src/lib/axisDragConnector.ts index 899c8c7977..54ac636130 100644 --- a/libraries/mapping/gizmo/core/src/lib/axisDragConnector.ts +++ b/libraries/mapping/gizmo/core/src/lib/axisDragConnector.ts @@ -1,14 +1,10 @@ -import { - AXIS_NUMERIC_EPSILON, - getClosestAxisParamToRay, - gizmoNormalize, - type GizmoRay3, - type GizmoVec3, -} from "./gizmoMath"; +import { getClosestLineParamToRay } from "@carma-commons/math"; +import { Ray, Vector3 } from "three"; +import { AXIS_NUMERIC_EPSILON } from "./constants"; export type GizmoAxisDragConnectorState = { - axisOrigin: GizmoVec3; - axisDirection: GizmoVec3; + axisOrigin: Vector3; + axisDirection: Vector3; axisParam: number; isDragging: boolean; }; @@ -16,13 +12,13 @@ export type GizmoAxisDragConnectorState = { export type GizmoAxisDragUpdate = { axisParam: number; deltaFromDragStart: number; - point: GizmoVec3; + point: Vector3; isDragging: boolean; }; export type GizmoAxisDragConnectorOptions = { - axisOrigin: GizmoVec3; - axisDirection: GizmoVec3; + axisOrigin: Vector3; + axisDirection: Vector3; initialAxisParam?: number; epsilon?: number; onAxisParamChange?: (update: GizmoAxisDragUpdate) => void; @@ -30,39 +26,28 @@ export type GizmoAxisDragConnectorOptions = { }; export type GizmoAxisDragConnector = { - setAxis: (axisOrigin: GizmoVec3, axisDirection: GizmoVec3) => void; + setAxis: (axisOrigin: Vector3, axisDirection: Vector3) => void; setAxisParam: (axisParam: number) => void; getAxisParam: () => number; - getPoint: () => GizmoVec3; + getPoint: () => Vector3; getState: () => GizmoAxisDragConnectorState; - projectRayToAxis: (ray: GizmoRay3) => number | null; - beginDragFromRay: (ray: GizmoRay3) => boolean; - updateDragFromRay: (ray: GizmoRay3) => number | null; + projectRayToAxis: (ray: Ray) => number | null; + beginDragFromRay: (ray: Ray) => boolean; + updateDragFromRay: (ray: Ray) => number | null; endDrag: () => void; }; -const cloneVec3 = (v: GizmoVec3): GizmoVec3 => ({ x: v.x, y: v.y, z: v.z }); - -const addScaled = ( - origin: GizmoVec3, - direction: GizmoVec3, - scalar: number -) => ({ - x: origin.x + direction.x * scalar, - y: origin.y + direction.y * scalar, - z: origin.z + direction.z * scalar, -}); - export const createAxisDragConnector = ( options: GizmoAxisDragConnectorOptions ): GizmoAxisDragConnector => { const epsilon = options.epsilon ?? AXIS_NUMERIC_EPSILON; const state: GizmoAxisDragConnectorState = { - axisOrigin: cloneVec3(options.axisOrigin), + axisOrigin: options.axisOrigin.clone(), axisDirection: - gizmoNormalize(options.axisDirection, epsilon) ?? - cloneVec3(options.axisDirection), + options.axisDirection.lengthSq() > epsilon + ? options.axisDirection.clone().normalize() + : options.axisDirection.clone(), axisParam: options.initialAxisParam ?? 0, isDragging: false, }; @@ -74,7 +59,9 @@ export const createAxisDragConnector = ( options.onAxisParamChange?.({ axisParam: state.axisParam, deltaFromDragStart, - point: addScaled(state.axisOrigin, state.axisDirection, state.axisParam), + point: state.axisOrigin + .clone() + .add(state.axisDirection.clone().multiplyScalar(state.axisParam)), isDragging: state.isDragging, }); }; @@ -85,30 +72,29 @@ export const createAxisDragConnector = ( options.onDragStateChange?.(isDragging); }; - const projectRayToAxis = (ray: GizmoRay3): number | null => { - const normalizedRayDirection = gizmoNormalize(ray.direction, epsilon); - const normalizedAxisDirection = gizmoNormalize( - state.axisDirection, - epsilon - ); - if (!normalizedRayDirection || !normalizedAxisDirection) return null; + const projectRayToAxis = (ray: Ray): number | null => { + if ( + ray.direction.lengthSq() <= epsilon || + state.axisDirection.lengthSq() <= epsilon + ) { + return null; + } - return getClosestAxisParamToRay( - { - origin: ray.origin, - direction: normalizedRayDirection, - }, + return getClosestLineParamToRay( + new Ray(ray.origin.clone(), ray.direction.clone().normalize()), state.axisOrigin, - normalizedAxisDirection, + state.axisDirection.clone().normalize(), epsilon ); }; return { setAxis: (axisOrigin, axisDirection) => { - state.axisOrigin = cloneVec3(axisOrigin); + state.axisOrigin = axisOrigin.clone(); state.axisDirection = - gizmoNormalize(axisDirection, epsilon) ?? cloneVec3(axisDirection); + axisDirection.lengthSq() > epsilon + ? axisDirection.clone().normalize() + : axisDirection.clone(); }, setAxisParam: (axisParam) => { @@ -119,11 +105,13 @@ export const createAxisDragConnector = ( getAxisParam: () => state.axisParam, getPoint: () => - addScaled(state.axisOrigin, state.axisDirection, state.axisParam), + state.axisOrigin + .clone() + .add(state.axisDirection.clone().multiplyScalar(state.axisParam)), getState: () => ({ - axisOrigin: cloneVec3(state.axisOrigin), - axisDirection: cloneVec3(state.axisDirection), + axisOrigin: state.axisOrigin.clone(), + axisDirection: state.axisDirection.clone(), axisParam: state.axisParam, isDragging: state.isDragging, }), diff --git a/libraries/mapping/gizmo/core/src/lib/constants.ts b/libraries/mapping/gizmo/core/src/lib/constants.ts new file mode 100644 index 0000000000..f0bb6a3a49 --- /dev/null +++ b/libraries/mapping/gizmo/core/src/lib/constants.ts @@ -0,0 +1,3 @@ +import { VEC3_NUMERIC_EPSILON } from "@carma-commons/math"; + +export const AXIS_NUMERIC_EPSILON = VEC3_NUMERIC_EPSILON; diff --git a/libraries/mapping/gizmo/core/src/lib/cssAxisDragController.ts b/libraries/mapping/gizmo/core/src/lib/cssAxisDragController.ts index 212e35db68..14528db72b 100644 --- a/libraries/mapping/gizmo/core/src/lib/cssAxisDragController.ts +++ b/libraries/mapping/gizmo/core/src/lib/cssAxisDragController.ts @@ -2,29 +2,29 @@ import { createAxisDragConnector, type GizmoAxisDragConnector, } from "./axisDragConnector"; -import { gizmoNormalize, type GizmoRay3, type GizmoVec3 } from "./gizmoMath"; +import { Ray, Vector3 } from "three"; export type GizmoCssAxisSnapshot = { axisParam: number; isDragging: boolean; - point: GizmoVec3; + point: Vector3; gridTransform: string; - lastRayDirection: GizmoVec3 | null; + lastRayDirection: Vector3 | null; }; export type GizmoCssAxisControllerOptions = { - axisOrigin: GizmoVec3; - axisDirection: GizmoVec3; + axisOrigin: Vector3; + axisDirection: Vector3; initialAxisParam?: number; pointerDepth?: number; - rayOrigin?: GizmoVec3; + rayOrigin?: Vector3; gridScale?: number; gridTiltDeg?: number; onChange?: (snapshot: GizmoCssAxisSnapshot) => void; }; export type GizmoCssAxisController = { - setAxis: (axisOrigin: GizmoVec3, axisDirection: GizmoVec3) => void; + setAxis: (axisOrigin: Vector3, axisDirection: Vector3) => void; setAxisParam: (axisParam: number) => void; setPointerDepth: (pointerDepth: number) => void; setGridStyle: (gridScale: number, gridTiltDeg: number) => void; @@ -42,10 +42,8 @@ export type GizmoCssAxisController = { getSnapshot: () => GizmoCssAxisSnapshot; }; -const cloneVec3 = (v: GizmoVec3): GizmoVec3 => ({ x: v.x, y: v.y, z: v.z }); - const buildGridTransform = ( - point: GizmoVec3, + point: Vector3, gridScale: number, gridTiltDeg: number ) => { @@ -61,24 +59,20 @@ const toPointerRay = ( clientY: number, viewportRect: DOMRect | ClientRect, pointerDepth: number, - rayOrigin: GizmoVec3 -): { ray: GizmoRay3; direction: GizmoVec3 } => { + rayOrigin: Vector3 +): { ray: Ray; direction: Vector3 } => { const ndcX = ((clientX - viewportRect.left) / viewportRect.width) * 2 - 1; const ndcY = ((clientY - viewportRect.top) / viewportRect.height) * 2 - 1; - const rawDirection: GizmoVec3 = { - x: ndcX, - y: -ndcY, - z: -Math.max(0.25, pointerDepth), - }; - - const direction = gizmoNormalize(rawDirection) ?? { x: 0, y: 0, z: -1 }; + const direction = new Vector3(ndcX, -ndcY, -Math.max(0.25, pointerDepth)); + if (direction.lengthSq() > 1e-6) { + direction.normalize(); + } else { + direction.set(0, 0, -1); + } return { - ray: { - origin: rayOrigin, - direction, - }, + ray: new Ray(rayOrigin.clone(), direction.clone()), direction, }; }; @@ -87,9 +81,7 @@ export const createCssAxisDragController = ( options: GizmoCssAxisControllerOptions ): GizmoCssAxisController => { let pointerDepth = options.pointerDepth ?? 1.6; - let rayOrigin: GizmoVec3 = options.rayOrigin - ? cloneVec3(options.rayOrigin) - : { x: 0, y: 0, z: 3 }; + let rayOrigin = options.rayOrigin?.clone() ?? new Vector3(0, 0, 3); let gridScale = options.gridScale ?? 80; let gridTiltDeg = options.gridTiltDeg ?? 58; @@ -99,7 +91,7 @@ export const createCssAxisDragController = ( initialAxisParam: options.initialAxisParam, }); - let lastRayDirection: GizmoVec3 | null = null; + let lastRayDirection: Vector3 | null = null; const getSnapshot = (): GizmoCssAxisSnapshot => { const state = connector.getState(); diff --git a/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoElement.ts b/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoElement.ts index 1090de145a..a0df373635 100644 --- a/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoElement.ts +++ b/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoElement.ts @@ -7,17 +7,17 @@ import { createCssAxisGizmoView, type GizmoCssAxisView, } from "./cssAxisGizmoView"; -import type { GizmoVec3 } from "./gizmoMath"; +import { Vector3 } from "three"; type AxisId = "x" | "y" | "z"; -const AXIS_DIRECTIONS: Record = { - x: { x: 1, y: 0, z: 0 }, - y: { x: 0, y: 1, z: 0 }, - z: { x: 0, y: 0, z: 1 }, +const AXIS_DIRECTIONS: Record = { + x: new Vector3(1, 0, 0), + y: new Vector3(0, 1, 0), + z: new Vector3(0, 0, 1), }; -const AXIS_ORIGIN: GizmoVec3 = { x: 0, y: 0, z: 0 }; +const AXIS_ORIGIN = new Vector3(0, 0, 0); const toNumber = (value: string | null, fallback: number): number => { if (!value) return fallback; @@ -218,32 +218,27 @@ export class CssAxisGizmoElement extends HTMLElement { } private refreshReadout() { - if (!this.lastSnapshot) return; - const snapshot = this.lastSnapshot; - const ray = snapshot.lastRayDirection; + if (!this.readoutEl || !this.controller) return; - this.readoutEl.innerHTML = [ - `
Axis: ${this.activeAxis.toUpperCase()}
`, - `
Dragging: ${ + const snapshot = this.lastSnapshot ?? this.controller.getSnapshot(); + this.readoutEl.innerHTML = ` +
Axis: ${this.activeAxis.toUpperCase()}
+
Offset: ${fmt(snapshot.axisParam)}
+
Dragging: ${ snapshot.isDragging ? "yes" : "no" - }
`, - `
t: ${fmt(snapshot.axisParam, 4)}
`, - `
Point: ${fmt(snapshot.point.x, 3)}, ${fmt( - snapshot.point.y, - 3 - )}, ${fmt(snapshot.point.z, 3)}
`, - `
Ray: ${ - ray ? `${fmt(ray.x, 3)}, ${fmt(ray.y, 3)}, ${fmt(ray.z, 3)}` : "—" - }
`, - ].join(""); + }
+
Point: (${fmt(snapshot.point.x)}, ${fmt( + snapshot.point.y + )}, ${fmt(snapshot.point.z)})
+
Ray: ${ + snapshot.lastRayDirection + ? `(${fmt(snapshot.lastRayDirection.x)}, ${fmt( + snapshot.lastRayDirection.y + )}, ${fmt(snapshot.lastRayDirection.z)})` + : "-" + }
+ `; } } -export const registerCssAxisGizmoElement = ( - tagName = "carma-css-axis-gizmo" -) => { - if (customElements.get(tagName)) return; - customElements.define(tagName, CssAxisGizmoElement); -}; - -export default registerCssAxisGizmoElement; +customElements.define("carma-css-axis-gizmo", CssAxisGizmoElement); diff --git a/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoView.ts b/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoView.ts index 2d02602352..b435da43a8 100644 --- a/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoView.ts +++ b/libraries/mapping/gizmo/core/src/lib/cssAxisGizmoView.ts @@ -1,13 +1,13 @@ import type { GizmoCssAxisController } from "./cssAxisDragController"; -import type { GizmoVec3 } from "./gizmoMath"; +import type { Vector3 } from "three"; export type GizmoAxisId = "x" | "y" | "z"; export type GizmoCssAxisViewOptions = { container: HTMLElement; controller: GizmoCssAxisController; - axisOrigin: GizmoVec3; - axisDirections: Record; + axisOrigin: Vector3; + axisDirections: Record; getViewportRect: () => DOMRect | ClientRect | null; initialActiveAxisId?: GizmoAxisId; axisColors?: Partial>; @@ -51,7 +51,7 @@ const createDiv = (style: Partial): HTMLDivElement => { const getAxisVisual = ( axisId: GizmoAxisId, - axisDirections: Record, + axisDirections: Record, axisColors: Record ) => { const direction = axisDirections[axisId]; @@ -220,7 +220,7 @@ export const createCssAxisGizmoView = ( refresh(); }; - (Object.keys(axisLayers) as GizmoAxisId[]).forEach((axisId) => { + (["x", "y", "z"] as GizmoAxisId[]).forEach((axisId) => { const layer = createAxisLayer(axisId, axisColors[axisId], beginDrag); axisLayers[axisId] = layer; overlay.appendChild(layer.wrapper); @@ -230,92 +230,79 @@ export const createCssAxisGizmoView = ( position: "absolute", left: "50%", top: "50%", - transform: "translate(-50%, -50%)", width: `${CENTER_DRAG_HIT_AREA_PX}px`, height: `${CENTER_DRAG_HIT_AREA_PX}px`, - borderRadius: "50%", + transform: "translate(-50%, -50%)", + borderRadius: "9999px", background: "transparent", - zIndex: "1", pointerEvents: "auto", cursor: "move", - userSelect: "none", + zIndex: "1", + }); + + centerHit.title = "Move point"; + centerHit.addEventListener("mousedown", (event) => { + event.preventDefault(); + event.stopPropagation(); }); - centerHit.title = "Move point along active axis"; - centerHit.addEventListener("mousedown", (event) => - beginDrag(event, activeAxisId) - ); - overlay.appendChild(centerHit); + overlay.appendChild(centerHit); options.container.appendChild(overlay); const refresh = () => { const snapshot = options.controller.getSnapshot(); + const { point } = snapshot; - (Object.keys(axisLayers) as GizmoAxisId[]).forEach((axisId) => { - const layer = axisLayers[axisId]; - const axisVisual = getAxisVisual( - axisId, - options.axisDirections, - axisColors - ); + overlay.style.display = "block"; + overlay.style.transform = `translate(calc(-50% + ${ + point.x + }px), calc(-50% + ${-point.y}px))`; - const isActiveAxis = axisId === activeAxisId; - const axisOpacity = isActiveAxis ? 1 : INACTIVE_AXIS_OPACITY; - const arrowScale = isActiveAxis ? 1 : INACTIVE_AXIS_ARROW_SCALE; - const arrowOffsetPx = isActiveAxis - ? AXIS_ARROW_OFFSET_PX - : AXIS_ARROW_OFFSET_PX * INACTIVE_AXIS_ARROW_OFFSET_SCALE; + (["x", "y", "z"] as GizmoAxisId[]).forEach((axisId) => { + const layer = axisLayers[axisId]; + const visual = getAxisVisual(axisId, options.axisDirections, axisColors); + const isActive = axisId === activeAxisId; + const axisOpacity = isActive ? 1 : INACTIVE_AXIS_OPACITY; + const arrowOffsetPx = + AXIS_ARROW_OFFSET_PX * + (isActive ? 1 : INACTIVE_AXIS_ARROW_OFFSET_SCALE); + const arrowScale = isActive ? 1 : INACTIVE_AXIS_ARROW_SCALE; - layer.line.style.width = `${arrowOffsetPx * 2}px`; - layer.line.style.transform = `translate(-50%, -50%) rotate(${axisVisual.angleRad}rad)`; layer.line.style.opacity = `${axisOpacity}`; + layer.line.style.transform = `translate(-50%, -50%) rotate(${visual.angleRad}rad)`; setElementCenterPos( layer.arrowForward, - axisVisual.dirX * arrowOffsetPx, - axisVisual.dirY * arrowOffsetPx + visual.dirX * arrowOffsetPx, + visual.dirY * arrowOffsetPx ); + layer.arrowForward.style.transform = `translate(-50%, -50%) rotate(${ + visual.angleRad + Math.PI / 2 + }rad) scale(${arrowScale})`; + layer.arrowForward.style.opacity = `${axisOpacity}`; + layer.arrowForward.style.cursor = isDraggingByView ? "grabbing" : "move"; + setElementCenterPos( layer.arrowBackward, - -axisVisual.dirX * arrowOffsetPx, - -axisVisual.dirY * arrowOffsetPx + -visual.dirX * arrowOffsetPx, + -visual.dirY * arrowOffsetPx ); - - layer.arrowForward.style.transform = `translate(-50%, -50%) rotate(${ - axisVisual.angleRad + Math.PI / 2 - }rad) scale(${arrowScale})`; layer.arrowBackward.style.transform = `translate(-50%, -50%) rotate(${ - axisVisual.angleRad + Math.PI / 2 + visual.angleRad - Math.PI / 2 }rad) scale(${arrowScale})`; - layer.arrowForward.style.opacity = `${axisOpacity}`; layer.arrowBackward.style.opacity = `${axisOpacity}`; - - const cursor = snapshot.isDragging && isActiveAxis ? "grabbing" : "move"; - layer.arrowForward.style.cursor = cursor; - layer.arrowBackward.style.cursor = cursor; - layer.arrowForward.style.zIndex = isActiveAxis ? "3" : "2"; - layer.arrowBackward.style.zIndex = isActiveAxis ? "3" : "2"; + layer.arrowBackward.style.cursor = isDraggingByView ? "grabbing" : "move"; }); - - centerHit.style.cursor = - snapshot.isDragging || isDraggingByView ? "grabbing" : "move"; - }; - - const setActiveAxisId = (axisId: GizmoAxisId) => { - activeAxisId = axisId; - options.onActiveAxisChange?.(activeAxisId); - options.controller.setAxis( - options.axisOrigin, - options.axisDirections[axisId] - ); - refresh(); }; refresh(); return { getActiveAxisId: () => activeAxisId, - setActiveAxisId, + setActiveAxisId: (axisId) => { + activeAxisId = axisId; + refresh(); + }, refresh, destroy: () => { overlay.remove(); diff --git a/libraries/mapping/gizmo/core/src/lib/gizmoMath.ts b/libraries/mapping/gizmo/core/src/lib/gizmoMath.ts deleted file mode 100644 index 0b74cf6a27..0000000000 --- a/libraries/mapping/gizmo/core/src/lib/gizmoMath.ts +++ /dev/null @@ -1,98 +0,0 @@ -export type GizmoVec3 = { - x: number; - y: number; - z: number; -}; - -export type GizmoRay3 = { - origin: GizmoVec3; - direction: GizmoVec3; -}; - -export type GizmoAxisCandidate = { - id: string; - direction: TVector; - color?: string; - title?: string | null; -}; - -export const AXIS_NUMERIC_EPSILON = 1e-6; - -export const gizmoMagnitudeSquared = (vector: GizmoVec3): number => - vector.x * vector.x + vector.y * vector.y + vector.z * vector.z; - -export const gizmoDot = (a: GizmoVec3, b: GizmoVec3): number => - a.x * b.x + a.y * b.y + a.z * b.z; - -export const gizmoSubtract = (a: GizmoVec3, b: GizmoVec3): GizmoVec3 => ({ - x: a.x - b.x, - y: a.y - b.y, - z: a.z - b.z, -}); - -export const gizmoNormalize = ( - vector: GizmoVec3, - epsilon: number = AXIS_NUMERIC_EPSILON -): GizmoVec3 | null => { - const lengthSquared = gizmoMagnitudeSquared(vector); - if (lengthSquared <= epsilon) return null; - const invLength = 1 / Math.sqrt(lengthSquared); - return { - x: vector.x * invLength, - y: vector.y * invLength, - z: vector.z * invLength, - }; -}; - -export const getClosestAxisParamToRay = ( - ray: GizmoRay3, - axisOrigin: GizmoVec3, - axisDirection: GizmoVec3, - epsilon: number = AXIS_NUMERIC_EPSILON -): number => { - const rayDirection = gizmoNormalize(ray.direction, epsilon); - const normalizedAxisDirection = gizmoNormalize(axisDirection, epsilon); - - if (!rayDirection || !normalizedAxisDirection) { - return 0; - } - - const originDelta = gizmoSubtract(ray.origin, axisOrigin); - - const a = gizmoDot(rayDirection, rayDirection); - const b = gizmoDot(rayDirection, normalizedAxisDirection); - const c = gizmoDot(normalizedAxisDirection, normalizedAxisDirection); - const d = gizmoDot(rayDirection, originDelta); - const e = gizmoDot(normalizedAxisDirection, originDelta); - const denominator = a * c - b * b; - - if (Math.abs(denominator) < epsilon) { - return e; - } - - return (a * e - b * d) / denominator; -}; - -export const findAxisCandidateByDirection = < - T extends { direction: GizmoVec3 } ->( - candidates: T[], - direction: GizmoVec3, - threshold = 0.999, - epsilon: number = AXIS_NUMERIC_EPSILON -): T | null => { - const normalizedDirection = gizmoNormalize(direction, epsilon); - if (!normalizedDirection) return null; - - for (const candidate of candidates) { - const normalizedCandidate = gizmoNormalize(candidate.direction, epsilon); - if (!normalizedCandidate) continue; - if ( - Math.abs(gizmoDot(normalizedCandidate, normalizedDirection)) > threshold - ) { - return candidate; - } - } - - return null; -}; diff --git a/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoMath.ts b/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoMath.ts new file mode 100644 index 0000000000..7b11fe877b --- /dev/null +++ b/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoMath.ts @@ -0,0 +1,135 @@ +import { clamp } from "@carma-commons/math"; +import type { CssPixelPosition, CssPixels } from "@carma/units/types"; +import { Matrix3, Ray, Vector3 } from "three"; +import { AXIS_NUMERIC_EPSILON } from "./constants"; +import { transformPointWithMatrix } from "./svgProjection"; + +export type ViewportRectLike = { + left: number; + top: number; + width: number; + height: number; +}; + +export type ViewportProjectedPoint = CssPixelPosition & { + depth: number; +}; + +export const DEFAULT_VIEW_FOV_RAD = (55 * Math.PI) / 180; +export const MIN_VIEW_FOV_RAD = (10 * Math.PI) / 180; +export const MAX_VIEW_FOV_RAD = (150 * Math.PI) / 180; + +const extractLinear3x3 = (matrix: readonly number[]): Matrix3 => + new Matrix3().set( + matrix[0] ?? 1, + matrix[1] ?? 0, + matrix[2] ?? 0, + matrix[4] ?? 0, + matrix[5] ?? 1, + matrix[6] ?? 0, + matrix[8] ?? 0, + matrix[9] ?? 0, + matrix[10] ?? 1 + ); + +const invert3x3 = (matrix: Matrix3): Matrix3 | null => { + const determinant = matrix.determinant(); + if (Math.abs(determinant) <= AXIS_NUMERIC_EPSILON) return null; + return matrix.clone().invert(); +}; + +const multiplyMat3Vec3 = (matrix: Matrix3, vector: Vector3): Vector3 => + vector.clone().applyMatrix3(matrix); + +const normalizeOrNull = (vector: Vector3): Vector3 | null => { + if (vector.lengthSq() <= AXIS_NUMERIC_EPSILON) return null; + return vector.clone().normalize(); +}; + +const resolveTanHalfFov = (fovRad: number): number | null => { + const safeFovRad = clamp(fovRad, MIN_VIEW_FOV_RAD, MAX_VIEW_FOV_RAD); + const tanHalfFov = Math.tan(safeFovRad / 2); + if (!Number.isFinite(tanHalfFov) || tanHalfFov <= AXIS_NUMERIC_EPSILON) { + return null; + } + return tanHalfFov; +}; + +export const projectPointToViewport = ( + point: Vector3, + viewMatrix: number[], + viewportRect: ViewportRectLike, + fovRad: number +): ViewportProjectedPoint | null => { + const safeWidth = Math.max(1, viewportRect.width); + const safeHeight = Math.max(1, viewportRect.height); + const tanHalfFov = resolveTanHalfFov(fovRad); + if (tanHalfFov === null) return null; + + const view = transformPointWithMatrix(point, viewMatrix, { + matrixOrder: "row-major", + }); + if ( + !Number.isFinite(view.x) || + !Number.isFinite(view.y) || + !Number.isFinite(view.z) + ) { + return null; + } + if (view.z <= 0.05) return null; + + const aspect = safeWidth / safeHeight; + const xNdc = view.x / (view.z * tanHalfFov * aspect); + const yNdc = view.y / (view.z * tanHalfFov); + if (!Number.isFinite(xNdc) || !Number.isFinite(yNdc)) return null; + + return { + x: ((xNdc + 1) * 0.5 * safeWidth) as CssPixels, + y: ((1 - yNdc) * 0.5 * safeHeight) as CssPixels, + depth: view.z, + }; +}; + +export const rayFromClientPosition = ( + clientX: number, + clientY: number, + viewportRect: ViewportRectLike, + viewMatrix: number[], + fovRad: number +): Ray | null => { + const safeWidth = Math.max(1, viewportRect.width); + const safeHeight = Math.max(1, viewportRect.height); + const tanHalfFov = resolveTanHalfFov(fovRad); + if (tanHalfFov === null) return null; + + const linear = extractLinear3x3(viewMatrix); + const inverted = invert3x3(linear); + if (!inverted) return null; + + const ndcX = ((clientX - viewportRect.left) / safeWidth) * 2 - 1; + const ndcY = 1 - ((clientY - viewportRect.top) / safeHeight) * 2; + const aspect = safeWidth / safeHeight; + + const directionView = normalizeOrNull( + new Vector3(ndcX * tanHalfFov * aspect, ndcY * tanHalfFov, 1) + ); + if (!directionView) return null; + + const directionLocal = normalizeOrNull( + multiplyMat3Vec3(inverted, directionView) + ); + if (!directionLocal) return null; + + const translationView = new Vector3( + viewMatrix[3] ?? 0, + viewMatrix[7] ?? 0, + viewMatrix[11] ?? 0 + ); + + const originLocal = multiplyMat3Vec3( + inverted, + translationView.clone().multiplyScalar(-1) + ); + + return new Ray(originLocal, directionLocal); +}; diff --git a/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoView.ts b/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoView.ts index 97d7a3d830..6674b30cb2 100644 --- a/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoView.ts +++ b/libraries/mapping/gizmo/core/src/lib/projectedMoveGizmoView.ts @@ -1,28 +1,28 @@ import { buildCirclePoints, + createPlaneBasisFromNormal, getEquilateralTriangleHeight, getEquilateralTrianglePathD, getEquilateralTriangleViewBox, + intersectRayWithPlane, getSupportRadius2d, type Point2, } from "@carma-commons/math"; +import { Vector3 } from "three"; import { createAxisDragConnector, type GizmoAxisDragConnector, } from "./axisDragConnector"; +import { toSvgPathD } from "./svgProjection"; +import { AXIS_NUMERIC_EPSILON } from "./constants"; import { - AXIS_NUMERIC_EPSILON, - gizmoDot, - gizmoNormalize, - type GizmoAxisCandidate, - type GizmoRay3, - type GizmoVec3, -} from "./gizmoMath"; -import { toSvgPathD, transformPointWithMatrix } from "./svgProjection"; + DEFAULT_VIEW_FOV_RAD, + projectPointToViewport, + rayFromClientPosition, +} from "./projectedMoveGizmoMath"; const SVG_NS = "http://www.w3.org/2000/svg"; -const DEFAULT_FOV_DEG = 55; const DEFAULT_DISC_RADIUS = 1.2; const DEFAULT_ACTIVE_ARROW_EDGE_PX = 16; const DEFAULT_INACTIVE_ARROW_EDGE_PX = 12; @@ -44,253 +44,14 @@ const ARROW_LAYER_Z_INDEX = 3; const ROTATION_HANDLE_RADIUS_PX = 8; const ROTATION_HANDLE_OFFSET_FROM_DISC_ZERO_RAD = -Math.PI / 4; -const cloneVec3 = (v: GizmoVec3): GizmoVec3 => ({ x: v.x, y: v.y, z: v.z }); - -const addVec3 = (a: GizmoVec3, b: GizmoVec3): GizmoVec3 => ({ - x: a.x + b.x, - y: a.y + b.y, - z: a.z + b.z, -}); - -const subVec3 = (a: GizmoVec3, b: GizmoVec3): GizmoVec3 => ({ - x: a.x - b.x, - y: a.y - b.y, - z: a.z - b.z, -}); - -const mulVec3Scalar = (v: GizmoVec3, scalar: number): GizmoVec3 => ({ - x: v.x * scalar, - y: v.y * scalar, - z: v.z * scalar, -}); - -const addScaledVec3 = ( - origin: GizmoVec3, - direction: GizmoVec3, - scalar: number -): GizmoVec3 => addVec3(origin, mulVec3Scalar(direction, scalar)); - -const crossVec3 = (a: GizmoVec3, b: GizmoVec3): GizmoVec3 => ({ - x: a.y * b.z - a.z * b.y, - y: a.z * b.x - a.x * b.z, - z: a.x * b.y - a.y * b.x, -}); - -const toRad = (deg: number): number => (deg * Math.PI) / 180; - -const clamp = (value: number, min: number, max: number): number => - Math.max(min, Math.min(max, value)); - -type Matrix3 = { - a11: number; - a12: number; - a13: number; - a21: number; - a22: number; - a23: number; - a31: number; - a32: number; - a33: number; -}; - -type Mat3Inverse = { - determinant: number; - inverse: Matrix3; -}; - -const extractLinear3x3 = (matrix: readonly number[]): Matrix3 => ({ - a11: matrix[0] ?? 1, - a12: matrix[1] ?? 0, - a13: matrix[2] ?? 0, - a21: matrix[4] ?? 0, - a22: matrix[5] ?? 1, - a23: matrix[6] ?? 0, - a31: matrix[8] ?? 0, - a32: matrix[9] ?? 0, - a33: matrix[10] ?? 1, -}); - -const invert3x3 = (matrix: Matrix3): Mat3Inverse | null => { - const { a11, a12, a13, a21, a22, a23, a31, a32, a33 } = matrix; - - const b11 = a22 * a33 - a23 * a32; - const b12 = -(a21 * a33 - a23 * a31); - const b13 = a21 * a32 - a22 * a31; - - const b21 = -(a12 * a33 - a13 * a32); - const b22 = a11 * a33 - a13 * a31; - const b23 = -(a11 * a32 - a12 * a31); - - const b31 = a12 * a23 - a13 * a22; - const b32 = -(a11 * a23 - a13 * a21); - const b33 = a11 * a22 - a12 * a21; - - const determinant = a11 * b11 + a12 * b12 + a13 * b13; - if (Math.abs(determinant) <= AXIS_NUMERIC_EPSILON) return null; - - const invDet = 1 / determinant; - return { - determinant, - inverse: { - a11: b11 * invDet, - a12: b21 * invDet, - a13: b31 * invDet, - a21: b12 * invDet, - a22: b22 * invDet, - a23: b32 * invDet, - a31: b13 * invDet, - a32: b23 * invDet, - a33: b33 * invDet, - }, - }; -}; - -const multiplyMat3Vec3 = (matrix: Matrix3, vector: GizmoVec3): GizmoVec3 => ({ - x: matrix.a11 * vector.x + matrix.a12 * vector.y + matrix.a13 * vector.z, - y: matrix.a21 * vector.x + matrix.a22 * vector.y + matrix.a23 * vector.z, - z: matrix.a31 * vector.x + matrix.a32 * vector.y + matrix.a33 * vector.z, -}); - -type ProjectedPoint = { - x: number; - y: number; - depth: number; -}; - -const projectPointToViewport = ( - point: GizmoVec3, - viewMatrix: number[], - viewportRect: DOMRect | ClientRect, - fovDeg: number -): ProjectedPoint | null => { - const safeWidth = Math.max(1, viewportRect.width); - const safeHeight = Math.max(1, viewportRect.height); - const safeFov = clamp(fovDeg, 10, 150); - const tanHalfFov = Math.tan(toRad(safeFov) / 2); - if (!Number.isFinite(tanHalfFov) || tanHalfFov <= AXIS_NUMERIC_EPSILON) { - return null; - } - - const view = transformPointWithMatrix(point, viewMatrix, { - matrixOrder: "row-major", - }); - if ( - !Number.isFinite(view.x) || - !Number.isFinite(view.y) || - !Number.isFinite(view.z) - ) { - return null; - } - if (view.z <= 0.05) return null; - - const aspect = safeWidth / safeHeight; - const xNdc = view.x / (view.z * tanHalfFov * aspect); - const yNdc = view.y / (view.z * tanHalfFov); - if (!Number.isFinite(xNdc) || !Number.isFinite(yNdc)) return null; - - return { - x: (xNdc + 1) * 0.5 * safeWidth, - y: (1 - yNdc) * 0.5 * safeHeight, - depth: view.z, - }; -}; - -const rayFromClientPosition = ( - clientX: number, - clientY: number, - viewportRect: DOMRect | ClientRect, - viewMatrix: number[], - fovDeg: number -): GizmoRay3 | null => { - const safeWidth = Math.max(1, viewportRect.width); - const safeHeight = Math.max(1, viewportRect.height); - const safeFov = clamp(fovDeg, 10, 150); - const tanHalfFov = Math.tan(toRad(safeFov) / 2); - if (!Number.isFinite(tanHalfFov) || tanHalfFov <= AXIS_NUMERIC_EPSILON) { - return null; - } - - const linear = extractLinear3x3(viewMatrix); - const inverted = invert3x3(linear); - if (!inverted) return null; - - const ndcX = ((clientX - viewportRect.left) / safeWidth) * 2 - 1; - const ndcY = 1 - ((clientY - viewportRect.top) / safeHeight) * 2; - const aspect = safeWidth / safeHeight; - - const directionView = gizmoNormalize( - { - x: ndcX * tanHalfFov * aspect, - y: ndcY * tanHalfFov, - z: 1, - }, - AXIS_NUMERIC_EPSILON - ); - if (!directionView) return null; - - const directionLocal = gizmoNormalize( - multiplyMat3Vec3(inverted.inverse, directionView), - AXIS_NUMERIC_EPSILON - ); - if (!directionLocal) return null; - - const translationView: GizmoVec3 = { - x: viewMatrix[3] ?? 0, - y: viewMatrix[7] ?? 0, - z: viewMatrix[11] ?? 0, - }; - - const originLocal = multiplyMat3Vec3(inverted.inverse, { - x: -translationView.x, - y: -translationView.y, - z: -translationView.z, - }); - - return { - origin: originLocal, - direction: directionLocal, - }; -}; - -const intersectRayWithPlane = ( - ray: GizmoRay3, - planeOrigin: GizmoVec3, - planeNormal: GizmoVec3 -): GizmoVec3 | null => { - const denominator = gizmoDot(ray.direction, planeNormal); - if (Math.abs(denominator) <= AXIS_NUMERIC_EPSILON) return null; - - const originToPlane = subVec3(planeOrigin, ray.origin); - const t = gizmoDot(originToPlane, planeNormal) / denominator; - if (!Number.isFinite(t)) return null; - - return addScaledVec3(ray.origin, ray.direction, t); -}; - -const createPlaneBasis = ( - normal: GizmoVec3 -): { xAxis: GizmoVec3; yAxis: GizmoVec3 } => { - const up = gizmoNormalize(normal) ?? { x: 0, y: 0, z: 1 }; - const reference = - Math.abs(gizmoDot(up, { x: 0, y: 0, z: 1 })) > 0.9 - ? { x: 1, y: 0, z: 0 } - : { x: 0, y: 0, z: 1 }; - const xAxis = gizmoNormalize(crossVec3(up, reference)) ?? { - x: 1, - y: 0, - z: 0, - }; - const yAxis = gizmoNormalize(crossVec3(xAxis, up)) ?? { x: 0, y: 1, z: 0 }; - return { xAxis, yAxis }; -}; - const ensureNormalizedAxisCandidates = ( axisCandidates: ProjectedMoveGizmoAxisCandidate[] ): ProjectedMoveGizmoAxisCandidate[] => { const normalized = axisCandidates .map((candidate) => { - const direction = gizmoNormalize(candidate.direction); - if (!direction) return null; + const direction = candidate.direction.clone(); + if (direction.lengthSq() <= AXIS_NUMERIC_EPSILON) return null; + direction.normalize(); return { ...candidate, direction, @@ -316,25 +77,30 @@ type DragState = | { mode: "axis"; connector: GizmoAxisDragConnector; - axisDirection: GizmoVec3; - startPoint: GizmoVec3; + axisDirection: Vector3; + startPoint: Vector3; } | { mode: "plane"; - planeNormal: GizmoVec3; - startPoint: GizmoVec3; - startPlanePoint: GizmoVec3; + planeNormal: Vector3; + startPoint: Vector3; + startPlanePoint: Vector3; }; -export type ProjectedMoveGizmoAxisCandidate = GizmoAxisCandidate; +export type ProjectedMoveGizmoAxisCandidate = { + id: string; + direction: Vector3; + color?: string; + title?: string | null; +}; export type ProjectedMoveGizmoViewOptions = { container: HTMLElement; axisCandidates: ProjectedMoveGizmoAxisCandidate[]; - initialPoint?: GizmoVec3; + initialPoint?: Vector3; initialActiveAxisId?: string | null; viewMatrix?: number[]; - fovDeg?: number; + fovRad?: number; discRadius?: number; axisWidthPx?: number; outlineStrokeWidthPx?: number; @@ -344,16 +110,16 @@ export type ProjectedMoveGizmoViewOptions = { centerPlaneDragCursor?: string; showRotationHandle?: boolean; getViewportRect?: () => DOMRect | ClientRect | null; - onPointChange?: (point: GizmoVec3) => void; + onPointChange?: (point: Vector3) => void; onActiveAxisChange?: (axisId: string) => void; onDragStateChange?: (isDragging: boolean) => void; }; export type ProjectedMoveGizmoView = { - setPoint: (point: GizmoVec3) => void; - getPoint: () => GizmoVec3; + setPoint: (point: Vector3) => void; + getPoint: () => Vector3; setViewMatrix: (viewMatrix: number[]) => void; - setFovDeg: (fovDeg: number) => void; + setFovRad: (fovRad: number) => void; setDiscRadius: (discRadius: number) => void; setActiveAxisId: (axisId: string) => void; getActiveAxisId: () => string; @@ -412,7 +178,7 @@ export const createProjectedMoveGizmoView = ( options.centerPlaneDragCursor ?? DEFAULT_DISC_CURSOR; const showRotationHandle = options.showRotationHandle ?? true; - let point = cloneVec3(options.initialPoint ?? { x: 0, y: 0, z: 0 }); + let point = options.initialPoint?.clone() ?? new Vector3(0, 0, 0); let activeAxisId = options.initialActiveAxisId && normalizedCandidates.some( @@ -424,7 +190,7 @@ export const createProjectedMoveGizmoView = ( let viewMatrix = Array.from( options.viewMatrix ?? [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 4, 0, 0, 0, 1] ); - let fovDeg = options.fovDeg ?? DEFAULT_FOV_DEG; + let fovRad = options.fovRad ?? DEFAULT_VIEW_FOV_RAD; let discRadius = Math.max( AXIS_NUMERIC_EPSILON, options.discRadius ?? DEFAULT_DISC_RADIUS @@ -631,10 +397,10 @@ export const createProjectedMoveGizmoView = ( ); }; - const setPointInternal = (nextPoint: GizmoVec3, emit = true) => { - point = cloneVec3(nextPoint); + const setPointInternal = (nextPoint: Vector3, emit = true) => { + point = nextPoint.clone(); if (emit) { - options.onPointChange?.(cloneVec3(point)); + options.onPointChange?.(point.clone()); } }; @@ -679,7 +445,7 @@ export const createProjectedMoveGizmoView = ( clientY, viewportRect, viewMatrix, - fovDeg + fovRad ); if (!ray) return; @@ -698,7 +464,7 @@ export const createProjectedMoveGizmoView = ( mode: "axis", connector, axisDirection: axis.direction, - startPoint: cloneVec3(point), + startPoint: point.clone(), }; isDragging = true; options.onDragStateChange?.(true); @@ -712,17 +478,15 @@ export const createProjectedMoveGizmoView = ( moveEvent.clientY, moveRect, viewMatrix, - fovDeg + fovRad ); if (!moveRay) return; const nextAxisParam = dragState.connector.updateDragFromRay(moveRay); if (nextAxisParam === null) return; setPointInternal( - addScaledVec3( - dragState.startPoint, - dragState.axisDirection, - nextAxisParam - ) + dragState.startPoint + .clone() + .add(dragState.axisDirection.clone().multiplyScalar(nextAxisParam)) ); refresh(); }); @@ -740,7 +504,7 @@ export const createProjectedMoveGizmoView = ( clientY, viewportRect, viewMatrix, - fovDeg + fovRad ); if (!ray) return; const startPlanePoint = intersectRayWithPlane( @@ -753,8 +517,8 @@ export const createProjectedMoveGizmoView = ( stopDragging(); dragState = { mode: "plane", - planeNormal: cloneVec3(activeAxis.direction), - startPoint: cloneVec3(point), + planeNormal: activeAxis.direction.clone(), + startPoint: point.clone(), startPlanePoint, }; isDragging = true; @@ -769,7 +533,7 @@ export const createProjectedMoveGizmoView = ( moveEvent.clientY, moveRect, viewMatrix, - fovDeg + fovRad ); if (!moveRay) return; const currentPlanePoint = intersectRayWithPlane( @@ -778,8 +542,8 @@ export const createProjectedMoveGizmoView = ( dragState.planeNormal ); if (!currentPlanePoint) return; - const delta = subVec3(currentPlanePoint, dragState.startPlanePoint); - setPointInternal(addVec3(dragState.startPoint, delta)); + const delta = currentPlanePoint.clone().sub(dragState.startPlanePoint); + setPointInternal(dragState.startPoint.clone().add(delta)); refresh(); }); @@ -821,7 +585,7 @@ export const createProjectedMoveGizmoView = ( point, viewMatrix, viewportRect, - fovDeg + fovRad ); if (!anchorCanvas) { gizmoGroup.style.display = "none"; @@ -842,7 +606,7 @@ export const createProjectedMoveGizmoView = ( normalizedCandidates.forEach((candidate) => { const pathEl = discPathById[candidate.id]; - const planeBasis = createPlaneBasis(candidate.direction); + const planeBasis = createPlaneBasisFromNormal(candidate.direction); const circlePointsWorld = buildCirclePoints( discRadius, DISC_OUTLINE_SEGMENTS @@ -850,18 +614,15 @@ export const createProjectedMoveGizmoView = ( const projectedOutlinePoints = circlePointsWorld .map((circlePoint): Point2 | null => { - const worldPoint = addVec3( - point, - addVec3( - mulVec3Scalar(planeBasis.xAxis, circlePoint.x), - mulVec3Scalar(planeBasis.yAxis, circlePoint.y) - ) - ); + const worldPoint = point + .clone() + .add(planeBasis.xAxis.clone().multiplyScalar(circlePoint.x)) + .add(planeBasis.yAxis.clone().multiplyScalar(circlePoint.y)); const projected = projectPointToViewport( worldPoint, viewMatrix, viewportRect, - fovDeg + fovRad ); if (!projected) return null; const localX = projected.x - anchorCanvas.x; @@ -925,22 +686,26 @@ export const createProjectedMoveGizmoView = ( // Keep the handle on the projected disc by projecting a real point on // the active disc in world/view space. - const planeBasis = createPlaneBasis(activeAxisCandidate.direction); - const worldHandlePoint = addVec3( - point, - addVec3( - mulVec3Scalar( - planeBasis.xAxis, - Math.cos(handleAngleRad) * discRadius - ), - mulVec3Scalar(planeBasis.yAxis, Math.sin(handleAngleRad) * discRadius) - ) + const planeBasis = createPlaneBasisFromNormal( + activeAxisCandidate.direction ); + const worldHandlePoint = point + .clone() + .add( + planeBasis.xAxis + .clone() + .multiplyScalar(Math.cos(handleAngleRad) * discRadius) + ) + .add( + planeBasis.yAxis + .clone() + .multiplyScalar(Math.sin(handleAngleRad) * discRadius) + ); const projectedHandlePoint = projectPointToViewport( worldHandlePoint, viewMatrix, viewportRect, - fovDeg + fovRad ); if (projectedHandlePoint) { handleX = projectedHandlePoint.x - anchorCanvas.x; @@ -967,16 +732,20 @@ export const createProjectedMoveGizmoView = ( let axisAngleRad = previousDirection.angleRad; const plusPoint = projectPointToViewport( - addScaledVec3(point, candidate.direction, sampleDistance), + point + .clone() + .add(candidate.direction.clone().multiplyScalar(sampleDistance)), viewMatrix, viewportRect, - fovDeg + fovRad ); const minusPoint = projectPointToViewport( - addScaledVec3(point, candidate.direction, -sampleDistance), + point + .clone() + .add(candidate.direction.clone().multiplyScalar(-sampleDistance)), viewMatrix, viewportRect, - fovDeg + fovRad ); if (plusPoint && minusPoint) { @@ -1067,7 +836,7 @@ export const createProjectedMoveGizmoView = ( : centerPlaneDragCursor; }; - const setPoint = (nextPoint: GizmoVec3) => { + const setPoint = (nextPoint: Vector3) => { setPointInternal(nextPoint, false); refresh(); }; @@ -1077,8 +846,8 @@ export const createProjectedMoveGizmoView = ( refresh(); }; - const setFov = (nextFovDeg: number) => { - fovDeg = nextFovDeg; + const setFov = (nextFovRad: number) => { + fovRad = nextFovRad; refresh(); }; @@ -1105,9 +874,9 @@ export const createProjectedMoveGizmoView = ( return { setPoint, - getPoint: () => cloneVec3(point), + getPoint: () => point.clone(), setViewMatrix, - setFovDeg: setFov, + setFovRad: setFov, setDiscRadius: setRadius, setActiveAxisId, getActiveAxisId: () => activeAxisId, diff --git a/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx b/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx index 1eddd32387..7812afe677 100644 --- a/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx +++ b/libraries/mapping/map-controls-layout/src/lib/components/Control.tsx @@ -20,7 +20,10 @@ function Control({ position, children, order }: ControlProps) { return () => { removeControl({ position, component: children, order }); }; - }, [children]); + // Control registration must stay mount-scoped; re-registering on every + // render (new ReactNode identity) causes control layout update loops. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); return <>; } diff --git a/libraries/providers/label-overlay/src/lib/LabelOverlayProvider.tsx b/libraries/providers/label-overlay/src/lib/LabelOverlayProvider.tsx index f97f6f1382..96e70b068c 100644 --- a/libraries/providers/label-overlay/src/lib/LabelOverlayProvider.tsx +++ b/libraries/providers/label-overlay/src/lib/LabelOverlayProvider.tsx @@ -13,6 +13,27 @@ import { createPortal } from "react-dom"; import { LabelOverlayContext } from "./LabelOverlayContext"; import type { LabelOverlayElement, LabelOverlayContextType } from "./types"; +const hasSameOverlayPortalContent = ( + left: LabelOverlayElement, + right: LabelOverlayElement +) => { + if (left.contentKey !== undefined || right.contentKey !== undefined) { + return left.contentKey === right.contentKey; + } + + return left.content === right.content; +}; + +const shouldReuseOverlayPortal = ( + existing: LabelOverlayElement, + next: LabelOverlayElement +) => + hasSameOverlayPortalContent(existing, next) && + existing.zIndex === next.zIndex && + existing.onClick === next.onClick && + existing.onDoubleClick === next.onDoubleClick && + existing.cursor === next.cursor; + interface LabelOverlayProviderProps { children: ReactNode; containerRef?: RefObject; @@ -82,21 +103,10 @@ export const LabelOverlayProvider: React.FC = ({ const addLabelOverlayElement = useCallback( (element: LabelOverlayElement) => { - // Check if element really needs an update const existing = overlayElementsRef.current.get(element.id); - if (existing) { - // Trigger provider re-render only when portal structure/props change. - if ( - existing.content === element.content && - existing.zIndex === element.zIndex && - existing.onClick === element.onClick && - existing.onDoubleClick === element.onDoubleClick - ) { - // Update mutable runtime fields (position callbacks/visibility) without - // forcing portal re-creation. - overlayElementsRef.current.set(element.id, element); - return; - } + if (existing && shouldReuseOverlayPortal(existing, element)) { + overlayElementsRef.current.set(element.id, element); + return; } overlayElementsRef.current.set(element.id, element); @@ -118,11 +128,8 @@ export const LabelOverlayProvider: React.FC = ({ (id: string, updates: Partial) => { const existing = overlayElementsRef.current.get(id); if (existing) { - // Only trigger re-render if content property changes - const shouldRender = - updates.content !== undefined && updates.content !== existing.content; - const updated = { ...existing, ...updates }; + const shouldRender = !shouldReuseOverlayPortal(existing, updated); overlayElementsRef.current.set(id, updated); if (shouldRender) { @@ -309,9 +316,10 @@ export const LabelOverlayProvider: React.FC = ({ pointerEvents: element.onClick || element.onDoubleClick ? "auto" : "none", cursor: - element.onClick || element.onDoubleClick + element.cursor ?? + (element.onClick || element.onDoubleClick ? "pointer" - : "default", + : "default"), }} onClick={element.onClick} onDoubleClick={element.onDoubleClick} diff --git a/libraries/providers/label-overlay/src/lib/components/PillbuttonLabelMarker.tsx b/libraries/providers/label-overlay/src/lib/components/PillbuttonLabelMarker.tsx index c86ffbc680..c5e966be52 100644 --- a/libraries/providers/label-overlay/src/lib/components/PillbuttonLabelMarker.tsx +++ b/libraries/providers/label-overlay/src/lib/components/PillbuttonLabelMarker.tsx @@ -19,6 +19,24 @@ const EXTENDED_RIGHT_EXTRA_PADDING_PX = 2; const GROW_SHRINK_WIDTH_TRANSITION_MS = 200; const SHRINK_WIDTH_TRANSITION_DELAY_MS = 3000; +const setNullableNumberStateIfChanged = ( + setState: React.Dispatch>, + nextValue: number | null +) => { + setState((previousValue) => + previousValue === nextValue ? previousValue : nextValue + ); +}; + +const setNumberStateIfChanged = ( + setState: React.Dispatch>, + nextValue: number +) => { + setState((previousValue) => + previousValue === nextValue ? previousValue : nextValue + ); +}; + const estimateCompactAnchorOffsetPx = (fontSize: string): number => { const parsed = Number.parseFloat(fontSize); if (!Number.isFinite(parsed) || parsed <= 0) return 8; @@ -54,8 +72,8 @@ interface PillbuttonLabelMarkerProps { onDoubleClick: (event: React.MouseEvent) => void; onMouseDown: (event: React.MouseEvent) => void; onMouseUp: () => void; - onMouseEnter: () => void; - onMouseLeave: () => void; + onMouseEnter: (event: React.MouseEvent) => void; + onMouseLeave: (event: React.MouseEvent) => void; } export const PillbuttonLabelMarker = ({ @@ -150,13 +168,14 @@ export const PillbuttonLabelMarker = ({ useLayoutEffect(() => { const el = compactRef.current; if (!el || !hasCompact) { - setCompactWidthPx(null); + setNullableNumberStateIfChanged(setCompactWidthPx, null); return; } const scrollW = Math.ceil(el.scrollWidth); const circlePx = el.offsetHeight; if (shouldForceCompactPill || scrollW > circlePx) { - setCompactWidthPx( + setNullableNumberStateIfChanged( + setCompactWidthPx, Math.max( scrollW + COMPACT_HORIZONTAL_PADDING_PX * 2 + @@ -166,7 +185,7 @@ export const PillbuttonLabelMarker = ({ ) ); } else { - setCompactWidthPx(null); + setNullableNumberStateIfChanged(setCompactWidthPx, null); } }, [ hasCompact, @@ -189,9 +208,9 @@ export const PillbuttonLabelMarker = ({ useLayoutEffect(() => { if (resizeMode !== "fast-grow-slow-shrink" || !showExtended) { previousExtendedWidthPxRef.current = null; - setAnimatedExtendedWidthPx(null); - setExtendedWidthTransitionMs(0); - setExtendedWidthTransitionDelayMs(0); + setNullableNumberStateIfChanged(setAnimatedExtendedWidthPx, null); + setNumberStateIfChanged(setExtendedWidthTransitionMs, 0); + setNumberStateIfChanged(setExtendedWidthTransitionDelayMs, 0); return; } @@ -202,20 +221,29 @@ export const PillbuttonLabelMarker = ({ const previousWidthPx = previousExtendedWidthPxRef.current; if (previousWidthPx === null) { - setExtendedWidthTransitionMs(0); - setExtendedWidthTransitionDelayMs(0); + setNumberStateIfChanged(setExtendedWidthTransitionMs, 0); + setNumberStateIfChanged(setExtendedWidthTransitionDelayMs, 0); } else if (nextWidthPx > previousWidthPx) { - setExtendedWidthTransitionMs(GROW_SHRINK_WIDTH_TRANSITION_MS); - setExtendedWidthTransitionDelayMs(0); + setNumberStateIfChanged( + setExtendedWidthTransitionMs, + GROW_SHRINK_WIDTH_TRANSITION_MS + ); + setNumberStateIfChanged(setExtendedWidthTransitionDelayMs, 0); } else if (nextWidthPx < previousWidthPx) { - setExtendedWidthTransitionMs(GROW_SHRINK_WIDTH_TRANSITION_MS); - setExtendedWidthTransitionDelayMs(SHRINK_WIDTH_TRANSITION_DELAY_MS); + setNumberStateIfChanged( + setExtendedWidthTransitionMs, + GROW_SHRINK_WIDTH_TRANSITION_MS + ); + setNumberStateIfChanged( + setExtendedWidthTransitionDelayMs, + SHRINK_WIDTH_TRANSITION_DELAY_MS + ); } else { - setExtendedWidthTransitionDelayMs(0); + setNumberStateIfChanged(setExtendedWidthTransitionDelayMs, 0); } previousExtendedWidthPxRef.current = nextWidthPx; - setAnimatedExtendedWidthPx(nextWidthPx); + setNullableNumberStateIfChanged(setAnimatedExtendedWidthPx, nextWidthPx); }, [resizeMode, showExtended, content, fontFamily, fontSize, fontWeight]); const getCompactStylesByMountSide = ( diff --git a/libraries/providers/label-overlay/src/lib/components/PointLabel.tsx b/libraries/providers/label-overlay/src/lib/components/PointLabel.tsx index 908e73529d..ba497d3524 100644 --- a/libraries/providers/label-overlay/src/lib/components/PointLabel.tsx +++ b/libraries/providers/label-overlay/src/lib/components/PointLabel.tsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from "react"; +import type { CssPixelPosition } from "@carma/units/types"; import { PointLabelMarker, type PointLabelAttach } from "./PointLabelMarker"; import { PillbuttonLabelMarker } from "./PillbuttonLabelMarker"; import { PointLabelStem } from "./PointLabelStem"; @@ -47,8 +48,12 @@ interface PointLabelProps extends PointLabelStyleProps { onDoubleClick?: () => void; onLongPress?: () => void; longPressDurationMs?: number; - onHoverChange?: (hovered: boolean) => void; + onHoverChange?: ( + hovered: boolean, + anchorPosition?: CssPixelPosition | null + ) => void; markerOnlyPointerEvents?: boolean; + forceMarkerInteractionTarget?: boolean; onMarkerDragStart?: (clientX: number, clientY: number) => void; onMarkerDragMove?: (clientX: number, clientY: number) => void; onMarkerDragEnd?: () => void; @@ -158,6 +163,7 @@ export const PointLabel = React.memo( longPressDurationMs = 300, onHoverChange, markerOnlyPointerEvents = false, + forceMarkerInteractionTarget = false, onMarkerDragStart, onMarkerDragMove, onMarkerDragEnd, @@ -237,9 +243,14 @@ export const PointLabel = React.memo( const labelPointerEvents = isInteractive && !markerOnlyPointerEvents ? "auto" : "none"; const renderInvisibleInteractionMarker = - markerOnlyPointerEvents && hideMarker && isInteractive; - const cursor = - onClick || onDoubleClick || onLongPress ? "pointer" : "default"; + isInteractive && + hideMarker && + (forceMarkerInteractionTarget || markerOnlyPointerEvents); + const cursor = forceMarkerInteractionTarget + ? "none" + : onClick || onDoubleClick || onLongPress + ? "pointer" + : "default"; const effectiveLineColor = lineColor; const effectiveTextColor = textColor; const effectiveBackgroundColor = selected @@ -267,18 +278,46 @@ export const PointLabel = React.memo( fullBorder; const usePillLabelShape = shouldRenderPillbuttonMarker || (!collapse && hasCompactContent); - const handleMouseEnter = () => { + const getOverlayAnchorPosition = ( + target: EventTarget | null + ): CssPixelPosition | null => { + if (!(target instanceof HTMLElement)) return null; + const overlayHost = target.closest( + "[data-label-overlay-id]" + ) as HTMLElement | null; + if (!overlayHost) return null; + + const x = Number.parseFloat(overlayHost.style.left); + const y = Number.parseFloat(overlayHost.style.top); + if (!Number.isFinite(x) || !Number.isFinite(y)) { + return null; + } + + return { x, y } as CssPixelPosition; + }; + const isTransitionWithinSamePointLabel = ( + relatedTarget: EventTarget | null + ) => { + if (!(relatedTarget instanceof Element)) return false; + const relatedPointId = relatedTarget + .closest("[data-point-label-id]") + ?.getAttribute("data-point-label-id"); + return Boolean(pointId && relatedPointId === pointId); + }; + const handleMouseEnter = (event: React.MouseEvent) => { + if (isTransitionWithinSamePointLabel(event.relatedTarget)) return; if (!isInteractive || isHoveredRef.current) return; isHoveredRef.current = true; setIsHovered(true); - onHoverChange?.(true); + onHoverChange?.(true, getOverlayAnchorPosition(event.currentTarget)); }; - const handleMouseLeave = () => { + const handleMouseLeave = (event: React.MouseEvent) => { clearLongPressTimeout(); + if (isTransitionWithinSamePointLabel(event.relatedTarget)) return; if (!isInteractive || !isHoveredRef.current) return; isHoveredRef.current = false; setIsHovered(false); - onHoverChange?.(false); + onHoverChange?.(false, getOverlayAnchorPosition(event.currentTarget)); }; const handleClick = (event: React.MouseEvent) => { event.stopPropagation(); @@ -460,11 +499,8 @@ export const PointLabel = React.memo( window.clearTimeout(longPressTimeoutRef.current); } clearMarkerDragListeners(); - if (isHoveredRef.current) { - onHoverChange?.(false); - } }, - [onHoverChange] + [] ); return ( diff --git a/libraries/providers/label-overlay/src/lib/components/PointLabelMarker.tsx b/libraries/providers/label-overlay/src/lib/components/PointLabelMarker.tsx index 751e9120d6..2f41416c1f 100644 --- a/libraries/providers/label-overlay/src/lib/components/PointLabelMarker.tsx +++ b/libraries/providers/label-overlay/src/lib/components/PointLabelMarker.tsx @@ -42,8 +42,8 @@ interface PointLabelMarkerProps { onDoubleClick: (event: React.MouseEvent) => void; onMouseDown: (event: React.MouseEvent) => void; onMouseUp: () => void; - onMouseEnter: () => void; - onMouseLeave: () => void; + onMouseEnter: (event: React.MouseEvent) => void; + onMouseLeave: (event: React.MouseEvent) => void; } export const PointLabelMarker = ({ diff --git a/libraries/providers/label-overlay/src/lib/pointLabelLayout/config.ts b/libraries/providers/label-overlay/src/lib/pointLabelLayout/config.ts index dd487efe8c..f29a360aee 100644 --- a/libraries/providers/label-overlay/src/lib/pointLabelLayout/config.ts +++ b/libraries/providers/label-overlay/src/lib/pointLabelLayout/config.ts @@ -1,4 +1,5 @@ import type { PointLabelAttach } from "../components/PointLabel"; +import { clamp } from "@carma-commons/math"; import type { DynamicLabelPlacementConfig, @@ -35,9 +36,6 @@ export const DEFAULT_POINT_LABEL_LAYOUT_CONFIG: PointLabelLayoutConfig = { transitionDurationMs: 300, }; -const clamp = (value: number, min: number, max: number): number => - Math.max(min, Math.min(max, value)); - const normalizeAngle = (angleRad: number): number => { let normalized = angleRad; while (normalized <= -Math.PI) normalized += 2 * Math.PI; diff --git a/libraries/providers/label-overlay/src/lib/pointLabelLayout/forceDirectedPlacement.ts b/libraries/providers/label-overlay/src/lib/pointLabelLayout/forceDirectedPlacement.ts index 999f93b4e4..51b3b82a9d 100644 --- a/libraries/providers/label-overlay/src/lib/pointLabelLayout/forceDirectedPlacement.ts +++ b/libraries/providers/label-overlay/src/lib/pointLabelLayout/forceDirectedPlacement.ts @@ -1,3 +1,4 @@ +import { clamp } from "@carma-commons/math"; import { createLabelRectFromConnector, getRectCenter, @@ -23,9 +24,6 @@ type RelaxPlacementWithForcesInput = { config: DynamicLabelPlacementConfig; }; -const clamp = (value: number, min: number, max: number): number => - Math.max(min, Math.min(max, value)); - export const relaxPlacementWithForces = ({ anchor, labelText, diff --git a/libraries/providers/label-overlay/src/lib/types.ts b/libraries/providers/label-overlay/src/lib/types.ts index 912b5e77c4..61e31629a6 100644 --- a/libraries/providers/label-overlay/src/lib/types.ts +++ b/libraries/providers/label-overlay/src/lib/types.ts @@ -1,16 +1,19 @@ import type { ReactNode } from "react"; import type { CssPixelPosition } from "@carma/units/types"; +import type { CSSProperties } from "react"; export interface LabelOverlayElement { id: string; getCanvasPosition?: () => CssPixelPosition | null; updatePosition?: (elementDiv: HTMLElement) => boolean; content: ReactNode; + contentKey?: string; zIndex?: number; visible?: boolean; isHidden?: boolean; onClick?: () => void; onDoubleClick?: () => void; + cursor?: CSSProperties["cursor"]; } export interface LabelOverlayContextType { diff --git a/libraries/providers/label-overlay/src/lib/useLineVisualizers.ts b/libraries/providers/label-overlay/src/lib/useLineVisualizers.ts index df0eb929c2..79760dd882 100644 --- a/libraries/providers/label-overlay/src/lib/useLineVisualizers.ts +++ b/libraries/providers/label-overlay/src/lib/useLineVisualizers.ts @@ -32,12 +32,196 @@ const LABEL_POSITION_STABILITY_EPSILON_PX = 0.85; const LABEL_ANGLE_STABILITY_EPSILON_DEG = 0.75; const LABEL_VISIBILITY_HYSTERESIS_PX = 2; +const overlayReferenceIdByValue = new WeakMap(); +let nextOverlayReferenceId = 1; + +const getOverlayReferenceSignature = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object" || typeof value === "function") { + const ref = value as object; + const existingId = overlayReferenceIdByValue.get(ref); + if (existingId) { + return `ref:${existingId}`; + } + + const nextId = nextOverlayReferenceId++; + overlayReferenceIdByValue.set(ref, nextId); + return `ref:${nextId}`; + } + + return String(value); +}; + +const buildLineOverlayUpdatePosition = + (line: LineVisualizerData) => (elementDiv: HTMLElement) => { + const canvasLine = line.getCanvasLine ? line.getCanvasLine() : null; + if (!canvasLine) return false; + + const lineEl = elementDiv.querySelector( + '[data-line-visualizer-segment="true"]' + ) as SVGLineElement | null; + if (!lineEl) return false; + + elementDiv.style.position = "absolute"; + elementDiv.style.left = "0"; + elementDiv.style.top = "0"; + elementDiv.style.width = "100%"; + elementDiv.style.height = "100%"; + elementDiv.style.transform = "none"; + // Keep map interaction free except for explicit line/label hit targets. + elementDiv.style.pointerEvents = "none"; + elementDiv.style.zIndex = `${LINE_OVERLAY_Z_INDEX}`; + + lineEl.setAttribute("x1", `${canvasLine.start.x}`); + lineEl.setAttribute("y1", `${canvasLine.start.y}`); + lineEl.setAttribute("x2", `${canvasLine.end.x}`); + lineEl.setAttribute("y2", `${canvasLine.end.y}`); + + const lineHitTargetEl = elementDiv.querySelector( + '[data-line-visualizer-hit-target="true"]' + ) as SVGLineElement | null; + if (lineHitTargetEl) { + lineHitTargetEl.setAttribute("x1", `${canvasLine.start.x}`); + lineHitTargetEl.setAttribute("y1", `${canvasLine.start.y}`); + lineHitTargetEl.setAttribute("x2", `${canvasLine.end.x}`); + lineHitTargetEl.setAttribute("y2", `${canvasLine.end.y}`); + } + + const textEl = elementDiv.querySelector( + '[data-line-visualizer-text="true"]' + ) as SVGTextElement | null; + if (textEl && line.labelText) { + const dx = canvasLine.end.x - canvasLine.start.x; + const dy = canvasLine.end.y - canvasLine.start.y; + const lineLength = Math.hypot(dx, dy); + if (lineLength > MIN_LINE_LENGTH_PX) { + const midX = (canvasLine.start.x + canvasLine.end.x) * 0.5; + const midY = (canvasLine.start.y + canvasLine.end.y) * 0.5; + let normalX = -dy / lineLength; + let normalY = dx / lineLength; + const outsideRef = line.getLabelOutsideReferencePoint?.(); + const insideRef = line.getLabelInsideReferencePoint?.(); + const previousShouldFlip = textEl.dataset.normalFlip === "1"; + let shouldFlip = previousShouldFlip; + if (outsideRef) { + const refDx = outsideRef.x - midX; + const refDy = outsideRef.y - midY; + const dotWithNormal = refDx * normalX + refDy * normalY; + if (dotWithNormal > LABEL_SIDE_HYSTERESIS_PX) { + shouldFlip = true; + } else if (dotWithNormal < -LABEL_SIDE_HYSTERESIS_PX) { + shouldFlip = false; + } + } else if (insideRef) { + const refDx = insideRef.x - midX; + const refDy = insideRef.y - midY; + const dotWithNormal = refDx * normalX + refDy * normalY; + if (dotWithNormal < -LABEL_SIDE_HYSTERESIS_PX) { + shouldFlip = true; + } else if (dotWithNormal > LABEL_SIDE_HYSTERESIS_PX) { + shouldFlip = false; + } + } + if (shouldFlip) { + normalX = -normalX; + normalY = -normalY; + } + textEl.dataset.normalFlip = shouldFlip ? "1" : "0"; + const rawAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; + const labelOffsetPx = line.labelOffsetPx ?? LABEL_OFFSET_PX; + const textX = midX + normalX * labelOffsetPx; + const textY = midY + normalY * labelOffsetPx; + const angleDeg = + line.labelRotationMode === "clockwise" + ? (rawAngleDeg + 360) % 360 + : (() => { + const crossProduct = + (dx / lineLength) * normalY - (dy / lineLength) * normalX; + const sideAdjustedAngle = + crossProduct >= 0 ? rawAngleDeg : rawAngleDeg + 180; + const normalizedAngle = ((sideAdjustedAngle % 360) + 360) % 360; + return normalizedAngle > 90 && normalizedAngle < 270 + ? (normalizedAngle + 180) % 360 + : normalizedAngle; + })(); + + const previousTextX = Number.parseFloat( + textEl.dataset.stableTextX ?? "" + ); + const previousTextY = Number.parseFloat( + textEl.dataset.stableTextY ?? "" + ); + const hasPreviousTextPosition = + Number.isFinite(previousTextX) && Number.isFinite(previousTextY); + const stableTextPosition = + hasPreviousTextPosition && + Math.hypot(textX - previousTextX, textY - previousTextY) <= + LABEL_POSITION_STABILITY_EPSILON_PX + ? { x: previousTextX, y: previousTextY } + : { x: textX, y: textY }; + + const previousAngleDeg = Number.parseFloat( + textEl.dataset.stableAngleDeg ?? "" + ); + const hasPreviousAngle = Number.isFinite(previousAngleDeg); + const normalizedAngleDelta = hasPreviousAngle + ? Math.abs(((angleDeg - previousAngleDeg + 540) % 360) - 180) + : Number.POSITIVE_INFINITY; + const stableAngleDeg = + hasPreviousAngle && + normalizedAngleDelta <= LABEL_ANGLE_STABILITY_EPSILON_DEG + ? previousAngleDeg + : angleDeg; + + textEl.dataset.stableTextX = `${stableTextPosition.x}`; + textEl.dataset.stableTextY = `${stableTextPosition.y}`; + textEl.dataset.stableAngleDeg = `${stableAngleDeg}`; + + textEl.setAttribute("x", `${stableTextPosition.x}`); + textEl.setAttribute("y", `${stableTextPosition.y}`); + textEl.setAttribute( + "transform", + `rotate(${stableAngleDeg} ${stableTextPosition.x} ${stableTextPosition.y})` + ); + const textLengthPx = textEl.getComputedTextLength(); + const minLabelLineLengthPx = + line.labelMinLineLengthPx ?? DEFAULT_MIN_LABEL_LINE_LENGTH_PX; + const previousVisible = textEl.dataset.labelVisible === "1"; + const lengthThreshold = previousVisible + ? minLabelLineLengthPx - LABEL_VISIBILITY_HYSTERESIS_PX + : minLabelLineLengthPx + LABEL_VISIBILITY_HYSTERESIS_PX; + const fitThreshold = previousVisible + ? lineLength + LABEL_VISIBILITY_HYSTERESIS_PX + : lineLength - LABEL_VISIBILITY_HYSTERESIS_PX; + const shouldShowLabel = + lineLength >= lengthThreshold && + textLengthPx + LABEL_MIN_PADDING_PX <= fitThreshold; + textEl.dataset.labelVisible = shouldShowLabel ? "1" : "0"; + textEl.style.display = shouldShowLabel ? "block" : "none"; + } else { + textEl.dataset.labelVisible = "0"; + textEl.style.display = "none"; + } + } else if (textEl) { + textEl.dataset.labelVisible = "0"; + textEl.style.display = "none"; + } + + return true; + }; + export const useLineVisualizers = ( lines: LineVisualizerData[], showLines: boolean = true ) => { - const { addLabelOverlayElement, removeLabelOverlayElement } = - useLabelOverlay(); + const { + addLabelOverlayElement, + removeLabelOverlayElement, + updateLabelOverlayElement, + } = useLabelOverlay(); const previousLineSignatureByIdRef = useRef>(new Map()); const lineSignatureById = useMemo( @@ -48,16 +232,20 @@ export const useLineVisualizers = ( `${line.id}:${line.visible}:${line.isHidden}:${line.stroke}:${ line.strokeWidth }:${line.strokeDasharray}:${line.strokeDashoffset}:${line.opacity}:${ - line.labelText - }:${line.labelColor}:${line.labelFontSize}:${line.labelFontFamily}:${ - line.labelFontWeight - }:${line.labelMinLineLengthPx}:${line.labelOffsetPx}:${ - line.labelRotationMode ?? "auto" - }:${line.labelDominantBaseline ?? "middle"}:${Boolean( + line.hitTargetStrokeWidth + }:${line.labelText}:${line.labelColor}:${line.labelStroke}:${ + line.labelFontSize + }:${line.labelFontFamily}:${line.labelFontWeight}:${ + line.labelMinLineLengthPx + }:${line.labelOffsetPx}:${line.labelRotationMode ?? "auto"}:${ + line.labelDominantBaseline ?? "middle" + }:${line.longPressDurationMs ?? ""}:${getOverlayReferenceSignature( line.onLineClick - )}:${Boolean(line.onLineLongPress)}:${Boolean(line.onLabelClick)}:${ - line.longPressDurationMs ?? "" - }:${line.contentSignature ?? ""}`, + )}:${getOverlayReferenceSignature( + line.onLineLongPress + )}:${getOverlayReferenceSignature(line.onLabelClick)}:${ + line.contentSignature ?? "" + }`, ]) ), [lines] @@ -81,9 +269,23 @@ export const useLineVisualizers = ( lineIndexById.forEach((line, lineId) => { const nextSignature = lineSignatureById.get(lineId) ?? ""; nextSignatureById.set(lineId, nextSignature); + const overlayId = `line-visualizer-${line.id}`; + const previousSignature = + previousLineSignatureByIdRef.current.get(lineId) ?? null; + + if (previousSignature === nextSignature) { + updateLabelOverlayElement(overlayId, { + visible: line.visible !== false, + isHidden: line.isHidden, + updatePosition: buildLineOverlayUpdatePosition(line), + }); + return; + } + addLabelOverlayElement({ - id: `line-visualizer-${line.id}`, + id: overlayId, zIndex: LINE_OVERLAY_Z_INDEX, + contentKey: nextSignature, content: React.createElement(LineVisualizer, { stroke: line.stroke, strokeWidth: line.strokeWidth, @@ -105,168 +307,7 @@ export const useLineVisualizers = ( }), visible: line.visible !== false, isHidden: line.isHidden, - updatePosition: (elementDiv) => { - const canvasLine = line.getCanvasLine ? line.getCanvasLine() : null; - if (!canvasLine) return false; - - const lineEl = elementDiv.querySelector( - '[data-line-visualizer-segment="true"]' - ) as SVGLineElement | null; - if (!lineEl) return false; - - elementDiv.style.position = "absolute"; - elementDiv.style.left = "0"; - elementDiv.style.top = "0"; - elementDiv.style.width = "100%"; - elementDiv.style.height = "100%"; - elementDiv.style.transform = "none"; - // Keep map interaction free except for explicit line/label hit targets. - elementDiv.style.pointerEvents = "none"; - elementDiv.style.zIndex = `${LINE_OVERLAY_Z_INDEX}`; - - lineEl.setAttribute("x1", `${canvasLine.start.x}`); - lineEl.setAttribute("y1", `${canvasLine.start.y}`); - lineEl.setAttribute("x2", `${canvasLine.end.x}`); - lineEl.setAttribute("y2", `${canvasLine.end.y}`); - - const lineHitTargetEl = elementDiv.querySelector( - '[data-line-visualizer-hit-target="true"]' - ) as SVGLineElement | null; - if (lineHitTargetEl) { - lineHitTargetEl.setAttribute("x1", `${canvasLine.start.x}`); - lineHitTargetEl.setAttribute("y1", `${canvasLine.start.y}`); - lineHitTargetEl.setAttribute("x2", `${canvasLine.end.x}`); - lineHitTargetEl.setAttribute("y2", `${canvasLine.end.y}`); - } - - const textEl = elementDiv.querySelector( - '[data-line-visualizer-text="true"]' - ) as SVGTextElement | null; - if (textEl && line.labelText) { - const dx = canvasLine.end.x - canvasLine.start.x; - const dy = canvasLine.end.y - canvasLine.start.y; - const lineLength = Math.hypot(dx, dy); - if (lineLength > MIN_LINE_LENGTH_PX) { - const midX = (canvasLine.start.x + canvasLine.end.x) * 0.5; - const midY = (canvasLine.start.y + canvasLine.end.y) * 0.5; - let normalX = -dy / lineLength; - let normalY = dx / lineLength; - const outsideRef = line.getLabelOutsideReferencePoint?.(); - const insideRef = line.getLabelInsideReferencePoint?.(); - const previousShouldFlip = textEl.dataset.normalFlip === "1"; - let shouldFlip = previousShouldFlip; - if (outsideRef) { - const refDx = outsideRef.x - midX; - const refDy = outsideRef.y - midY; - const dotWithNormal = refDx * normalX + refDy * normalY; - if (dotWithNormal > LABEL_SIDE_HYSTERESIS_PX) { - shouldFlip = true; - } else if (dotWithNormal < -LABEL_SIDE_HYSTERESIS_PX) { - shouldFlip = false; - } - } else if (insideRef) { - const refDx = insideRef.x - midX; - const refDy = insideRef.y - midY; - const dotWithNormal = refDx * normalX + refDy * normalY; - if (dotWithNormal < -LABEL_SIDE_HYSTERESIS_PX) { - shouldFlip = true; - } else if (dotWithNormal > LABEL_SIDE_HYSTERESIS_PX) { - shouldFlip = false; - } - } - if (shouldFlip) { - normalX = -normalX; - normalY = -normalY; - } - textEl.dataset.normalFlip = shouldFlip ? "1" : "0"; - const rawAngleDeg = (Math.atan2(dy, dx) * 180) / Math.PI; - const labelOffsetPx = line.labelOffsetPx ?? LABEL_OFFSET_PX; - const textX = midX + normalX * labelOffsetPx; - const textY = midY + normalY * labelOffsetPx; - const angleDeg = - line.labelRotationMode === "clockwise" - ? (rawAngleDeg + 360) % 360 - : (() => { - // cross product of line direction and final normal: - // positive → label is to the right of the line direction → read forward - // negative → label is to the left → read backward (flip 180°) - const crossProduct = - (dx / lineLength) * normalY - - (dy / lineLength) * normalX; - const sideAdjustedAngle = - crossProduct >= 0 ? rawAngleDeg : rawAngleDeg + 180; - const normalizedAngle = - ((sideAdjustedAngle % 360) + 360) % 360; - return normalizedAngle > 90 && normalizedAngle < 270 - ? (normalizedAngle + 180) % 360 - : normalizedAngle; - })(); - - const previousTextX = Number.parseFloat( - textEl.dataset.stableTextX ?? "" - ); - const previousTextY = Number.parseFloat( - textEl.dataset.stableTextY ?? "" - ); - const hasPreviousTextPosition = - Number.isFinite(previousTextX) && - Number.isFinite(previousTextY); - const stableTextPosition = - hasPreviousTextPosition && - Math.hypot(textX - previousTextX, textY - previousTextY) <= - LABEL_POSITION_STABILITY_EPSILON_PX - ? { x: previousTextX, y: previousTextY } - : { x: textX, y: textY }; - - const previousAngleDeg = Number.parseFloat( - textEl.dataset.stableAngleDeg ?? "" - ); - const hasPreviousAngle = Number.isFinite(previousAngleDeg); - const normalizedAngleDelta = hasPreviousAngle - ? Math.abs(((angleDeg - previousAngleDeg + 540) % 360) - 180) - : Number.POSITIVE_INFINITY; - const stableAngleDeg = - hasPreviousAngle && - normalizedAngleDelta <= LABEL_ANGLE_STABILITY_EPSILON_DEG - ? previousAngleDeg - : angleDeg; - - textEl.dataset.stableTextX = `${stableTextPosition.x}`; - textEl.dataset.stableTextY = `${stableTextPosition.y}`; - textEl.dataset.stableAngleDeg = `${stableAngleDeg}`; - - textEl.setAttribute("x", `${stableTextPosition.x}`); - textEl.setAttribute("y", `${stableTextPosition.y}`); - textEl.setAttribute( - "transform", - `rotate(${stableAngleDeg} ${stableTextPosition.x} ${stableTextPosition.y})` - ); - const textLengthPx = textEl.getComputedTextLength(); - const minLabelLineLengthPx = - line.labelMinLineLengthPx ?? DEFAULT_MIN_LABEL_LINE_LENGTH_PX; - const previousVisible = textEl.dataset.labelVisible === "1"; - const lengthThreshold = previousVisible - ? minLabelLineLengthPx - LABEL_VISIBILITY_HYSTERESIS_PX - : minLabelLineLengthPx + LABEL_VISIBILITY_HYSTERESIS_PX; - const fitThreshold = previousVisible - ? lineLength + LABEL_VISIBILITY_HYSTERESIS_PX - : lineLength - LABEL_VISIBILITY_HYSTERESIS_PX; - const shouldShowLabel = - lineLength >= lengthThreshold && - textLengthPx + LABEL_MIN_PADDING_PX <= fitThreshold; - textEl.dataset.labelVisible = shouldShowLabel ? "1" : "0"; - textEl.style.display = shouldShowLabel ? "block" : "none"; - } else { - textEl.dataset.labelVisible = "0"; - textEl.style.display = "none"; - } - } else if (textEl) { - textEl.dataset.labelVisible = "0"; - textEl.style.display = "none"; - } - - return true; - }, + updatePosition: buildLineOverlayUpdatePosition(line), }); }); @@ -281,6 +322,7 @@ export const useLineVisualizers = ( lineSignatureById, addLabelOverlayElement, removeLabelOverlayElement, + updateLabelOverlayElement, ]); useEffect( diff --git a/libraries/providers/label-overlay/src/lib/usePointLabels.ts b/libraries/providers/label-overlay/src/lib/usePointLabels.ts index 169b24a6cd..66f2d8bd6d 100644 --- a/libraries/providers/label-overlay/src/lib/usePointLabels.ts +++ b/libraries/providers/label-overlay/src/lib/usePointLabels.ts @@ -48,9 +48,13 @@ export interface PointLabelData { onDoubleClick?: () => void; onLongPress?: () => void; longPressDurationMs?: number; - onHoverChange?: (hovered: boolean) => void; + onHoverChange?: ( + hovered: boolean, + anchorPosition?: CssPixelPosition | null + ) => void; markerOnlyPointerEvents?: boolean; attachOverlayClickHandlers?: boolean; + forceMarkerInteractionTarget?: boolean; onMarkerDragStart?: (clientX: number, clientY: number) => void; onMarkerDragMove?: (clientX: number, clientY: number) => void; onMarkerDragEnd?: () => void; @@ -60,6 +64,59 @@ export type PointLabelLayoutOptions = { transitionDurationMs?: number; }; +const overlayReferenceIdByValue = new WeakMap(); +let nextOverlayReferenceId = 1; + +const getOverlayReferenceSignature = (value: unknown): string => { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "object" || typeof value === "function") { + const ref = value as object; + const existingId = overlayReferenceIdByValue.get(ref); + if (existingId) { + return `ref:${existingId}`; + } + + const nextId = nextOverlayReferenceId++; + overlayReferenceIdByValue.set(ref, nextId); + return `ref:${nextId}`; + } + + return String(value); +}; + +const getPointStyleSignature = ( + styleProps: PointLabelStyleProps | undefined +): string => + [ + styleProps?.fontSize ?? "", + styleProps?.fontFamily ?? "", + styleProps?.fontWeight ?? "", + styleProps?.textColor ?? "", + styleProps?.textBackgroundColor ?? "", + styleProps?.selectedBackgroundColor ?? "", + styleProps?.hoverBackgroundColor ?? "", + styleProps?.lineWidth ?? "", + styleProps?.lineColor ?? "", + styleProps?.markerSize ?? "", + styleProps?.markerStrokeWidth ?? "", + styleProps?.stemReferenceMarkerSize ?? "", + styleProps?.stemStartDistance ?? "", + getOverlayReferenceSignature(styleProps?.markerContent), + styleProps?.markerBackgroundColor ?? "", + styleProps?.markerTextColor ?? "", + getOverlayReferenceSignature(styleProps?.compactContent), + String(styleProps?.compactBorderless ?? false), + styleProps?.labelStyle ?? "", + String(styleProps?.collapse ?? false), + String(styleProps?.forceCollapse ?? false), + String(styleProps?.fullBorder ?? false), + styleProps?.resizeMode ?? "none", + styleProps?.labelDistance ?? "", + ].join(":"); + export const usePointLabels = ( points: PointLabelData[], showLabels: boolean = true, @@ -67,26 +124,35 @@ export const usePointLabels = ( styleProps?: PointLabelStyleProps, layoutOptions?: PointLabelLayoutOptions ) => { - const { addLabelOverlayElement, removeLabelOverlayElement } = - useLabelOverlay(); + const { + addLabelOverlayElement, + removeLabelOverlayElement, + updateLabelOverlayElement, + } = useLabelOverlay(); const previousPointSignatureByIdRef = useRef>(new Map()); + const pointStyleSignature = useMemo( + () => getPointStyleSignature(styleProps), + [styleProps] + ); const pointSignatureById = useMemo( () => new Map( points.map((p) => [ p.id, - `${p.id}:${String(p.content)}:${p.selected}:${p.visible}:${ - p.isOccluded - }:${p.isHidden}:${p.contentSignature ?? ""}:${p.pitch}:${Boolean( - p.onClick - )}:${p.labelAngleRad}:${p.labelDistance}:${p.labelAttach}:${ + `${p.id}:${ + p.contentSignature ?? getOverlayReferenceSignature(p.content) + }:${p.selected}:${p.visible}:${p.isOccluded}:${p.isHidden}:${ + p.pitch + }:${p.labelAngleRad}:${p.labelDistance}:${p.labelAttach}:${ p.hideLabelAndStem }:${p.hideMarker}:${p.markerSize}:${p.markerStrokeWidth}:${ p.stemReferenceMarkerSize - }:${p.stemStartDistance}:${String(p.markerContent)}:${ - p.markerBackgroundColor - }:${p.markerTextColor}:${String(p.compactContent)}:${Boolean( + }:${p.stemStartDistance}:${getOverlayReferenceSignature( + p.markerContent + )}:${p.markerBackgroundColor}:${ + p.markerTextColor + }:${getOverlayReferenceSignature(p.compactContent)}:${Boolean( p.compactBorderless )}:${p.labelStyle}:${p.collapse}:${p.forceCollapse}:${p.fullBorder}:${ p.resizeMode ?? "none" @@ -94,18 +160,28 @@ export const usePointLabels = ( p.textColor ?? "" }:${p.textBackgroundColor ?? ""}:${p.selectedBackgroundColor ?? ""}:${ p.hoverBackgroundColor ?? "" - }:${Boolean(p.onHoverChange)}:${Boolean(p.onDoubleClick)}:${Boolean( + }:${p.longPressDurationMs ?? ""}:${getOverlayReferenceSignature( + p.onClick + )}:${getOverlayReferenceSignature( + p.onDoubleClick + )}:${getOverlayReferenceSignature( p.onLongPress - )}:${p.longPressDurationMs}:${Boolean(p.onMarkerDragStart)}:${Boolean( + )}:${getOverlayReferenceSignature( + p.onHoverChange + )}:${getOverlayReferenceSignature( + p.onMarkerDragStart + )}:${getOverlayReferenceSignature( p.onMarkerDragMove - )}:${Boolean(p.onMarkerDragEnd)}:${Boolean( + )}:${getOverlayReferenceSignature(p.onMarkerDragEnd)}:${Boolean( p.markerOnlyPointerEvents - )}:${Boolean(p.attachOverlayClickHandlers)}:transition:${ + )}:${Boolean(p.attachOverlayClickHandlers)}:${Boolean( + p.forceMarkerInteractionTarget + )}:transition:${ layoutOptions?.transitionDurationMs ?? "" - }`, + }:style:${pointStyleSignature}`, ]) ), - [points, layoutOptions?.transitionDurationMs] + [points, layoutOptions?.transitionDurationMs, pointStyleSignature] ); const pointIndexById = useMemo( @@ -127,6 +203,8 @@ export const usePointLabels = ( const nextSignature = pointSignatureById.get(pointId) ?? ""; nextSignatureById.set(pointId, nextSignature); const labelId = `point-label-${point.id}`; + const previousSignature = + previousPointSignatureByIdRef.current.get(pointId) ?? null; // Use pitch from point data or fallback to getPitch callback const pitch = point.pitch ?? (getPitch ? getPitch() : -Math.PI / 4); @@ -155,9 +233,29 @@ export const usePointLabels = ( ? { hoverBackgroundColor: point.hoverBackgroundColor } : {}), }; + const overlayClickHandler = attachOverlayClickHandlers + ? point.onClick + : undefined; + const overlayDoubleClickHandler = attachOverlayClickHandlers + ? point.onDoubleClick + : undefined; + + if (previousSignature === nextSignature) { + updateLabelOverlayElement(labelId, { + getCanvasPosition: point.getCanvasPosition, + visible: point.visible !== false, + isHidden: point.isHidden, + onClick: overlayClickHandler, + onDoubleClick: overlayDoubleClickHandler, + cursor: point.forceMarkerInteractionTarget ? "none" : undefined, + }); + return; + } + addLabelOverlayElement({ id: labelId, zIndex: 20, + contentKey: nextSignature, getCanvasPosition: point.getCanvasPosition, content: React.createElement(PointLabel, { pointId: point.id, @@ -191,6 +289,7 @@ export const usePointLabels = ( longPressDurationMs: point.longPressDurationMs, onHoverChange: point.onHoverChange, markerOnlyPointerEvents: point.markerOnlyPointerEvents, + forceMarkerInteractionTarget: point.forceMarkerInteractionTarget, onMarkerDragStart: point.onMarkerDragStart, onMarkerDragMove: point.onMarkerDragMove, onMarkerDragEnd: point.onMarkerDragEnd, @@ -198,10 +297,9 @@ export const usePointLabels = ( }), visible: point.visible !== false, isHidden: point.isHidden, - onClick: attachOverlayClickHandlers ? point.onClick : undefined, - onDoubleClick: attachOverlayClickHandlers - ? point.onDoubleClick - : undefined, + onClick: overlayClickHandler, + onDoubleClick: overlayDoubleClickHandler, + cursor: point.forceMarkerInteractionTarget ? "none" : undefined, }); }); @@ -216,6 +314,7 @@ export const usePointLabels = ( pointSignatureById, addLabelOverlayElement, removeLabelOverlayElement, + updateLabelOverlayElement, getPitch, styleProps, layoutOptions?.transitionDurationMs, diff --git a/package-lock.json b/package-lock.json index 851a6df22f..031679fe45 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "storybook": "8.5.3", "sysend": "^1.17.5", "tailwind-merge": "^2.4.0", + "three": "^0.180.0", "tslib": "^2.8.1", "ua-parser-js": "^1.0.40", "uuid": "^10.0.0", @@ -158,6 +159,7 @@ "@types/react-bootstrap": "^0.32.37", "@types/react-dom": "18.3.0", "@types/redux-logger": "^3.0.13", + "@types/three": "^0.180.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "1.6.0", "@vitest/ui": "1.6.0", @@ -7113,6 +7115,13 @@ "node": ">=10" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@dnd-kit/accessibility": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.0.tgz", @@ -27319,6 +27328,13 @@ "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", "license": "MIT" }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/stylis": { "version": "4.2.5", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.5.tgz", @@ -27343,6 +27359,36 @@ "@types/jest": "*" } }, + "node_modules/@types/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": "*", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~0.22.0" + } + }, + "node_modules/@types/three/node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three/node_modules/meshoptimizer": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/tough-cookie": { "version": "4.0.5", "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", @@ -27396,6 +27442,13 @@ "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==", "license": "MIT" }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/whatwg-url": { "version": "11.0.5", "resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz", @@ -28312,6 +28365,13 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@webgpu/types": { + "version": "0.1.69", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz", + "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/@xhmikosr/archive-type": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/@xhmikosr/archive-type/-/archive-type-7.0.0.tgz", @@ -38208,6 +38268,12 @@ "through": "^2.3.4" } }, + "node_modules/geojson-vt": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", + "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", + "license": "ISC" + }, "node_modules/geotiff": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/geotiff/-/geotiff-0.4.1.tgz", @@ -45229,13 +45295,6 @@ "license": "BSD-2-Clause", "peer": true }, - "node_modules/mapbox-gl/node_modules/geojson-vt": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", - "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", - "license": "ISC", - "peer": true - }, "node_modules/mapbox-gl/node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -51223,12 +51282,6 @@ "url": "https://github.com/sindresorhus/file-type?sponsor=1" } }, - "node_modules/react-cismap/node_modules/geojson-vt": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/geojson-vt/-/geojson-vt-3.2.1.tgz", - "integrity": "sha512-EvGQQi/zPrDA6zr6BnJD/YhwAkBP8nnJ9emh3EnHQKVMfg/MRVtPbMYdgVy/IaEmn4UfagD2a6fafPDL5hbtwg==", - "license": "ISC" - }, "node_modules/react-cismap/node_modules/kdbush": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-3.0.0.tgz", @@ -56366,6 +56419,12 @@ "tslib": "^2" } }, + "node_modules/three": { + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", + "integrity": "sha512-o+qycAMZrh+TsE01GqWUxUIKR1AL0S8pq7zDkYOQw8GqfX8b8VoCKYUoHbhiX5j+7hr8XsuHDVU6+gkQJQKg9w==", + "license": "MIT" + }, "node_modules/throat": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/throat/-/throat-6.0.2.tgz", diff --git a/package.json b/package.json index 19604c9c41..2052957f65 100644 --- a/package.json +++ b/package.json @@ -110,6 +110,7 @@ "storybook": "8.5.3", "sysend": "^1.17.5", "tailwind-merge": "^2.4.0", + "three": "^0.180.0", "tslib": "^2.8.1", "ua-parser-js": "^1.0.40", "uuid": "^10.0.0", @@ -157,6 +158,7 @@ "@types/react-bootstrap": "^0.32.37", "@types/react-dom": "18.3.0", "@types/redux-logger": "^3.0.13", + "@types/three": "^0.180.0", "@vitejs/plugin-react": "^4.3.4", "@vitest/coverage-v8": "1.6.0", "@vitest/ui": "1.6.0", diff --git a/playgrounds/annotations/index.html b/playgrounds/annotations/index.html new file mode 100644 index 0000000000..22073d1fc5 --- /dev/null +++ b/playgrounds/annotations/index.html @@ -0,0 +1,18 @@ + + + + + Annotations Playground + + + + + + +
+ + + diff --git a/playgrounds/annotations/postcss.config.cjs b/playgrounds/annotations/postcss.config.cjs new file mode 100644 index 0000000000..4b60583030 --- /dev/null +++ b/playgrounds/annotations/postcss.config.cjs @@ -0,0 +1,12 @@ +const path = require("path"); + +module.exports = { + plugins: { + "postcss-import": {}, + "tailwindcss/nesting": {}, + tailwindcss: { + config: path.join(__dirname, "tailwind.config.cjs"), + }, + autoprefixer: {}, + }, +}; diff --git a/playgrounds/annotations/project.json b/playgrounds/annotations/project.json new file mode 100644 index 0000000000..7b1c6bd2d6 --- /dev/null +++ b/playgrounds/annotations/project.json @@ -0,0 +1,69 @@ +{ + "name": "annotations", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "playgrounds/annotations/src", + "projectType": "application", + "tags": [], + "targets": { + "build": { + "executor": "@nx/vite:build", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "production", + "options": { + "outputPath": "dist/playgrounds/annotations" + }, + "configurations": { + "development": { + "mode": "development" + }, + "production": { + "mode": "production" + } + } + }, + "serve": { + "executor": "@nx/vite:dev-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "annotations:build", + "host": "0.0.0.0" + }, + "configurations": { + "development": { + "buildTarget": "annotations:build:development", + "hmr": true + }, + "production": { + "buildTarget": "annotations:build:production", + "hmr": false + } + } + }, + "preview": { + "executor": "@nx/vite:preview-server", + "defaultConfiguration": "development", + "options": { + "buildTarget": "annotations:build" + }, + "configurations": { + "development": { + "buildTarget": "annotations:build:development" + }, + "production": { + "buildTarget": "annotations:build:production" + } + }, + "dependsOn": ["build"] + }, + "test": { + "executor": "@nx/vite:test", + "outputs": ["{options.reportsDirectory}"], + "options": { + "reportsDirectory": "../../coverage/playgrounds/annotations" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + } + } +} diff --git a/playgrounds/annotations/public/favicon.ico b/playgrounds/annotations/public/favicon.ico new file mode 100644 index 0000000000..c0b440dacd Binary files /dev/null and b/playgrounds/annotations/public/favicon.ico differ diff --git a/playgrounds/annotations/src/components/CesiumWidgetContainer.tsx b/playgrounds/annotations/src/components/CesiumWidgetContainer.tsx new file mode 100644 index 0000000000..905409f1d1 --- /dev/null +++ b/playgrounds/annotations/src/components/CesiumWidgetContainer.tsx @@ -0,0 +1,367 @@ +import { + useEffect, + useRef, + useState, + type MutableRefObject, + type ReactNode, +} from "react"; +import { + Cartesian3, + Cesium3DTileset, + CesiumTerrainProvider, + createMinimalCesiumWidget, + type CesiumWidget, + type ImageryLayer, + type Scene, +} from "@carma/cesium"; +import { degToRadNumeric } from "@carma/units/helpers"; +import { + WUPPERTAL, + WUPP_MESH_2024, + WUPP_TERRAIN_PROVIDER, + WUPP_TERRAIN_PROVIDER_DSM_MESH_2024_1M, +} from "@carma-commons/resources"; + +type PersistedCameraState = { + longitude: number; + latitude: number; + height: number; + heading: number; + pitch: number; + roll: number; +}; + +const CAMERA_STATE_STORAGE_KEY = "annotations-playground-camera-state"; +const CAMERA_SAVE_DELAY_MS = 750; + +const isFiniteNumber = (value: unknown): value is number => + typeof value === "number" && Number.isFinite(value); + +const parsePersistedCameraState = ( + rawValue: string | null +): PersistedCameraState | null => { + if (!rawValue) return null; + + try { + const parsed = JSON.parse(rawValue) as Partial; + if ( + !isFiniteNumber(parsed.longitude) || + !isFiniteNumber(parsed.latitude) || + !isFiniteNumber(parsed.height) || + !isFiniteNumber(parsed.heading) || + !isFiniteNumber(parsed.pitch) || + !isFiniteNumber(parsed.roll) + ) { + return null; + } + + return { + longitude: parsed.longitude, + latitude: parsed.latitude, + height: parsed.height, + heading: parsed.heading, + pitch: parsed.pitch, + roll: parsed.roll, + }; + } catch { + return null; + } +}; + +const loadPersistedCameraState = (): PersistedCameraState | null => + parsePersistedCameraState(localStorage.getItem(CAMERA_STATE_STORAGE_KEY)); + +const savePersistedCameraState = (state: PersistedCameraState) => { + try { + localStorage.setItem(CAMERA_STATE_STORAGE_KEY, JSON.stringify(state)); + } catch (error) { + console.warn( + "[annotations-playground] Failed to persist camera state", + error + ); + } +}; + +const extractCameraState = (widget: CesiumWidget): PersistedCameraState => { + const camera = widget.camera; + const position = camera.positionCartographic; + return { + longitude: position.longitude, + latitude: position.latitude, + height: position.height, + heading: camera.heading, + pitch: camera.pitch, + roll: camera.roll, + }; +}; + +const applyCameraState = ( + widget: CesiumWidget, + state: PersistedCameraState +) => { + widget.camera.setView({ + destination: Cartesian3.fromRadians( + state.longitude, + state.latitude, + state.height + ), + orientation: { + heading: state.heading, + pitch: state.pitch, + roll: state.roll, + }, + }); + widget.scene.requestRender(); +}; + +const setupCameraPersistence = (widget: CesiumWidget): (() => void) => { + const persistedState = loadPersistedCameraState(); + if (persistedState) { + applyCameraState(widget, persistedState); + } + + let saveTimeout: number | null = null; + + const onCameraChanged = () => { + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + } + + saveTimeout = window.setTimeout(() => { + if (widget.isDestroyed()) return; + savePersistedCameraState(extractCameraState(widget)); + saveTimeout = null; + }, CAMERA_SAVE_DELAY_MS); + }; + + const removeListener = + widget.camera.changed.addEventListener(onCameraChanged); + + return () => { + removeListener?.(); + if (saveTimeout !== null) { + window.clearTimeout(saveTimeout); + } + }; +}; + +const requestRenderWithOptions = ( + scene: Scene | null, + opts?: { + delay?: number; + repeat?: number; + repeatInterval?: number; + } +) => { + if (!scene || scene.isDestroyed()) return; + const delay = Math.max(0, opts?.delay ?? 0); + const repeat = Math.max(1, opts?.repeat ?? 1); + const repeatInterval = Math.max(0, opts?.repeatInterval ?? 50); + + const renderOnce = () => { + if (!scene.isDestroyed()) { + scene.requestRender(); + } + }; + + if (delay > 0) { + window.setTimeout(renderOnce, delay); + } else { + renderOnce(); + } + + for (let index = 1; index < repeat; index += 1) { + window.setTimeout(renderOnce, delay + repeatInterval * index); + } +}; + +const initializeWidget = ( + container: HTMLDivElement, + useBrowserRecommendedResolution = false +): CesiumWidget => { + const widget = createMinimalCesiumWidget(container, { + useBrowserRecommendedResolution, + }); + const position = Cartesian3.fromDegrees( + WUPPERTAL.position.longitude, + WUPPERTAL.position.latitude - 0.003, + 500 + ); + widget.camera.setView({ + destination: position, + orientation: { + heading: degToRadNumeric(0), + pitch: degToRadNumeric(-45), + roll: 0, + }, + }); + + return widget; +}; + +const initializeTerrainProviders = async () => { + const providers = { + terrain: null as CesiumTerrainProvider | null, + surface: null as CesiumTerrainProvider | null, + }; + + try { + providers.terrain = await CesiumTerrainProvider.fromUrl( + WUPP_TERRAIN_PROVIDER.url + ); + } catch (error) { + console.warn( + "[annotations-playground] Failed to initialize terrain provider", + { + error, + url: WUPP_TERRAIN_PROVIDER.url, + } + ); + } + + try { + providers.surface = await CesiumTerrainProvider.fromUrl( + WUPP_TERRAIN_PROVIDER_DSM_MESH_2024_1M.url + ); + } catch (error) { + console.warn( + "[annotations-playground] Failed to initialize surface provider", + { + error, + url: WUPP_TERRAIN_PROVIDER_DSM_MESH_2024_1M.url, + } + ); + } + + return providers; +}; + +const loadTileset = async ( + widget: CesiumWidget +): Promise => { + try { + const tileset = await Cesium3DTileset.fromUrl(WUPP_MESH_2024.url, { + preloadWhenHidden: false, + scene: widget.scene, + shadows: 0, + enableCollision: false, + maximumScreenSpaceError: 6, + skipLevelOfDetail: true, + skipScreenSpaceErrorFactor: 128, + baseScreenSpaceError: 4096, + }); + + if (!widget.isDestroyed()) { + widget.scene.primitives.add(tileset); + widget.scene.requestRender(); + } + + return tileset; + } catch (error) { + console.warn("[annotations-playground] Failed to load tileset", { + error, + url: WUPP_MESH_2024.url, + }); + return null; + } +}; + +type CesiumWidgetContainerProps = { + rootRef: MutableRefObject; + onSceneChange?: (scene: Scene | null) => void; + children: ReactNode; +}; + +export function CesiumWidgetContainer({ + rootRef, + onSceneChange, + children, +}: CesiumWidgetContainerProps) { + const cesiumContainerRef = useRef(null); + const widgetRef = useRef(null); + const terrainProviderRef = useRef(null); + const surfaceProviderRef = useRef(null); + const tilesetRef = useRef(null); + const [providersReady, setProvidersReady] = useState(false); + const [isViewerReady, setIsViewerReady] = useState(false); + const [initialViewApplied, setInitialViewApplied] = useState(true); + + useEffect(() => { + if (!cesiumContainerRef.current) return; + let disposed = false; + let teardownCameraPersistence: (() => void) | null = null; + + const initialize = async () => { + const widget = initializeWidget(cesiumContainerRef.current); + if (disposed) { + if (!widget.isDestroyed()) { + widget.destroy(); + } + return; + } + + widgetRef.current = widget; + onSceneChange?.(widget.scene); + teardownCameraPersistence = setupCameraPersistence(widget); + setIsViewerReady(true); + setInitialViewApplied(true); + + const [providers, tileset] = await Promise.all([ + initializeTerrainProviders(), + loadTileset(widget), + ]); + + if (disposed || widget.isDestroyed()) return; + + terrainProviderRef.current = providers.terrain; + surfaceProviderRef.current = providers.surface; + tilesetRef.current = tileset; + setProvidersReady(true); + widget.scene.requestRender(); + }; + + initialize().catch((error) => { + console.error( + "[annotations-playground] Failed to initialize CesiumWidget container", + error + ); + }); + + return () => { + disposed = true; + onSceneChange?.(null); + teardownCameraPersistence?.(); + setProvidersReady(false); + setIsViewerReady(false); + terrainProviderRef.current = null; + surfaceProviderRef.current = null; + tilesetRef.current = null; + const widget = widgetRef.current; + widgetRef.current = null; + if (widget && !widget.isDestroyed()) { + widget.destroy(); + } + }; + }, [onSceneChange]); + + return ( +
+
+ {children} +
+ ); +} diff --git a/playgrounds/annotations/src/config.ts b/playgrounds/annotations/src/config.ts new file mode 100644 index 0000000000..bfd0be76b1 --- /dev/null +++ b/playgrounds/annotations/src/config.ts @@ -0,0 +1,2 @@ +export const APP_BASE_PATH = import.meta.env.BASE_URL; +export const CESIUM_PATHNAME = "__cesium__"; diff --git a/playgrounds/annotations/src/main.tsx b/playgrounds/annotations/src/main.tsx new file mode 100644 index 0000000000..26c92ece50 --- /dev/null +++ b/playgrounds/annotations/src/main.tsx @@ -0,0 +1,102 @@ +import * as ReactDOM from "react-dom/client"; +import { ConfigProvider, theme } from "antd"; +import { useRef, useState } from "react"; +import { type Scene } from "@carma/cesium"; + +import { setupCesiumEnvironment } from "@carma-mapping/engines/cesium"; +import { LabelOverlayProvider } from "@carma-providers/label-overlay"; +import { type AnnotationEntry } from "@carma-mapping/annotations/core"; +import { + AnnotationInfoBox, + AnnotationsProvider, + AnnotationToolbar3D, + useLocalAnnotationPersistence, +} from "@carma-mapping/annotations/provider"; +import { Control, ControlLayout } from "@carma-mapping/map-controls-layout"; + +import { CesiumWidgetContainer } from "./components/CesiumWidgetContainer"; +import { APP_BASE_PATH, CESIUM_PATHNAME } from "./config"; + +import "cesium/Build/Cesium/Widgets/widgets.css"; +import "antd/dist/reset.css"; +import "./styles.css"; + +const CESIUM_BASE_URL = `${APP_BASE_PATH}${CESIUM_PATHNAME}`; +setupCesiumEnvironment({ baseUrl: CESIUM_BASE_URL }); + +const INFOBOX_WIDTH_PX = 430; + +const App = () => { + const rootRef = useRef(null); + const [scene, setScene] = useState(null); + const { initialPersistenceState, onPersistenceStateChange } = + useLocalAnnotationPersistence({ + enabled: true, + storageKey: "annotations-playground-annotations", + }); + + return ( + <> + + + {scene ? ( + + + +
+ +
+
+ +
+ +
+
+ +
+
+ ) : null} +
+
+ + ); +}; + +const root = ReactDOM.createRoot( + document.getElementById("root") as HTMLElement +); + +root.render( + + + +); diff --git a/playgrounds/annotations/src/styles.css b/playgrounds/annotations/src/styles.css new file mode 100644 index 0000000000..ee186248f6 --- /dev/null +++ b/playgrounds/annotations/src/styles.css @@ -0,0 +1,7 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +.cesium-widget-credits { + display: none !important; +} diff --git a/playgrounds/cesium-reference/tailwind.config.cjs b/playgrounds/annotations/tailwind.config.cjs similarity index 100% rename from playgrounds/cesium-reference/tailwind.config.cjs rename to playgrounds/annotations/tailwind.config.cjs diff --git a/playgrounds/annotations/tsconfig.json b/playgrounds/annotations/tsconfig.json new file mode 100644 index 0000000000..792ea3d6dd --- /dev/null +++ b/playgrounds/annotations/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.legacy.base.json", + "compilerOptions": { "strict": false }, + "files": [], + "references": [] +} diff --git a/playgrounds/annotations/tsconfig.spec.json b/playgrounds/annotations/tsconfig.spec.json new file mode 100644 index 0000000000..d6054e7f30 --- /dev/null +++ b/playgrounds/annotations/tsconfig.spec.json @@ -0,0 +1,28 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": [ + "vitest/globals", + "vitest/importMeta", + "vite/client", + "node", + "vitest", + "@nx/react/typings/cssmodule.d.ts", + "@nx/react/typings/image.d.ts" + ] + }, + "include": [ + "vite.config.ts", + "vitest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.test.tsx", + "src/**/*.spec.tsx", + "src/**/*.test.js", + "src/**/*.spec.js", + "src/**/*.test.jsx", + "src/**/*.spec.jsx", + "src/**/*.d.ts" + ] +} diff --git a/playgrounds/annotations/vite.config.mts b/playgrounds/annotations/vite.config.mts new file mode 100644 index 0000000000..ed9708266b --- /dev/null +++ b/playgrounds/annotations/vite.config.mts @@ -0,0 +1,67 @@ +/// +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin"; +import { viteStaticCopy } from "vite-plugin-static-copy"; + +const CESIUM_PATHNAME = "__cesium__"; + +export default defineConfig({ + root: __dirname, + cacheDir: "../../node_modules/.vite/playgrounds/annotations", + + server: { + port: 4200, + host: "localhost", + fs: { + allow: ["../.."], + }, + }, + + preview: { + port: 4300, + host: "localhost", + cors: true, + }, + + plugins: [ + react(), + nxViteTsPaths(), + viteStaticCopy({ + targets: [ + { + src: "../../node_modules/cesium/Build/Cesium/*", + dest: CESIUM_PATHNAME, + }, + ], + silent: false, + }), + ], + + worker: { + plugins: () => [nxViteTsPaths()], + }, + + build: { + outDir: "../../dist/playgrounds/annotations", + reportCompressedSize: true, + commonjsOptions: { + transformMixedEsModules: true, + }, + }, + + test: { + globals: true, + cache: { + dir: "../../node_modules/.vitest", + }, + environment: "jsdom", + include: ["src/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}"], + + reporters: ["default"], + coverage: { + reportsDirectory: "../../coverage/playgrounds/annotations", + provider: "v8", + }, + }, +}); diff --git a/playgrounds/cesium-reference/postcss.config.cjs b/playgrounds/cesium-reference/postcss.config.cjs index 4b60583030..616a362484 100644 --- a/playgrounds/cesium-reference/postcss.config.cjs +++ b/playgrounds/cesium-reference/postcss.config.cjs @@ -1,12 +1,6 @@ -const path = require("path"); - module.exports = { plugins: { "postcss-import": {}, - "tailwindcss/nesting": {}, - tailwindcss: { - config: path.join(__dirname, "tailwind.config.cjs"), - }, autoprefixer: {}, }, }; diff --git a/playgrounds/cesium-reference/src/config.views.ts b/playgrounds/cesium-reference/src/config.views.ts index bf822fb38c..ffebace7ce 100644 --- a/playgrounds/cesium-reference/src/config.views.ts +++ b/playgrounds/cesium-reference/src/config.views.ts @@ -7,7 +7,6 @@ const ViewShed = lazy(() => import("./views/ViewShed")); const ObliqueAndMesh = lazy(() => import("./views/ObliqueAndMesh")); const NavigationControlView = lazy(() => import("./views/NavigationControl")); const TestMesh = lazy(() => import("./views/TestMesh")); -const Measurements = lazy(() => import("./views/Measurements")); const MeasurementsEarlyPrototype = lazy( () => import("./views/MeasurementsEarlyPrototype") ); @@ -29,11 +28,6 @@ export const views = [ component: NavigationControlView, }, { path: "/test-mesh", name: "Test Mesh", component: TestMesh }, - { - path: "/measurements", - name: "Measurements", - component: Measurements, - }, { path: "/measurements-early-prototype", name: "Measurements (Early Prototype)", diff --git a/playgrounds/cesium-reference/src/styles.css b/playgrounds/cesium-reference/src/styles.css index ee186248f6..ec9908eac0 100644 --- a/playgrounds/cesium-reference/src/styles.css +++ b/playgrounds/cesium-reference/src/styles.css @@ -1,7 +1,3 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - .cesium-widget-credits { display: none !important; } diff --git a/playgrounds/cesium-reference/src/views/Measurements.tsx b/playgrounds/cesium-reference/src/views/Measurements.tsx deleted file mode 100644 index fed55375fe..0000000000 --- a/playgrounds/cesium-reference/src/views/Measurements.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { useRef } from "react"; -import { CesiumErrorHandling } from "@carma-mapping/engines/cesium"; -import { LabelOverlayProvider } from "@carma-providers/label-overlay"; -import { type AnnotationEntry } from "@carma-mapping/annotations/cesium"; -import { - AnnotationsAdapterProvider, - AnnotationInfoBox, - AnnotationToolbar3D, - useLocalAnnotationPersistence, -} from "@carma-mapping/annotations/provider"; -import { Control, ControlLayout } from "@carma-mapping/map-controls-layout"; -import { CesiumWidgetContainer } from "../components/CesiumWidgetContainer"; - -const INFOBOX_WIDTH_PX = 430; - -const Measurements = () => { - const rootRef = useRef(null); - const { initialPersistenceState, onPersistenceStateChange } = - useLocalAnnotationPersistence({ - enabled: true, - storageKey: "cesium-reference-annotations", - }); - - return ( - <> - - - - - - -
- -
-
- -
- -
-
- -
-
-
-
- - ); -}; - -export default Measurements; diff --git a/playgrounds/cesium-reference/src/views/NavigationControl.tsx b/playgrounds/cesium-reference/src/views/NavigationControl.tsx index f6cd684c19..94e5b6dc2a 100644 --- a/playgrounds/cesium-reference/src/views/NavigationControl.tsx +++ b/playgrounds/cesium-reference/src/views/NavigationControl.tsx @@ -95,19 +95,30 @@ const NavigationControlView: FC = () => { - + - + diff --git a/playgrounds/stories/src/stories/measurements/MeasurementModeToolbar.stories.tsx b/playgrounds/stories/src/stories/measurements/MeasurementModeToolbar.stories.tsx index 348628aab4..2bfc833f68 100644 --- a/playgrounds/stories/src/stories/measurements/MeasurementModeToolbar.stories.tsx +++ b/playgrounds/stories/src/stories/measurements/MeasurementModeToolbar.stories.tsx @@ -2,7 +2,8 @@ import type { Meta, StoryObj } from "@storybook/react"; import { AnnotationToolbar3D, - useAnnotationsAdapter, + useAnnotationSelectionState, + useAnnotationTools, } from "@carma-mapping/annotations/provider"; import { MeasurementCesiumStoryShell } from "./shared/MeasurementCesiumStoryShell"; @@ -11,13 +12,8 @@ const MeasurementToolkitStory = ({ }: { pixelWidth?: number; }) => { - const { - annotationMode, - selectionModeActive, - pointLabelOnCreate, - planarMeasurementCreationMode, - polygonSurfaceTypePreset, - } = useAnnotationsAdapter(); + const tools = useAnnotationTools(); + const selection = useAnnotationSelectionState(); return (
- mode: {annotationMode} | selection:{" "} - {selectionModeActive ? "on" : "off"} | label-on-create:{" "} - {pointLabelOnCreate ? "on" : "off"} | planar-creation:{" "} - {planarMeasurementCreationMode} | surface:{" "} - {polygonSurfaceTypePreset} + tool: {tools.activeToolType} | selection:{" "} + {selection.mode.active ? "on" : "off"}
); diff --git a/playgrounds/stories/src/stories/measurements/shared/MeasurementCesiumStoryShell.tsx b/playgrounds/stories/src/stories/measurements/shared/MeasurementCesiumStoryShell.tsx index eb8d2909d1..ffedac1777 100644 --- a/playgrounds/stories/src/stories/measurements/shared/MeasurementCesiumStoryShell.tsx +++ b/playgrounds/stories/src/stories/measurements/shared/MeasurementCesiumStoryShell.tsx @@ -19,7 +19,7 @@ import { MapMeasurementsProvider, MEASUREMENT_MODE, } from "@carma-commons/measurements"; -import { AnnotationsAdapterProvider } from "@carma-mapping/annotations/provider"; +import { AnnotationsProvider } from "@carma-mapping/annotations/provider"; import { CesiumContext, type CesiumContextType, @@ -90,6 +90,10 @@ export const MeasurementCesiumStoryShell = ({ const [providersReady, setProvidersReady] = useState(false); const [isViewerReady, setIsViewerReady] = useState(false); const [initialViewApplied, setInitialViewApplied] = useState(true); + const scene = + isViewerReady && widgetRef.current && !widgetRef.current.isDestroyed() + ? widgetRef.current.scene + : null; useEffect(() => { if (!cesiumContainerRef.current) return; @@ -228,24 +232,26 @@ export const MeasurementCesiumStoryShell = ({ }} > - -
-
{children}
-
-
+ {scene ? ( + +
+
{children}
+
+
+ ) : null}