diff --git a/README.md b/README.md index ac36ae05..9d1204dd 100644 --- a/README.md +++ b/README.md @@ -15,3 +15,12 @@ import { useObjectState } from '@terrestris/react-util'; // or import { useObjectState } from '@terrestris/react-util/dist/hooks/useObjectState/useObjectState'; ``` + +## Development + +`npm run watch:buildto` can be used to inject an updated version of `react-util` into another project, e.g. `react-geo`. +The script will also watch for further changes. + +```sh +npm run watch:buildto ../react-geo/node_modules/@terrestris/react-geo/ +``` diff --git a/package-lock.json b/package-lock.json index 2923f277..adfcf73c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "enzyme": "^3.11.0", "eslint": "^8.14.0", "eslint-plugin-simple-import-sort": "^10.0.0", + "fs-extra": "^11.1.1", "jest": "^29.6.4", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.6.4", @@ -55,6 +56,7 @@ "ts-jest": "^29.1.1", "typescript": "^4.2.4", "use-resize-observer": "^9.1.0", + "watch": "^1.0.2", "whatwg-fetch": "^3.6.18" }, "peerDependencies": { @@ -8416,6 +8418,15 @@ "node": ">=0.10.0" } }, + "node_modules/exec-sh": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.2.2.tgz", + "integrity": "sha512-FIUCJz1RbuS0FKTdaAafAByGS0CPvU3R0MeHxgtl+djzCc//F8HakL8GzmVNZanasTbTAY/3DRFA0KpVqj/eAw==", + "dev": true, + "dependencies": { + "merge": "^1.2.0" + } + }, "node_modules/execa": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", @@ -8696,6 +8707,29 @@ "node": ">= 6" } }, + "node_modules/fs-extra": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.1.1.tgz", + "integrity": "sha512-MGIE4HOvQCeUCzmlHs0vXpih4ysz4wg9qiSAu6cd42lVwPbTM1TjV7RusoyQqMmk/95gdQZX72u+YW+c3eEpFQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/fs-extra/node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/fs-minipass": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", @@ -12224,6 +12258,27 @@ "node": ">=6" } }, + "node_modules/jsonfile": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.1.0.tgz", + "integrity": "sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==", + "dev": true, + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/jsonfile/node_modules/universalify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.0.tgz", + "integrity": "sha512-hAZsKq7Yy11Zu1DE0OzWjw7nnLZmJZYTDZZyEFHZdUhV8FkH5MCfoU1XMaxXovpyW5nq5scPqq0ZDP9Zyl04oQ==", + "dev": true, + "engines": { + "node": ">= 10.0.0" + } + }, "node_modules/jspdf": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.1.tgz", @@ -13143,6 +13198,12 @@ "node": ">=10" } }, + "node_modules/merge": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/merge/-/merge-1.2.1.tgz", + "integrity": "sha512-VjFo4P5Whtj4vsLzsYBu5ayHhoHJ0UqNm7ibvShmbmoz7tGi0vXaoJbGdB+GmDMLUdg8DpQXEIeVDAe8MaABvQ==", + "dev": true + }, "node_modules/merge-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", @@ -16974,6 +17035,22 @@ "makeerror": "1.0.12" } }, + "node_modules/watch": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/watch/-/watch-1.0.2.tgz", + "integrity": "sha512-1u+Z5n9Jc1E2c7qDO8SinPoZuHj7FgbgU1olSFoyaklduDvvtX7GMMtlE6OC9FTXq4KvNAOfj6Zu4vI1e9bAKA==", + "dev": true, + "dependencies": { + "exec-sh": "^0.2.0", + "minimist": "^1.2.0" + }, + "bin": { + "watch": "cli.js" + }, + "engines": { + "node": ">=0.1.95" + } + }, "node_modules/web-worker": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.2.0.tgz", diff --git a/package.json b/package.json index c4161528..39787a41 100644 --- a/package.json +++ b/package.json @@ -8,15 +8,19 @@ "dist" ], "scripts": { - "build": "rimraf ./dist/* && tsc -p tsconfig.json", + "build": "npm run clean && npm run build:dist && npm run build:declaration", + "build:declaration": "tsc --emitDeclarationOnly", + "build:dist": "tsc -p tsconfig.json", + "clean": "rimraf ./build/* ./coverage/* ./dist/*", "lint": "eslint -c .eslintrc --ext .tsx,.ts src/", "lint:fix": "eslint -c .eslintrc --ext .tsx,.ts src/ --fix", "prepublishOnly": "npm run lint:test:build", "release": "np --no-yarn && git push https://github.com/terrestris/react-util.git main", + "test": "jest --maxWorkers=4 --coverage", "test-ci": "jest --ci --coverage", "test-watch": "jest --watch", - "test": "jest --maxWorkers=4 --coverage", - "typecheck": "tsc --project tsconfig.json --noEmit" + "typecheck": "tsc --project tsconfig.json --noEmit", + "watch:buildto": "node watchBuild.js" }, "repository": { "type": "git", @@ -66,6 +70,7 @@ "enzyme": "^3.11.0", "eslint": "^8.14.0", "eslint-plugin-simple-import-sort": "^10.0.0", + "fs-extra": "^11.1.1", "jest": "^29.6.4", "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^29.6.4", @@ -84,6 +89,7 @@ "ts-jest": "^29.1.1", "typescript": "^4.2.4", "use-resize-observer": "^9.1.0", + "watch": "^1.0.2", "whatwg-fetch": "^3.6.18" } } diff --git a/src/hooks/useGeoLocation/geolocation-marker-heading.png b/src/hooks/useGeoLocation/geolocation-marker-heading.png new file mode 100644 index 00000000..0790dd54 Binary files /dev/null and b/src/hooks/useGeoLocation/geolocation-marker-heading.png differ diff --git a/src/hooks/useGeoLocation/geolocation-marker.png b/src/hooks/useGeoLocation/geolocation-marker.png new file mode 100644 index 00000000..62586406 Binary files /dev/null and b/src/hooks/useGeoLocation/geolocation-marker.png differ diff --git a/src/hooks/useGeoLocation/useGeoLocation.spec.ts b/src/hooks/useGeoLocation/useGeoLocation.spec.ts new file mode 100644 index 00000000..4fe3e482 --- /dev/null +++ b/src/hooks/useGeoLocation/useGeoLocation.spec.ts @@ -0,0 +1,9 @@ +import { useGeoLocation } from './useGeoLocation'; + +jest.mock('react'); + +describe('basic test', () => { + it('is defined', () => { + expect(useGeoLocation).toBeDefined(); + }); +}); diff --git a/src/hooks/useGeoLocation/useGeoLocation.ts b/src/hooks/useGeoLocation/useGeoLocation.ts new file mode 100644 index 00000000..63c03e51 --- /dev/null +++ b/src/hooks/useGeoLocation/useGeoLocation.ts @@ -0,0 +1,188 @@ +import MathUtil from '@terrestris/base-util/dist/MathUtil/MathUtil'; +import _isNil from 'lodash/isNil'; +import BaseEvent from 'ol/events/Event'; +import OlFeature from 'ol/Feature'; +import OlGeolocation from 'ol/Geolocation'; +import OlGeometry from 'ol/geom/Geometry'; +import OlGeomLineString from 'ol/geom/LineString'; +import OlGeomPoint from 'ol/geom/Point'; +import OlLayerVector from 'ol/layer/Vector'; +import RenderFeature from 'ol/render/Feature'; +import OlSourceVector from 'ol/source/Vector'; +import OlStyleIcon from 'ol/style/Icon'; +import OlStyleStyle from 'ol/style/Style'; +import {useEffect, useMemo, useState} from 'react'; + +import useMap from '../useMap'; +import {useOlLayer} from '../useOlLayer'; +import mapMarker from './geolocation-marker.png'; +import mapMarkerHeading from './geolocation-marker-heading.png'; + +export type UseGeoLocationArgs = { + active: boolean; + enableTracking?: boolean; + follow?: boolean; + onError?: () => void; + onGeoLocationChange?: (actualGeoLocation: GeoLocation) => void; + showMarker?: boolean; + trackingOptions?: PositionOptions; +}; + +export type GeoLocation = { + accuracy: number; + heading: number; + position: number[]; + speed: number; +}; + +export type GeoLocationType = { + actualPosition: GeoLocation; + trackedLine: OlGeomLineString; +}; + +/** + * This hook allows to debounce a setState. + */ +export const useGeoLocation = ({ + active, + enableTracking = false, + follow = false, + onError = () => {}, + onGeoLocationChange = () => {}, + showMarker = false, + trackingOptions = { + enableHighAccuracy: true, + maximumAge: 10000, + timeout: 600000 + } +}: UseGeoLocationArgs): GeoLocationType | undefined => { + + const map = useMap(); + + const [actualPosition, setActualPosition] = useState(); + + const trackedLine = useMemo(() => new OlGeomLineString([], 'XYZM'), []); + const markerFeature = useMemo(() => new OlFeature(), []); + + useOlLayer(() => new OlLayerVector({ + properties: { + name: 'react-geo_geolocationlayer', + }, + source: new OlSourceVector({ + features: [markerFeature] + }), + style: (feature: OlFeature | RenderFeature) => { + const heading = feature.get('heading'); + const src = heading !== 0 ? mapMarkerHeading : mapMarker; + const rotation = heading !== 0 ? heading * Math.PI / 180 : 0; + + return [new OlStyleStyle({ + image: new OlStyleIcon({ + rotation, + src + }) + })]; + } + }), [], showMarker); + + /** + * Callback of the interactions on change event. + */ + const onLocationChanged = (geoLocationEvent: BaseEvent) => { + const ac = geoLocationEvent.target as OlGeolocation; + + const position = ac.getPosition() ?? [0, 0]; + const accuracy = ac.getAccuracy(); + let heading = ac.getHeading() || 0; + const speed = ac.getSpeed() || 0; + + const x = position[0]; + const y = position[1]; + const fCoords = trackedLine.getCoordinates(); + const previous = fCoords[fCoords.length - 1]; + const prevHeading = previous && previous[2]; + if (prevHeading) { + let headingDiff = heading - MathUtil.mod(prevHeading); + + // force the rotation change to be less than 180° + if (Math.abs(headingDiff) > Math.PI) { + const sign = (headingDiff >= 0) ? 1 : -1; + headingDiff = -sign * (2 * Math.PI - Math.abs(headingDiff)); + } + heading = prevHeading + headingDiff; + } + trackedLine.appendCoordinate([x, y, heading, Date.now()]); + + // only keep the 20 last coordinates + trackedLine.setCoordinates(trackedLine.getCoordinates().slice(-20)); + + const actualGeoLocation: GeoLocation = { + position, + accuracy, + heading, + speed + }; + setActualPosition(actualGeoLocation); + onGeoLocationChange(actualGeoLocation); + }; + + // Geolocation Control + const olGeoLocation = useMemo(() => active ? new OlGeolocation({ + projection: map?.getView().getProjection() + }) : undefined, [active, map]); + + // re-centers the view by putting the given coordinates at 3/4 from the top or + // the screen + const getCenterWithHeading = (position: [number, number], rotation: number, resolution: number) => { + const size = map?.getSize() ?? [0, 0]; + const height = size[1]; + + return [ + position[0] - Math.sin(rotation) * height * resolution / 4, + position[1] + Math.cos(rotation) * height * resolution / 4 + ]; + }; + + useEffect(() => { + olGeoLocation?.on('change', onLocationChanged); + olGeoLocation?.on('error', onError); + + return () => { + olGeoLocation?.un('change', onLocationChanged); + olGeoLocation?.un('error', onError); + }; + }, [olGeoLocation, onError]); + + useEffect(() => { + olGeoLocation?.setTracking(enableTracking); + }, [enableTracking]); + + useEffect(() => { + olGeoLocation?.setTrackingOptions(trackingOptions); + }, [trackingOptions]); + + useEffect(() => { + const deltaMean = 500; // the geolocation sampling period mean in ms + // use sampling period to get a smooth transition + let m = Date.now() - deltaMean * 1.5; + m = Math.max(m, 0); + + // interpolate position along positions LineString + const c = trackedLine.getCoordinateAtM(m, true); + if (!_isNil(c)) { + if (follow) { + map?.getView().setCenter(getCenterWithHeading([c[0], c[1]], -c[2], map?.getView().getResolution() ?? 0)); + map?.getView().setRotation(-c[2]); + } + if (showMarker) { + const pointGeometry = new OlGeomPoint([c[0], c[1]]); + markerFeature.setGeometry(pointGeometry); + } + } + }, [actualPosition, showMarker, follow, map]); + + return { + actualPosition, + trackedLine + }; +}; diff --git a/src/index.ts b/src/index.ts index ef8fe2a7..8437eecf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,6 +2,7 @@ import BackgroundLayerChooser from './BackgroundLayerChooser/BackgroundLayerChoo import BackgroundLayerPreview from './BackgroundLayerPreview/BackgroundLayerPreview'; import onDropAware from './HigherOrderComponent/DropTargetMap/DropTargetMap'; import timeLayerAware from './HigherOrderComponent/TimeLayerAware/TimeLayerAware'; +import {GeoLocation, useGeoLocation } from './hooks/useGeoLocation/useGeoLocation'; import useMap from './hooks/useMap'; import FloatingMapLogo from './Map/FloatingMapLogo/FloatingMapLogo'; import MapComponent from './Map/MapComponent/MapComponent'; @@ -17,12 +18,13 @@ export { ClickAwayListener, DigitizeUtil, FloatingMapLogo, + GeoLocation, InkmapPrintSpec, isWmsLayer, MapComponent, onDropAware, PrintUtil, timeLayerAware, + useGeoLocation, useMap, - WmsLayer -}; + WmsLayer}; diff --git a/watchBuild.js b/watchBuild.js new file mode 100644 index 00000000..c530dfb3 --- /dev/null +++ b/watchBuild.js @@ -0,0 +1,69 @@ +#!/usr/bin/env node +/* eslint-disable no-console */ + +'use strict'; + +const fs = require('fs-extra'); +const path = require('path'); +const watch = require('watch'); +const util = require('util'); +const exec = util.promisify(require('child_process').exec); + +const curDir = process.cwd(); + +if (process.argv.length < 3) { + console.log('please specify target path'); + console.log('for example npm run watch:buildto ../react-geo/node_modules/@terrestris/react-util/'); + process.exit(0); +} + +const sourcePath = path.join(curDir, 'src'); +const distPath = path.join(curDir, 'dist'); +const targetSourcePath = path.join(curDir, process.argv[2], 'src'); +const targetDistPath = path.join(curDir, process.argv[2], 'dist'); + +if (!fs.existsSync(targetSourcePath) || !fs.existsSync(targetDistPath) ) { + throw new Error('target does not exist'); +} + +async function buildAndCopy() { + console.log('run build'); + + try { + const { stdout, stderr} = await exec('npm run build'); + console.log(stdout); + console.log(stderr); + + console.log('copy dist / src'); + await fs.copy(distPath, targetDistPath); + await fs.copy(sourcePath, targetSourcePath); + + console.log('done'); + } catch (error) { + console.log('error'); + const { stdout, stderr } = error; + console.log(stdout); + console.log(stderr); + } +} + +buildAndCopy(); + +let timeout; + +function throttle(callback, time) { + if (!timeout) { + timeout = setTimeout(function () { + timeout = null; + callback(); + }, time); + } +} + +watch.watchTree(sourcePath, function (f, curr, prev) { + if (typeof f === 'object') { + console.log('watching'); + } else { + throttle(buildAndCopy, 1000); + } +});