diff --git a/package-lock.json b/package-lock.json index 6fb2279..9cc1942 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,8 @@ "@esri/arcgis-rest-feature-service": "^4.0.4", "@esri/arcgis-rest-request": "^4.2.0", "@reduxjs/toolkit": "^1.9.5", - "@vannizhang/wayback-core": "^1.0.5", + "@vannizhang/images-to-video-converter-client": "^1.1.10", + "@vannizhang/wayback-core": "^1.0.6", "axios": "^1.6.2", "classnames": "^2.2.6", "d3": "^7.8.5", @@ -4426,10 +4427,15 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "node_modules/@vannizhang/images-to-video-converter-client": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@vannizhang/images-to-video-converter-client/-/images-to-video-converter-client-1.1.10.tgz", + "integrity": "sha512-OjE4aLaXZbxq8XIRE/Pa/MYt1BFz+j1CPZinoF2pT4rG6eNFmkBkYCdVFTgB+X1KhpsSapTpbrt1PFCL9z/CsA==" + }, "node_modules/@vannizhang/wayback-core": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@vannizhang/wayback-core/-/wayback-core-1.0.5.tgz", - "integrity": "sha512-tSorkvDRvojionT6RksdW9W1zhSCbxnaynRBn1+q5aX0hW9d/3XPdk3X6yxGv1TT7bbu50fY5m7cG3ygMkVEHw==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@vannizhang/wayback-core/-/wayback-core-1.0.6.tgz", + "integrity": "sha512-Ee5oaqy2vSUyxid+ZoYY0ZEUrNVWxi0wXwp8eU4jpelEneIt9HWSOOtT8Y5RVEAWgMhQQfWsqJZhh7KZxgduVg==" }, "node_modules/@webassemblyjs/ast": { "version": "1.11.6", @@ -20230,10 +20236,15 @@ "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", "dev": true }, + "@vannizhang/images-to-video-converter-client": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@vannizhang/images-to-video-converter-client/-/images-to-video-converter-client-1.1.10.tgz", + "integrity": "sha512-OjE4aLaXZbxq8XIRE/Pa/MYt1BFz+j1CPZinoF2pT4rG6eNFmkBkYCdVFTgB+X1KhpsSapTpbrt1PFCL9z/CsA==" + }, "@vannizhang/wayback-core": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@vannizhang/wayback-core/-/wayback-core-1.0.5.tgz", - "integrity": "sha512-tSorkvDRvojionT6RksdW9W1zhSCbxnaynRBn1+q5aX0hW9d/3XPdk3X6yxGv1TT7bbu50fY5m7cG3ygMkVEHw==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@vannizhang/wayback-core/-/wayback-core-1.0.6.tgz", + "integrity": "sha512-Ee5oaqy2vSUyxid+ZoYY0ZEUrNVWxi0wXwp8eU4jpelEneIt9HWSOOtT8Y5RVEAWgMhQQfWsqJZhh7KZxgduVg==" }, "@webassemblyjs/ast": { "version": "1.11.6", diff --git a/package.json b/package.json index 4f8b749..a29a037 100644 --- a/package.json +++ b/package.json @@ -92,7 +92,8 @@ "@esri/arcgis-rest-feature-service": "^4.0.4", "@esri/arcgis-rest-request": "^4.2.0", "@reduxjs/toolkit": "^1.9.5", - "@vannizhang/wayback-core": "^1.0.5", + "@vannizhang/images-to-video-converter-client": "^1.1.10", + "@vannizhang/wayback-core": "^1.0.6", "axios": "^1.6.2", "classnames": "^2.2.6", "d3": "^7.8.5", diff --git a/public/index.html b/public/index.html index 0ac42e3..a7534f7 100644 --- a/public/index.html +++ b/public/index.html @@ -11,8 +11,8 @@ - - + +
diff --git a/src/components/AnimationControls/AnimationControls.tsx b/src/components/AnimationControls/AnimationControls.tsx index e381bb3..462a1ba 100644 --- a/src/components/AnimationControls/AnimationControls.tsx +++ b/src/components/AnimationControls/AnimationControls.tsx @@ -26,31 +26,42 @@ import { } from '@store/Wayback/reducer'; import { - waybackItems4AnimationLoaded, + // waybackItems4AnimationLoaded, // rNum4AnimationFramesSelector, rNum2ExcludeSelector, - toggleAnimationFrame, - rNum2ExcludeReset, + // toggleAnimationFrame, + // rNum2ExcludeReset, // animationSpeedChanged, animationSpeedSelector, - isAnimationPlayingToggled, - isAnimationPlayingSelector, - startAnimation, - stopAnimation, - updateAnimationSpeed, - indexOfCurrentAnimationFrameSelector, - waybackItem4CurrentAnimationFrameSelector, - setActiveFrameByReleaseNum, + // isAnimationPlayingToggled, + // isAnimationPlayingSelector, + // startAnimation, + // stopAnimation, + // updateAnimationSpeed, + // indexOfCurrentAnimationFrameSelector, + // waybackItem4CurrentAnimationFrameSelector, + animationSpeedChanged, + selectAnimationStatus, + animationStatusChanged, + // indexOfActiveAnimationFrameChanged, + selectReleaseNumberOfActiveAnimationFrame, + rNum2ExcludeToggled, + releaseNumberOfActiveAnimationFrameChanged, + // releaseNumberOfActiveAnimationFrameChanged, + // setActiveFrameByReleaseNum, } from '@store/AnimationMode/reducer'; import { IWaybackItem } from '@typings/index'; -import DonwloadGifButton from './DonwloadGifButton'; +import { DonwloadAnimationButton } from './DonwloadAnimationButton'; import FramesSeletor from './FramesSeletor'; import SpeedSelector from './SpeedSelector'; import PlayPauseBtn from './PlayPauseBtn'; -import { usePrevious } from '@hooks/usePrevious'; -import { saveFrames2ExcludeInURLQueryParam } from '@utils/UrlSearchParam'; +// import { usePrevious } from '@hooks/usePrevious'; +import { + saveAnimationSpeedInURLQueryParam, + saveFrames2ExcludeInURLQueryParam, +} from '@utils/UrlSearchParam'; const AnimationControls = () => { const dispatch = useDispatch(); @@ -58,35 +69,29 @@ const AnimationControls = () => { const rNum2ExcludeFromAnimation: number[] = useSelector(rNum2ExcludeSelector); - // const activeItem:IWaybackItem = useSelector(activeWaybackItemSelector); - const waybackItemsWithLocalChanges: IWaybackItem[] = useSelector( waybackItemsWithLocalChangesSelector ); - const prevWaybackItemsWithLocalChanges = usePrevious( - waybackItemsWithLocalChanges - ); - const animationSpeed = useSelector(animationSpeedSelector); - const isPlaying = useSelector(isAnimationPlayingSelector); + const animationStatus = useSelector(selectAnimationStatus); - const waybackItem4CurrentAnimationFrame = useSelector( - waybackItem4CurrentAnimationFrameSelector + const releaseNum4ActiveFrame = useSelector( + selectReleaseNumberOfActiveAnimationFrame ); const speedOnChange = useCallback((speed: number) => { - dispatch(updateAnimationSpeed(speed)); + dispatch(animationSpeedChanged(speed)); }, []); const playPauseBtnOnClick = useCallback(() => { - if (isPlaying) { - dispatch(stopAnimation()); + if (animationStatus === 'playing') { + dispatch(animationStatusChanged('pausing')); } else { - dispatch(startAnimation()); + dispatch(animationStatusChanged('playing')); } - }, [isPlaying]); + }, [animationStatus]); const getContent = () => { if ( @@ -95,7 +100,7 @@ const AnimationControls = () => { ) { return (
-

+

Loading versions with local changes.

@@ -104,10 +109,10 @@ const AnimationControls = () => { return ( <> - + -
- Animation Speed +
+ Animation Speed
{ }} > @@ -129,39 +135,27 @@ const AnimationControls = () => { { - dispatch(setActiveFrameByReleaseNum(rNum)); + if (animationStatus !== 'pausing') { + return; + } + + dispatch( + releaseNumberOfActiveAnimationFrameChanged(rNum) + ); + // console.log(rNum); }} toggleFrame={(rNum) => { - dispatch(toggleAnimationFrame(rNum)); + dispatch(rNum2ExcludeToggled(rNum)); }} - waybackItem4CurrentAnimationFrame={ - waybackItem4CurrentAnimationFrame - } - isButtonDisabled={isPlaying} + releaseNum4ActiveFrame={releaseNum4ActiveFrame} + isButtonDisabled={animationStatus === 'playing'} /> ); }; - useEffect(() => { - batch(() => { - dispatch( - waybackItems4AnimationLoaded(waybackItemsWithLocalChanges) - ); - - if ( - prevWaybackItemsWithLocalChanges && - prevWaybackItemsWithLocalChanges.length - ) { - dispatch(rNum2ExcludeReset()); - } - }); - }, [waybackItemsWithLocalChanges]); - useEffect(() => { // console.log(rNum2ExcludeFromAnimation) saveFrames2ExcludeInURLQueryParam(rNum2ExcludeFromAnimation); @@ -170,10 +164,11 @@ const AnimationControls = () => { return ( <>
{getContent()}
diff --git a/src/components/AnimationControls/DonwloadGifButton.tsx b/src/components/AnimationControls/DonwloadAnimationButton.tsx similarity index 50% rename from src/components/AnimationControls/DonwloadGifButton.tsx rename to src/components/AnimationControls/DonwloadAnimationButton.tsx index 81d0081..853a7dd 100644 --- a/src/components/AnimationControls/DonwloadGifButton.tsx +++ b/src/components/AnimationControls/DonwloadAnimationButton.tsx @@ -17,28 +17,41 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import classnames from 'classnames'; import { - isDownloadGIFDialogOnToggled, - isLoadingFrameDataSelector, + showDownloadAnimationPanelToggled, + selectAnimationStatus, + // isLoadingFrameDataSelector, } from '@store/AnimationMode/reducer'; -const DonwloadGifButton = () => { +export const DonwloadAnimationButton = () => { const dispatch = useDispatch(); - const isLoadingFrameData = useSelector(isLoadingFrameDataSelector); + // const isLoadingFrameData = useSelector(isLoadingFrameDataSelector); + + const animationStatus = useSelector(selectAnimationStatus); const onClickHandler = useCallback(() => { - dispatch(isDownloadGIFDialogOnToggled()); + dispatch(showDownloadAnimationPanelToggled(true)); }, []); - const classNames = classnames('btn btn-fill', { - 'btn-disabled': isLoadingFrameData, - }); + // const classNames = classnames('btn btn-fill', { + // 'btn-disabled': animationStatus === 'loading', + // }); return ( -
- Download GIF +
+ + Download Animation +
); }; - -export default DonwloadGifButton; diff --git a/src/components/AnimationControls/FramesSeletor.tsx b/src/components/AnimationControls/FramesSeletor.tsx index 4c7678e..06e3470 100644 --- a/src/components/AnimationControls/FramesSeletor.tsx +++ b/src/components/AnimationControls/FramesSeletor.tsx @@ -24,7 +24,7 @@ type Props = { // rNum4AnimationFrames: number[]; waybackItemsWithLocalChanges: IWaybackItem[]; rNum2Exclude: number[]; - waybackItem4CurrentAnimationFrame: IWaybackItem; + releaseNum4ActiveFrame: number; // activeItem: IWaybackItem; isButtonDisabled: boolean; setActiveFrame: (rNum: number) => void; @@ -36,7 +36,7 @@ const FramesSeletor: React.FC = ({ // rNum4AnimationFrames, waybackItemsWithLocalChanges, rNum2Exclude, - waybackItem4CurrentAnimationFrame, + releaseNum4ActiveFrame, // activeItem, isButtonDisabled, setActiveFrame, @@ -95,9 +95,7 @@ const FramesSeletor: React.FC = ({ // onClick={onSelect.bind(this, d)} onClick={setActiveFrame.bind(this, releaseNum)} showBoarderOnLeft={ - waybackItem4CurrentAnimationFrame && - waybackItem4CurrentAnimationFrame.releaseNum === - releaseNum + releaseNum4ActiveFrame === releaseNum } disableCursorPointer={isButtonDisabled} > @@ -108,11 +106,11 @@ const FramesSeletor: React.FC = ({ }} >
{ evt.stopPropagation(); toggleFrame(releaseNum); @@ -129,10 +127,11 @@ const FramesSeletor: React.FC = ({ return (
{/*
diff --git a/src/components/AnimationControls/PlayPauseBtn.tsx b/src/components/AnimationControls/PlayPauseBtn.tsx index 05dad90..0328369 100644 --- a/src/components/AnimationControls/PlayPauseBtn.tsx +++ b/src/components/AnimationControls/PlayPauseBtn.tsx @@ -16,6 +16,7 @@ import React, { useEffect } from 'react'; type Props = { + isLoading: boolean; isPlaying: boolean; onClick: () => void; }; @@ -44,16 +45,27 @@ const PauseBtn = ( ); -const PlayPauseBtn: React.FC = ({ isPlaying, onClick }: Props) => { +const PlayPauseBtn: React.FC = ({ + isLoading, + isPlaying, + onClick, +}: Props) => { + const getIcon = () => { + if (isLoading) { + return ; + } + + return isPlaying ? PauseBtn : PlayBtn; + }; return (
- {isPlaying ? PauseBtn : PlayBtn} + {getIcon()}
); }; diff --git a/src/components/AnimationControls/SpeedSelector.tsx b/src/components/AnimationControls/SpeedSelector.tsx index 87a938e..3d10448 100644 --- a/src/components/AnimationControls/SpeedSelector.tsx +++ b/src/components/AnimationControls/SpeedSelector.tsx @@ -13,22 +13,35 @@ * limitations under the License. */ +import { ANIMATION_SPEED_OPTIONS_IN_MILLISECONDS } from '@store/AnimationMode/reducer'; import React from 'react'; type Props = { defaultVal: number; - onChange: (speed: number) => void; + onChange: (speedInMilliseonds: number) => void; }; -const MIN_VAL = 0.0; // min animation speed is .5 second -const MAX_VAL = 2; // max animation speed is 3 second -const SLIDER_STEP = 0.25; +// const MIN_VAL = 0.0; // min animation speed is .5 second +// const MAX_VAL = 2; // max animation speed is 3 second +// const SLIDER_STEP = 0.25; const SpeedSelector: React.FC = ({ defaultVal, onChange }: Props) => { const sliderRef = React.useRef(); const onChangeDely = React.useRef(); + const calcSliderDefaultValue = () => { + const idx = ANIMATION_SPEED_OPTIONS_IN_MILLISECONDS.indexOf(defaultVal); + + if (idx === -1) { + return Math.floor( + ANIMATION_SPEED_OPTIONS_IN_MILLISECONDS.length / 2 + ); + } + + return idx; + }; + React.useEffect(() => { sliderRef.current.addEventListener( 'calciteSliderChange', @@ -39,13 +52,18 @@ const SpeedSelector: React.FC = ({ defaultVal, onChange }: Props) => { // console.log('slider on change', evt.target.value) // onChange(+evt.target.value) - const tickVal = Math.floor(+evt.target.value * 100) / 100; + // const tickVal = Math.floor(+evt.target.value * 100) / 100; + + // // the max val indciates fastes time and min val indicates slowest, therefore we need to use max val to minus the tick val + // // to get the actual animation speed, let's say the tick val is 2 and max val is 3, that gives a current speed of 1 second + // const val = MAX_VAL - tickVal; + + const index = evt.target.value; - // the max val indciates fastes time and min val indicates slowest, therefore we need to use max val to minus the tick val - // to get the actual animation speed, let's say the tick val is 2 and max val is 3, that gives a current speed of 1 second - const val = MAX_VAL - tickVal; + const speed = + ANIMATION_SPEED_OPTIONS_IN_MILLISECONDS[index]; - onChange(val); + onChange(speed); }, 500); } ); @@ -68,7 +86,7 @@ const SpeedSelector: React.FC = ({ defaultVal, onChange }: Props) => { }} className="calcite-theme-dark" > - - + -
= ({ defaultVal, onChange }: Props) => { >
- + + +
); }; diff --git a/src/components/AnimationDownloadPanel/CopiedLinkMessage.tsx b/src/components/AnimationDownloadPanel/CopiedLinkMessage.tsx new file mode 100644 index 0000000..6b3892e --- /dev/null +++ b/src/components/AnimationDownloadPanel/CopiedLinkMessage.tsx @@ -0,0 +1,42 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { selectAnimationLinkIsCopied } from '@store/AnimationMode/reducer'; +import classNames from 'classnames'; +import React from 'react'; +import { useSelector } from 'react-redux'; + +export const CopiedLinkMessage = () => { + const linkIsCopied = useSelector(selectAnimationLinkIsCopied); + + if (!linkIsCopied) { + return null; + } + + return ( +
+
+ + {'link copied to clipboard'} +
+
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/CopyLinkButton.tsx b/src/components/AnimationDownloadPanel/CopyLinkButton.tsx new file mode 100644 index 0000000..1a6ace9 --- /dev/null +++ b/src/components/AnimationDownloadPanel/CopyLinkButton.tsx @@ -0,0 +1,46 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { copyAnimationLink } from '@store/AnimationMode/thunks'; +import classNames from 'classnames'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +export const CopyLinkButton = () => { + const dispatch = useDispatch(); + + return ( +
{ + dispatch(copyAnimationLink()); + }} + > + {/* link icon: https://esri.github.io/calcite-ui-icons/#link */} + + + + +
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/DownloadJobStatus.tsx b/src/components/AnimationDownloadPanel/DownloadJobStatus.tsx new file mode 100644 index 0000000..6eb67e1 --- /dev/null +++ b/src/components/AnimationDownloadPanel/DownloadJobStatus.tsx @@ -0,0 +1,85 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import { DownloadJobStatus } from './DownloadPanel'; +import classNames from 'classnames'; + +type Props = { + status: DownloadJobStatus; + /** + * emits when user clicks on cancel button to cancel the pending download job + * @returns + */ + cancelButtonOnClick: () => void; + /** + * emit when user clicks on the close button to hide the notification message + * @returns + */ + closeButtonOnClick: () => void; +}; + +export const DownloadJobStatusInfo: FC = ({ + status, + cancelButtonOnClick, + closeButtonOnClick, +}) => { + if (!status) { + return null; + } + + return ( +
+ {status === 'pending' && ( +
+ + Creating MP4. + + Cancel + +
+ )} + + {(status === 'finished' || status === 'failed') && ( +
+

+ {status === 'finished' + ? 'Complete! Check browser downloads for the MP4 file.' + : 'Failed to create MP4.'} +

+ + +
+ )} +
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/DownloadOptionsList.tsx b/src/components/AnimationDownloadPanel/DownloadOptionsList.tsx new file mode 100644 index 0000000..b2dd723 --- /dev/null +++ b/src/components/AnimationDownloadPanel/DownloadOptionsList.tsx @@ -0,0 +1,139 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useRef } from 'react'; +import { VIDEO_SIZE_OPTIONS } from './config'; +import classNames from 'classnames'; + +type DimensionInfoProps = { + width: number; + height: number; + onClick: () => void; + onMouseEnter: () => void; + onMouseLeave: () => void; +}; + +/** + * size of the icon that will be used to preview the dimension + */ +const DIMENSION_ICON_SIZE = 32; + +/** + * this is the max size of the output video that we support + */ +const MAX_SIZE_OUTPUT_VIDEO = 1920; + +const DimensionInfo: FC = ({ + width, + height, + onClick, + onMouseEnter, + onMouseLeave, +}) => { + const getDimensionIcon = () => { + return ( +
+
+
+ ); + }; + + return ( +
+ {getDimensionIcon()} + + {width} x {height} + +
+ ); +}; + +type Props = { + /** + * emits when user hovers an option list item + * @param size [width, height] of output video + * @returns void + */ + onMouseEnter: (sizes?: number[]) => void; + onMouseLeave: () => void; + /** + * emits when user clicks an option list item + * @param size [width, height] of output video + * @returns void + */ + onClick: (sizes?: number[]) => void; +}; + +export const DownloadOptionsList: FC = ({ + onMouseEnter, + onMouseLeave, + onClick, +}) => { + return ( +
+
+ {VIDEO_SIZE_OPTIONS.map((d) => { + const { title, dimensions } = d; + + return ( +
+

+ {title} +

+
+ {dimensions.map((size) => { + const [w, h] = size; + + return ( + + ); + })} +
+
+ ); + })} +
+
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/DownloadPanel.tsx b/src/components/AnimationDownloadPanel/DownloadPanel.tsx new file mode 100644 index 0000000..8024b04 --- /dev/null +++ b/src/components/AnimationDownloadPanel/DownloadPanel.tsx @@ -0,0 +1,217 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useRef, useState } from 'react'; +import IImageElement from '@arcgis/core/layers/support/ImageElement'; +import { downloadBlob } from '@utils/snippets/downloadBlob'; +import { DownloadOptionsList } from './DownloadOptionsList'; +import { Dimension, PreviewWindow } from './PreviewWindow'; +import { useSelector } from 'react-redux'; +import { DownloadJobStatusInfo } from './DownloadJobStatus'; +import { CloseButton } from '../CloseButton'; +import { useDispatch } from 'react-redux'; +// import { selectMapCenter } from '@shared/store/Map/selectors'; +import { OpenDownloadPanelButton } from './OpenDownloadPanelButton'; +import { + convertImages2Video, + AnimationFrameData, +} from '@vannizhang/images-to-video-converter-client'; +import { APP_TITLE } from '@constants/strings'; +import { + selectShouldShowDownloadPanel, + showDownloadAnimationPanelToggled, +} from '@store/AnimationMode/reducer'; +import { CopyLinkButton } from './CopyLinkButton'; +import { CopiedLinkMessage } from './CopiedLinkMessage'; + +// /** +// * This object contains the data for each animation frame. +// */ +// export type AnimationFrameData4DownloadJob = { +// /** +// * The image element representing the median layer for this frame. +// */ +// mediaLayerElement: IImageElement; +// /** +// * Additional information about this frame. +// */ +// info: string; +// }; + +type Props = { + /** + * An array containing data representing the animation frames. + */ + frameData4DownloadJob: AnimationFrameData[]; + /** + * animation speed in millisecond + */ + animationSpeed: number; + /** + * size of the map view window + */ + mapViewWindowSize: Dimension; +}; + +/** + * status of job to download animation as MP4 + */ +export type DownloadJobStatus = 'pending' | 'finished' | 'cancelled' | 'failed'; + +export const AnimationDownloadPanel: FC = ({ + frameData4DownloadJob, + animationSpeed, + mapViewWindowSize, +}) => { + const dispatch = useDispatch(); + + const shouldShowDownloadPanel = useSelector(selectShouldShowDownloadPanel); + + const [previewWindowSize, setPreviewWindowSize] = useState(null); + + const [downloadJobStatus, setDownloadJobStatus] = + useState(null); + + const abortController = useRef(); + + const downloadAnimation = async (outputVideoDimension: Dimension) => { + setDownloadJobStatus('pending'); + + const { width, height } = outputVideoDimension; + + try { + if (abortController.current) { + abortController.current.abort(); + } + + abortController.current = new AbortController(); + + const { filename, fileContent } = await convertImages2Video({ + data: frameData4DownloadJob, + animationSpeed, + outputWidth: width, + outputHeight: height, + authoringApp: APP_TITLE, + abortController: abortController.current, + }); + + downloadBlob(fileContent, 'wayback-animation-' + filename); + + setDownloadJobStatus('finished'); + } catch (err) { + console.log(err); + + // no need to set status to failed if error + // is caused by the user aborting the pending job + if (err.name === 'AbortError') { + return; + } + + setDownloadJobStatus('failed'); + } + }; + + useEffect(() => { + if (!shouldShowDownloadPanel) { + setPreviewWindowSize(null); + setDownloadJobStatus(null); + + if (abortController.current) { + abortController.current.abort(); + } + } + }, [shouldShowDownloadPanel]); + + if (!frameData4DownloadJob || !frameData4DownloadJob?.length) { + return null; + } + + return ( + <> +
+ {/* Download Button that opens the Download Animation Panel */} + {shouldShowDownloadPanel === false && ( + <> + + + + + )} + + {downloadJobStatus !== null && ( + { + // close animation download panel will also cancel any + // pending tasks + dispatch(showDownloadAnimationPanelToggled(false)); + }} + closeButtonOnClick={() => { + dispatch(showDownloadAnimationPanelToggled(false)); + }} + /> + )} + + {shouldShowDownloadPanel && downloadJobStatus === null && ( + <> + { + if (!size) { + return; + } + + const [width, height] = size; + + setPreviewWindowSize({ + width, + height, + }); + // console.log(size); + }} + onMouseLeave={setPreviewWindowSize.bind(null, null)} + onClick={(size) => { + if (!size) { + return; + } + + const [width, height] = size; + + downloadAnimation({ + width, + height, + }); + }} + /> + + { + dispatch( + showDownloadAnimationPanelToggled(false) + ); + }} + /> + + )} +
+ + {previewWindowSize && ( + + )} + + ); +}; diff --git a/src/components/AnimationDownloadPanel/OpenDownloadPanelButton.tsx b/src/components/AnimationDownloadPanel/OpenDownloadPanelButton.tsx new file mode 100644 index 0000000..478d208 --- /dev/null +++ b/src/components/AnimationDownloadPanel/OpenDownloadPanelButton.tsx @@ -0,0 +1,47 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { showDownloadAnimationPanelToggled } from '@store/AnimationMode/reducer'; +import classNames from 'classnames'; +import React from 'react'; +import { useDispatch } from 'react-redux'; + +export const OpenDownloadPanelButton = () => { + const dispatch = useDispatch(); + + return ( +
{ + dispatch(showDownloadAnimationPanelToggled(true)); + }} + > + {/* download-to icon: https://esri.github.io/calcite-ui-icons/#download-to */} + + + + +
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/PreviewWindow.tsx b/src/components/AnimationDownloadPanel/PreviewWindow.tsx new file mode 100644 index 0000000..1935d0d --- /dev/null +++ b/src/components/AnimationDownloadPanel/PreviewWindow.tsx @@ -0,0 +1,92 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useMemo } from 'react'; + +export type Dimension = { + width: number; + height: number; +}; + +type Props = { + /** + * dimension of the preview window + */ + previewWindowSize: Dimension; + /** + * dimension of the map view window + */ + mapViewWindowSize: Dimension; +}; + +/** + * Preview Window component helps user to visually see the portion of animation that will be included in the output mp4 file + * @param param0 + * @returns + */ +export const PreviewWindow: FC = ({ + previewWindowSize, + mapViewWindowSize, +}) => { + /** + * useMemo Hook for Adjusting Window Size + * + * This `useMemo` hook adjusts the size of a preview window (represented by the `size` variable) + * to make sure it can fit within the preview window (represented by the `mapViewWindowSize` variable). + * + * @param {WindowSize} size - The original size of the preview window to be adjusted. + * @param {WindowSize} mapViewWindowSize - The size of the map view window. + * @returns {WindowSize | null} - The adjusted window size, or null if `size` is falsy. + */ + const adjustedSize: Dimension = useMemo(() => { + if (!previewWindowSize) { + return null; + } + + // Calculate the aspect ratio of the user selected output size size. + const aspectRatio = previewWindowSize.width / previewWindowSize.height; + + const previewWindowHeight = mapViewWindowSize.height; + const previewWindowWidth = previewWindowHeight * aspectRatio; + + if (previewWindowWidth > mapViewWindowSize.width) { + return { + width: mapViewWindowSize.width, + height: mapViewWindowSize.width * (1 / aspectRatio), + }; + } + + return { + height: previewWindowHeight, + width: previewWindowWidth, + }; + }, [previewWindowSize]); + + if (!adjustedSize) { + return null; + } + + return ( +
+
+
+ ); +}; diff --git a/src/components/AnimationDownloadPanel/config.ts b/src/components/AnimationDownloadPanel/config.ts new file mode 100644 index 0000000..9f57891 --- /dev/null +++ b/src/components/AnimationDownloadPanel/config.ts @@ -0,0 +1,38 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export const VIDEO_SIZE_OPTIONS = [ + { + title: 'horizontal', + dimensions: [ + [1920, 1080], // aspect ratio 16:9 + [1080, 720], // aspect ratio 3:2 + ], + }, + { + title: 'square', + dimensions: [ + [1080, 1080], // aspect ratio 1:1 + [720, 720], // aspect ratio 1:1 + ], + }, + { + title: 'vertical', + dimensions: [ + [1080, 1920], // aspect ratio 9:16 + [720, 1080], // aspect ratio 2:3 + ], + }, +]; diff --git a/src/components/SettingDialog/config.ts b/src/components/AnimationDownloadPanel/index.ts similarity index 90% rename from src/components/SettingDialog/config.ts rename to src/components/AnimationDownloadPanel/index.ts index 82a6555..e1cbb80 100644 --- a/src/components/SettingDialog/config.ts +++ b/src/components/AnimationDownloadPanel/index.ts @@ -13,6 +13,4 @@ * limitations under the License. */ -export default { - 'modal-id': 'setting', -}; +export { AnimationDownloadPanel } from './DownloadPanel'; diff --git a/src/components/AnimationLayer/AnimationLayer.tsx b/src/components/AnimationLayer/AnimationLayer.tsx new file mode 100644 index 0000000..9a9f2de --- /dev/null +++ b/src/components/AnimationLayer/AnimationLayer.tsx @@ -0,0 +1,209 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import MediaLayer from '@arcgis/core/layers/MediaLayer'; +import React, { FC, useCallback, useEffect, useRef } from 'react'; +import { useDispatch } from 'react-redux'; +import { useSelector } from 'react-redux'; +import { + // animationSpeedChanged, + animationSpeedSelector, + animationStatusChanged, + isAnimationModeOnSelector, + rNum2ExcludeReset, + rNum2ExcludeSelector, + // indexOfActiveAnimationFrameChanged, + releaseNumberOfActiveAnimationFrameChanged, + selectAnimationStatus, + selectReleaseNumberOfActiveAnimationFrame, + showDownloadAnimationPanelToggled, + toggleAnimationMode, + // waybackItems4AnimationSelector, +} from '@store/AnimationMode/reducer'; +import classNames from 'classnames'; +import { CloseButton } from '@components/CloseButton'; +import { useMediaLayerImageElement } from './useMediaLayerImageElement'; +import useMediaLayerAnimation from './useMediaLayerAnimation'; +import { + selectIsLoadingWaybackItems, + waybackItemsWithLocalChangesSelector, +} from '@store/Wayback/reducer'; +import { AnimationDownloadPanel } from '@components/AnimationDownloadPanel'; +import { useFrameDataForDownloadJob } from './useFrameDataForDownloadJob'; +import { delay } from '@utils/snippets/delay'; + +type Props = { + mapView?: MapView; +}; + +export const AnimationLayer: FC = ({ mapView }: Props) => { + const dispatch = useDispatch(); + + const mediaLayerRef = useRef(); + + const isAnimationModeOn = useSelector(isAnimationModeOnSelector); + + const animationStatus = useSelector(selectAnimationStatus); + + // const animationSpeedInSeconds = useSelector(animationSpeedSelector); + + const animationSpeedInMilliseconds = useSelector(animationSpeedSelector); + + /** + * wayback items with local changes + */ + const waybackItems = useSelector(waybackItemsWithLocalChangesSelector); + + /** + * release num of wayback items to be excluded from the animation + */ + const releaseNumOfItems2Exclude = useSelector(rNum2ExcludeSelector); + + const isLoadingWaybackItemsWithLoalChanges = useSelector( + selectIsLoadingWaybackItems + ); + + const releaseNumOfActiveFrame = useSelector( + selectReleaseNumberOfActiveAnimationFrame + ); + + /** + * Array of Imagery Element Data + */ + const imageElementsData = useMediaLayerImageElement({ + mapView, + animationStatus, + waybackItems, + isLoadingWaybackItemsWithLoalChanges, + }); + + const frameData = useFrameDataForDownloadJob({ + waybackItems, + imageElements: imageElementsData, + releaseNumOfItems2Exclude, + }); + + /** + * This is a callback function that will be called each time the active frame (Image Element) in the animation layer is changed. + */ + const activeFrameOnChange = useCallback( + (indexOfActiveFrame: number) => { + dispatch( + releaseNumberOfActiveAnimationFrameChanged( + waybackItems[indexOfActiveFrame]?.releaseNum + ) + ); + + // console.log(waybackItems[indexOfActiveFrame]) + }, + [waybackItems] + ); + + useMediaLayerAnimation({ + animationStatus, + animationSpeed: animationSpeedInMilliseconds, + imageElementsData, + releaseNumOfItems2Exclude, + releaseNumOfActiveFrame, + activeFrameOnChange, + }); + + const initMediaLayer = async () => { + mediaLayerRef.current = new MediaLayer({ + visible: true, + // effect: LandCoverLayerEffect, + // blendMode: LandCoverLayerBlendMode, + }); + + mapView.map.add(mediaLayerRef.current); + }; + + useEffect(() => { + (async () => { + if (!mediaLayerRef.current) { + initMediaLayer(); + return; + } + + const source = mediaLayerRef.current.source as any; + + if (!imageElementsData || !imageElementsData?.length) { + // animation is not started or just stopped + // just clear all elements in media layer + source.elements.removeAll(); + } else { + source.elements.addMany( + imageElementsData.map((d) => d.imageElement) + ); + + // wait for one second before starting playing the animation, + // to give the media layer enough time to add all image elements + await delay(1000); + + // media layer elements are ready, change animation mode to playing to start the animation + dispatch(animationStatusChanged('playing')); + } + })(); + }, [imageElementsData, mapView]); + + useEffect(() => { + if (isAnimationModeOn) { + dispatch(animationStatusChanged('loading')); + } else { + dispatch(animationStatusChanged(null)); + dispatch(rNum2ExcludeReset()); + dispatch(releaseNumberOfActiveAnimationFrameChanged(null)); + } + }, [isAnimationModeOn]); + + useEffect(() => { + // should close download animation panel whenever user exits the animation mode + if (animationStatus === null) { + dispatch(showDownloadAnimationPanelToggled(false)); + } + }, [animationStatus]); + + if (!isAnimationModeOn) { + return null; + } + + return ( +
+ {animationStatus === 'loading' && ( + + )} + + { + dispatch(toggleAnimationMode()); + }} + /> + + +
+ ); +}; diff --git a/src/components/AnimationPanel/generateFrames4GIF.ts b/src/components/AnimationLayer/generateAnimationFrames.ts similarity index 82% rename from src/components/AnimationPanel/generateFrames4GIF.ts rename to src/components/AnimationLayer/generateAnimationFrames.ts index 7a9a0d6..549f52a 100644 --- a/src/components/AnimationPanel/generateFrames4GIF.ts +++ b/src/components/AnimationLayer/generateAnimationFrames.ts @@ -47,6 +47,7 @@ type GenerateFramesParams = { frameRect: FrameRectInfo; mapView: MapView; waybackItems: IWaybackItem[]; + abortController?: AbortController; }; type CenterLocationForFrameRect = { @@ -56,20 +57,22 @@ type CenterLocationForFrameRect = { export type FrameData = { releaseNum: number; - waybackItem: IWaybackItem; + // waybackItem: IWaybackItem; frameCanvas: HTMLCanvasElement; - frameDataURI: string; - height: number; - width: number; - center: CenterLocationForFrameRect; + frameBlob: Blob; + // frameDataURI?: string; + // height: number; + // width: number; + // center: CenterLocationForFrameRect; }; const WaybackImageBaseURL = getServiceUrl('wayback-imagery-base'); -export const generateFrames = async ({ +export const generateAnimationFrames = async ({ frameRect, mapView, waybackItems, + abortController, }: GenerateFramesParams): Promise => { const frames: FrameData[] = []; @@ -79,33 +82,39 @@ export const generateFrames = async ({ mapView, }); - const center = getCenterLocationForFrameRect({ - frameRect, - mapView, - }); - - for (const item of waybackItems) { + const generateFrameRequests = waybackItems.map((item) => { const { releaseNum } = item; - const { frameCanvas, frameDataURI } = await generateFrame({ + return generateFrame({ frameRect, tiles, releaseNum, + abortController, }); + }); + + const responses = await Promise.all(generateFrameRequests); + + for (let i = 0; i < responses.length; i++) { + const res = responses[i]; + + const { releaseNum } = waybackItems[i]; frames.push({ + ...res, releaseNum, - waybackItem: item, - frameCanvas, - frameDataURI, - width: frameRect.width, - height: frameRect.height, - center, }); } - // console.log(frameDataURL) - return frames; + return new Promise((resolve, reject) => { + if (abortController && abortController.signal.aborted) { + reject( + 'The task to generate animation frames has been cancelled by the user' + ); + } + + resolve(frames); + }); }; // get data URL from canvas with map tiles that conver the entire container @@ -113,13 +122,15 @@ const generateFrame = async ({ frameRect, tiles, releaseNum, + abortController, }: { frameRect: FrameRectInfo; tiles: TileInfo[]; releaseNum: number; + abortController: AbortController; }): Promise<{ frameCanvas: HTMLCanvasElement; - frameDataURI: string; + frameBlob: Blob; }> => { return new Promise((resolve, reject) => { const { screenX, screenY, height, width } = frameRect; @@ -160,9 +171,16 @@ const generateFrame = async ({ // all tiles are drawn to canvas, return the canvas as D if (tilesProcessed === tiles.length) { // resolve(canvas.toDataURL('image/png')); - resolve({ - frameCanvas: canvas, - frameDataURI: canvas.toDataURL('image/png'), + // resolve({ + // frameCanvas: canvas, + // frameDataURI: canvas.toDataURL('image/png'), + // }); + + canvas.toBlob((frameBlob: Blob) => { + resolve({ + frameCanvas: canvas, + frameBlob, + }); }); } }); diff --git a/src/components/AnimationLayer/useFrameDataForDownloadJob.tsx b/src/components/AnimationLayer/useFrameDataForDownloadJob.tsx new file mode 100644 index 0000000..a98e302 --- /dev/null +++ b/src/components/AnimationLayer/useFrameDataForDownloadJob.tsx @@ -0,0 +1,102 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import ImageElement from '@arcgis/core/layers/support/ImageElement'; +import { selectMapCenter } from '@store/Map/reducer'; +import { IWaybackItem } from '@typings/index'; +import { loadImageAsHTMLIMageElement } from '@utils/snippets/loadImage'; +import { AnimationFrameData } from '@vannizhang/images-to-video-converter-client'; +import React, { FC, useEffect, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { ImageElementData } from './useMediaLayerImageElement'; +// import { AnimationFrameData4DownloadJob } from '../AnimationDownloadPanel/DownloadPanel'; + +/** + * Represents the properties required by the custom hook `useFrameDataForDownloadJob`. + */ +type Props = { + /** + * An array wayback items associated with the media layer elements in animation mode. + */ + waybackItems: IWaybackItem[]; + /** + * An array of ImageElement objects representing media layer elements. + */ + imageElements: ImageElementData[]; + /** + * list of release number of wayback items to exclude from the animation. + */ + releaseNumOfItems2Exclude: number[]; +}; + +/** + * This custom hook returns an array of `AnimationFrameData` objects that + * can be used by the Animation Download task. + * @param {Props} - The properties required by the hook. + * @returns An array of `AnimationFrameData4DownloadJob` objects. + */ +export const useFrameDataForDownloadJob = ({ + waybackItems, + imageElements, + releaseNumOfItems2Exclude, +}: Props) => { + const { lon, lat } = useSelector(selectMapCenter) || {}; + + const [frameData, setFrameData] = useState([]); + + useEffect(() => { + (async () => { + if (!waybackItems?.length || !imageElements?.length) { + setFrameData([]); + return; + } + + const data: AnimationFrameData[] = []; + + const images = await Promise.all( + imageElements.map((d) => + loadImageAsHTMLIMageElement(d.imageElement.image as string) + ) + ); + + for (let i = 0; i < imageElements.length; i++) { + const item = waybackItems[i]; + + // should not include the frame of the items in the exlusion list + if (releaseNumOfItems2Exclude.includes(item.releaseNum)) { + continue; + } + + // // load media layer elements as an array of HTML Image Elements + // const image = await loadImageAsHTMLIMageElement( + // imageElements[i].imageElement.image as string + // ); + + const frameData = { + image: images[i], + imageInfo: `${item.releaseDateLabel} | x ${lon.toFixed( + 3 + )} y ${lat.toFixed(3)}`, + } as AnimationFrameData; + + data.push(frameData); + } + + setFrameData(data); + })(); + }, [imageElements, releaseNumOfItems2Exclude]); + + return frameData; +}; diff --git a/src/components/AnimationLayer/useMediaLayerAnimation.tsx b/src/components/AnimationLayer/useMediaLayerAnimation.tsx new file mode 100644 index 0000000..11e5d1f --- /dev/null +++ b/src/components/AnimationLayer/useMediaLayerAnimation.tsx @@ -0,0 +1,192 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC, useEffect, useRef, useState } from 'react'; +import IImageElement from '@arcgis/core/layers/support/ImageElement'; +import { AnimationStatus } from '@store/AnimationMode/reducer'; +import { ImageElementData } from './useMediaLayerImageElement'; + +type Props = { + /** + * status of the animation mode + */ + animationStatus: AnimationStatus; + /** + * animation speed in millisecond + */ + animationSpeed: number; + /** + * array of image elements to be animated + */ + imageElementsData: ImageElementData[]; + /** + * list of release number of wayback items to exclude from the animation + */ + releaseNumOfItems2Exclude: number[]; + + releaseNumOfActiveFrame: number; + /** + * Fires when the active frame changes + * @param indexOfActiveFrame index of the active frame + * @returns void + */ + activeFrameOnChange: (indexOfActiveFrame: number) => void; +}; + +/** + * This is a custom hook that handles the animation of input media layer elements + * @param animationStatus status of the animation + * @param mediaLayerElements Image Elements added to a media layer that will be animated + */ +const useMediaLayerAnimation = ({ + animationStatus, + animationSpeed, + imageElementsData, + releaseNumOfItems2Exclude, + releaseNumOfActiveFrame, + activeFrameOnChange, +}: Props) => { + const isPlayingRef = useRef(false); + + const timeLastFrameDisplayed = useRef(performance.now()); + + const indexOfActiveFrame = useRef(0); + + const activeFrameOnChangeRef = useRef(); + + const animationSpeedRef = useRef(animationSpeed); + + const releaseNumOfItems2ExcludeRef = useRef([]); + + const showNextFrame = () => { + // use has stopped animation, no need to show next frame + if (!isPlayingRef.current) { + return; + } + + // get current performance time + const now = performance.now(); + + const millisecondsSinceLastFrame = now - timeLastFrameDisplayed.current; + + // if last frame was shown within the time window, no need to display next frame + if (millisecondsSinceLastFrame < animationSpeedRef.current) { + requestAnimationFrame(showNextFrame); + return; + } + + timeLastFrameDisplayed.current = now; + + // reset index of next frame to 0 if it is out of range. + // this can happen when a frame gets removed after previous animation is stopped + if (indexOfActiveFrame.current >= imageElementsData.length) { + indexOfActiveFrame.current = 0; + } + + activeFrameOnChangeRef.current(indexOfActiveFrame.current); + + for (let i = 0; i < imageElementsData.length; i++) { + const opacity = i === indexOfActiveFrame.current ? 1 : 0; + imageElementsData[i].imageElement.opacity = opacity; + } + + // set index of active frame to -1 so that all frames wil be exclude from the animation + if ( + releaseNumOfItems2ExcludeRef.current.length === + imageElementsData.length + ) { + indexOfActiveFrame.current = -1; + requestAnimationFrame(showNextFrame); + return; + } + + // get the index of animation frame that will become active next + let indexOfNextFrame = + (indexOfActiveFrame.current + 1) % imageElementsData.length; + + // check if the next frame should be excluded from the animation, + // if so, update the indexOfNextFrame to skip the frame that should be excluded + while ( + indexOfNextFrame !== indexOfActiveFrame.current && + releaseNumOfItems2ExcludeRef.current.includes( + imageElementsData[indexOfNextFrame].releaseNumber + ) + ) { + indexOfNextFrame = + (indexOfNextFrame + 1) % imageElementsData.length; + } + + // update indexOfNextFrame using the index of next element + // when hit the end of the array, use 0 instead + indexOfActiveFrame.current = indexOfNextFrame; + + // call showNextFrame recursively to play the animation as long as + // animationMode is 'playing' + requestAnimationFrame(showNextFrame); + }; + + useEffect(() => { + isPlayingRef.current = animationStatus === 'playing'; + + // reset the index of active frame when animation is stopped + if (animationStatus === null) { + indexOfActiveFrame.current = 0; + } + + // cannot animate layers if the list is empty + if (!imageElementsData || !imageElementsData?.length) { + return; + } + + if (imageElementsData?.length && animationStatus === 'playing') { + requestAnimationFrame(showNextFrame); + } + }, [animationStatus, imageElementsData]); + + useEffect(() => { + activeFrameOnChangeRef.current = activeFrameOnChange; + }, [activeFrameOnChange]); + + useEffect(() => { + animationSpeedRef.current = animationSpeed; + }, [animationSpeed]); + + useEffect(() => { + releaseNumOfItems2ExcludeRef.current = releaseNumOfItems2Exclude; + }, [releaseNumOfItems2Exclude]); + + useEffect(() => { + // should not do anything if the animation is playing or loading + if (animationStatus !== 'pausing') { + return; + } + + // find the index of active frame using the release number + // why is this necessary? when the animation is paused, the user can click on the + // frame list card to view a frame and decide whether or not to include this frame + // in the animation + indexOfActiveFrame.current = imageElementsData.findIndex( + (d) => d.releaseNumber === releaseNumOfActiveFrame + ); + + // adjust opacity of image elements to show the one that is currently active + for (let i = 0; i < imageElementsData.length; i++) { + const opacity = i === indexOfActiveFrame.current ? 1 : 0; + imageElementsData[i].imageElement.opacity = opacity; + } + }, [animationStatus, releaseNumOfActiveFrame, imageElementsData]); +}; + +export default useMediaLayerAnimation; diff --git a/src/components/AnimationLayer/useMediaLayerImageElement.tsx b/src/components/AnimationLayer/useMediaLayerImageElement.tsx new file mode 100644 index 0000000..1bc275b --- /dev/null +++ b/src/components/AnimationLayer/useMediaLayerImageElement.tsx @@ -0,0 +1,154 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import MapView from '@arcgis/core/views/MapView'; +import { AnimationStatus } from '@store/AnimationMode/reducer'; +import React, { useEffect, useRef, useState } from 'react'; +import ImageElement from '@arcgis/core/layers/support/ImageElement'; +import ExtentAndRotationGeoreference from '@arcgis/core/layers/support/ExtentAndRotationGeoreference'; +import { IWaybackItem } from '@typings/index'; +import { getNormalizedExtent } from '@utils/snippets/getNormalizedExtent'; +import { generateAnimationFrames, FrameData } from './generateAnimationFrames'; + +export const MAP_CONTAINER_LEFT_OFFSET = 350; + +type Props = { + mapView?: MapView; + /** + * status of the animation + */ + animationStatus: AnimationStatus; + /** + * wayback items with local changes + */ + waybackItems: IWaybackItem[]; + /** + * if true, it is in process of loading wayback items + */ + isLoadingWaybackItemsWithLoalChanges: boolean; +}; + +export type ImageElementData = { + /** + * wayback release number associated with this image element + */ + releaseNumber: number; + /** + * image element to be used in the media layer + */ + imageElement: ImageElement; +}; + +export const useMediaLayerImageElement = ({ + mapView, + animationStatus, + waybackItems, + isLoadingWaybackItemsWithLoalChanges, +}: Props) => { + const [imageElements, setImageElements] = useState([]); + + const abortControllerRef = useRef(); + + // const animationStatus = useSelector(selectAnimationStatus); + + const loadFrameData = async () => { + if (!mapView) { + return; + } + + // use a new abort controller so the pending requests can be cancelled + // if user quits animation mode before the responses are returned + abortControllerRef.current = new AbortController(); + + try { + const extent = getNormalizedExtent(mapView.extent); + const width = mapView.width; + const height = mapView.height; + + const { xmin, ymin, xmax, ymax } = extent; + + const container = mapView.container as HTMLDivElement; + + const elemRect = container.getBoundingClientRect(); + // console.log(elemRect) + + const frameData = await generateAnimationFrames({ + frameRect: { + screenX: elemRect.left - MAP_CONTAINER_LEFT_OFFSET, + screenY: elemRect.top, + width, + height, + }, + mapView, + waybackItems, + abortController: abortControllerRef.current, + }); + + // once responses are received, get array of image elements using the binary data returned from export image requests + const imageElements = frameData.map((d: FrameData) => { + const imageElement = new ImageElement({ + image: URL.createObjectURL(d.frameBlob), + georeference: new ExtentAndRotationGeoreference({ + extent: { + spatialReference: { + wkid: 102100, + }, + xmin, + ymin, + xmax, + ymax, + }, + }), + opacity: 0, + }); + + return { + releaseNumber: d.releaseNum, + imageElement, + } as ImageElementData; + }); + + setImageElements(imageElements); + } catch (err) { + console.error(err); + } + }; + + useEffect(() => { + if (isLoadingWaybackItemsWithLoalChanges) { + return; + } + + if (!animationStatus || !waybackItems.length) { + // call abort so all pending requests can be cancelled + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + + // call revokeObjectURL so these image elements can be freed from the memory + if (imageElements) { + for (const elem of imageElements) { + URL.revokeObjectURL(elem.imageElement.image as string); + } + } + + setImageElements([]); + } else if (animationStatus === 'loading') { + loadFrameData(); + } + }, [animationStatus, waybackItems, isLoadingWaybackItemsWithLoalChanges]); + + return imageElements; +}; diff --git a/src/components/AnimationPanel/AnimationPanel.tsx b/src/components/AnimationPanel/AnimationPanel.tsx deleted file mode 100644 index 4e30954..0000000 --- a/src/components/AnimationPanel/AnimationPanel.tsx +++ /dev/null @@ -1,260 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useCallback, useEffect, useRef, useState } from 'react'; - -import MapView from '@arcgis/core/views/MapView'; - -import { FrameData, generateFrames } from './generateFrames4GIF'; - -import Resizable from './Resizable'; -import ImageAutoPlay from './ImageAutoPlay'; -import LoadingIndicator from './LoadingIndicator'; -import DownloadGIFDialog from './DownloadGIFDialog'; -import CloseBtn from './CloseBtn'; - -// import { whenFalse } from '@arcgis/core/core/watchUtils'; -import { IWaybackItem } from '@typings/index'; -import { useDispatch, useSelector } from 'react-redux'; - -import { - animationSpeedSelector, - // indexOfCurrentFrameChanged, - // isAnimationModeOnSelector, - isDownloadGIFDialogOnSelector, - rNum2ExcludeSelector, - // startAnimation, - toggleIsLoadingFrameData, -} from '@store/AnimationMode/reducer'; -import Background from './Background'; -import { watch } from '@arcgis/core/core/reactiveUtils'; - -type Props = { - waybackItems4Animation: IWaybackItem[]; - mapView?: MapView; -}; - -type GetFramesParams = { - waybackItems: IWaybackItem[]; - // releaseNums:number[], - container: HTMLDivElement; - mapView: MapView; -}; - -type GetFramesResponse = { - data: FrameData[]; - taskInfo: string; -}; - -type GetTaskFingerPrintParams = { - container: HTMLDivElement; - mapView: MapView; -}; - -// width of Gutter and Side Bar, need to calculate this dynamically -export const PARENT_CONTAINER_LEFT_OFFSET = 350; - -const getFrames = async ({ - waybackItems, - // releaseNums, - container, - mapView, -}: GetFramesParams): Promise => { - const taskInfo = getAnimationTaskInfo({ - container, - mapView, - }); - - const elemRect = container.getBoundingClientRect(); - // console.log(elemRect) - - const { offsetHeight, offsetWidth } = container; - // const releaseNums = waybackItems.map(d=>d.releaseNum) - - const data = await generateFrames({ - frameRect: { - screenX: elemRect.left - PARENT_CONTAINER_LEFT_OFFSET, - screenY: elemRect.top, - width: offsetWidth, - height: offsetHeight, - }, - mapView, - waybackItems, - }); - - return { - data, - taskInfo, - }; -}; - -// get a string that represent current Map (extent) and UI State (size, position of the Resize component) -// this string will be used as a finger print to check if frame data returned by getFrames match the current Map and UI state, -// sometimes multiple getFrames calls can be triggered (zoom the map after resizing the window) and we should only show the response from the last request -const getAnimationTaskInfo = ({ - container, - mapView, -}: GetTaskFingerPrintParams): string => { - if (!mapView || !container) { - return ''; - } - - const { xmax, xmin, ymax, ymin } = mapView.extent; - - const { left, top } = container.getBoundingClientRect(); - - const { offsetHeight, offsetWidth } = container; - - return [xmax, xmin, ymax, ymin, left, top, offsetHeight, offsetWidth].join( - '#' - ); -}; - -const containerRef = React.createRef(); - -const AnimationPanel: React.FC = ({ - waybackItems4Animation, - mapView, -}: Props) => { - const dispatch = useDispatch(); - - // array of frame images as dataURI string - const [frameData, setFrameData] = useState(null); - - const loadingWaybackItems4AnimationRef = useRef(false); - - const getAnimationFramesDelay = useRef(); - - const waybackItems4AnimationRef = useRef(); - - const isDownloadGIFDialogOn = useSelector(isDownloadGIFDialogOnSelector); - - // in second - const animationSpeed = useSelector(animationSpeedSelector); - - // release numbers for the frames to be excluded from animation - const rNum2Exclude = useSelector(rNum2ExcludeSelector); - - const getAnimationFrames = useCallback(() => { - // in milliseconds - const DELAY_TIME = 1500; - - clearTimeout(getAnimationFramesDelay.current); - - getAnimationFramesDelay.current = setTimeout(async () => { - try { - const waybackItems = waybackItems4AnimationRef.current; - - const container = containerRef.current; - - if ( - !waybackItems || - !waybackItems.length || - loadingWaybackItems4AnimationRef.current - ) { - return; - } - - const { data, taskInfo } = await getFrames({ - waybackItems, - container, - mapView, - }); - - if (taskInfo !== getAnimationTaskInfo({ mapView, container })) { - console.error( - "animation task info doesn't match current map or UI state, ignore frame data returned by this task" - ); - return; - } - - setFrameData(data); - } catch (err) { - console.error(err); - } - }, DELAY_TIME); - }, [waybackItems4Animation]); - - const resizableOnChange = useCallback(() => { - setFrameData(null); - - getAnimationFrames(); - }, []); - - useEffect(() => { - waybackItems4AnimationRef.current = waybackItems4Animation; - - loadingWaybackItems4AnimationRef.current = false; - - getAnimationFrames(); - }, [waybackItems4Animation]); - - useEffect(() => { - // const onUpdating = whenFalse(mapView, 'stationary', () => { - // loadingWaybackItems4AnimationRef.current = true; - // setFrameData(null); - // }); - - watch( - () => mapView.stationary, - () => { - if (!mapView.stationary) { - loadingWaybackItems4AnimationRef.current = true; - setFrameData(null); - } - } - ); - - // return () => { - // // onStationary.remove(); - // onUpdating.remove(); - // }; - }, []); - - useEffect(() => { - const isLoading = frameData === null; - dispatch(toggleIsLoadingFrameData(isLoading)); - }, [frameData]); - - return ( - <> - - - - {frameData && frameData.length ? ( - - ) : ( - - )} - - - - - {isDownloadGIFDialogOn ? ( - - ) : null} - - ); -}; - -export default AnimationPanel; diff --git a/src/components/AnimationPanel/AnimationPanelContainer.tsx b/src/components/AnimationPanel/AnimationPanelContainer.tsx deleted file mode 100644 index bd70a65..0000000 --- a/src/components/AnimationPanel/AnimationPanelContainer.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useEffect } from 'react'; - -import { useSelector } from 'react-redux'; - -import { - isAnimationModeOnSelector, - waybackItems4AnimationSelector, -} from '@store/AnimationMode/reducer'; - -// import IMapView from 'esri/views/MapView'; - -import AnimationPanel from './AnimationPanel'; -import { IWaybackItem } from '@typings/index'; -import MapView from '@arcgis/core/views/MapView'; - -type Props = { - mapView?: MapView; -}; - -const AnimationPanelContainer: React.FC = ({ mapView }: Props) => { - const isAnimationModeOn = useSelector(isAnimationModeOnSelector); - - const waybackItems4Animation: IWaybackItem[] = useSelector( - waybackItems4AnimationSelector - ); - - return isAnimationModeOn && waybackItems4Animation.length ? ( - - ) : null; -}; - -export default AnimationPanelContainer; diff --git a/src/components/AnimationPanel/Background.tsx b/src/components/AnimationPanel/Background.tsx deleted file mode 100644 index 4f1834c..0000000 --- a/src/components/AnimationPanel/Background.tsx +++ /dev/null @@ -1,59 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useCallback } from 'react'; - -const Background = () => { - // const dispatch = useDispatch(); - - // const onClickHandler = useCallback(() => { - // dispatch(toggleAnimationMode()); - // }, []); - - return ( -
- {/*
- - - -
*/} -
- ); -}; - -export default Background; diff --git a/src/components/AnimationPanel/DownloadGIFDialog.tsx b/src/components/AnimationPanel/DownloadGIFDialog.tsx deleted file mode 100644 index 0679e8c..0000000 --- a/src/components/AnimationPanel/DownloadGIFDialog.tsx +++ /dev/null @@ -1,342 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useState, useCallback, useRef } from 'react'; - -import { useDispatch } from 'react-redux'; -import { isDownloadGIFDialogOnToggled } from '@store/AnimationMode/reducer'; -import { FrameData } from './generateFrames4GIF'; - -import LoadingSpinner from './LoadingSpinner'; - -import classnames from 'classnames'; - -// import gifshot from 'gifshot'; -import GifStream from '@entryline/gifstream'; - -type Props = { - frameData: FrameData[]; - rNum2Exclude: number[]; - speed?: number; // animation speed in second -}; - -// type CreateGIFCallBack = (response: { -// // image - Base 64 image -// image: string; -// // error - Boolean that determines if an error occurred -// error: boolean; -// // errorCode - Helpful error label -// errorCode: string; -// // errorMsg - Helpful error message -// errorMsg: string; -// }) => void; - -type SaveAsGIFParams = { - frameData: FrameData[]; - // outputFileName: string; - speed: number; -}; - -type ResponseCreateGIF = { - error: string; - blob: Blob; -}; - -type ImagesCreateGIF = { - src: string; - delay: number; -}; - -const gifStream = new GifStream(); - -const donwload = (blob: Blob, fileName = ''): void => { - const url = URL.createObjectURL(blob); - - const link = document.createElement('a'); - link.download = fileName; - link.href = url; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - - URL.revokeObjectURL(url); -}; - -const saveAsGIF = async ({ - frameData, - // outputFileName, - speed, -}: SaveAsGIFParams): Promise => { - // if the speed is zero, it means user wants to have the fastest speed, so let's use 100 millisecond - speed = speed || 0.1; - - return new Promise((resolve, reject) => { - const images: ImagesCreateGIF[] = frameData.map((d) => { - const { frameCanvas, height, width, waybackItem, center } = d; - - const { releaseDateLabel } = waybackItem; - - const releaseData = `Wayback ${releaseDateLabel}`; - const sourceInfo = - 'Esri, Maxar, Earthstar Geographics, GIS Community'; - const locationInfo = `${center.latitude.toFixed( - 3 - )}, ${center.longitude.toFixed(3)}`; - const HorizontalPadding = 4; - const SpaceBetween = 4; - - const context = frameCanvas.getContext('2d'); - context.font = '10px Avenir Next'; - - const metrics4ReleaseDate = context.measureText(releaseData); - const metrics4LocationInfo = context.measureText(locationInfo); - const metrics4SourceInfo = context.measureText(sourceInfo); - - const shouldWrap = - metrics4ReleaseDate.width + - metrics4LocationInfo.width + - metrics4SourceInfo.width + - SpaceBetween * 2 + - HorizontalPadding * 2 > - width; - - // draw the gradient background rect - const gradientRectHeight = shouldWrap ? 28 : 16; - // const gradient = context.createLinearGradient(0, 0, 0, gradientRectHeight); - // gradient.addColorStop(0, "rgba(0,0,0,0)"); - // gradient.addColorStop(0.5, "rgba(0,0,0,.3)"); - // gradient.addColorStop(1, "rgba(0,0,0,.6)"); - context.fillStyle = 'rgba(0,0,0,.2)'; - context.rect( - 0, - height - gradientRectHeight, - width, - gradientRectHeight - ); - context.fill(); - - // draw the watermark text - context.shadowColor = 'black'; - context.shadowBlur = 5; - context.fillStyle = 'rgba(255,255,255,.9)'; - - if (shouldWrap) { - let y = height - 4; - const horizontalPadding = - (width - Math.ceil(metrics4SourceInfo.width)) / 2; - context.fillText(sourceInfo, horizontalPadding, y); - - y = height - 16; - context.fillText(releaseData, horizontalPadding, y); - - const xPos4LocationInfo = - width - (metrics4LocationInfo.width + horizontalPadding); - context.fillText(locationInfo, xPos4LocationInfo, y); - } else { - const y = height - 4; - - context.fillText(releaseData, HorizontalPadding, y); - - const xPos4SourceInfo = - width - (metrics4SourceInfo.width + HorizontalPadding); - context.fillText(sourceInfo, xPos4SourceInfo, y); - - let xPos4LocationInfo = - metrics4ReleaseDate.width + HorizontalPadding; - const availWidth = xPos4SourceInfo - xPos4LocationInfo; - const leftPadding4LocationInfo = - (availWidth - metrics4LocationInfo.width) / 2; - - xPos4LocationInfo = - xPos4LocationInfo + leftPadding4LocationInfo; - context.fillText(locationInfo, xPos4LocationInfo, y); - } - - return { - src: frameCanvas.toDataURL(), - delay: speed * 1000, - }; - }); - - gifStream.createGIF( - { - gifWidth: frameData[0].width, - gifHeight: frameData[0].height, - images, - progressCallback: (progress: number) => { - // console.log(progress) - }, - }, - (res: ResponseCreateGIF) => { - // this.onGifComplete(obj, width, height); - // console.log(res) - - if (res.error) { - reject(res.error); - } - - // donwload(res.blob, outputFileName) - resolve(res.blob); - } - ); - }); -}; - -const DownloadGIFDialog: React.FC = ({ - frameData, - speed, - rNum2Exclude, -}) => { - const dispatch = useDispatch(); - - const [isDownloading, setIsDownloading] = useState(false); - - const [outputFileName, setOutputFileName] = useState( - 'wayback-imagery-animation' - ); - - const isCancelled = useRef(false); - - const closeDialog = useCallback(() => { - dispatch(isDownloadGIFDialogOnToggled()); - }, []); - - const downloadBtnOnClick = async () => { - setIsDownloading(true); - - const data = !rNum2Exclude.length - ? frameData - : frameData.filter( - (d) => rNum2Exclude.indexOf(d.releaseNum) === -1 - ); - - try { - const blob = await saveAsGIF({ - frameData: data, - // outputFileName, - speed, - }); - - if (isCancelled.current) { - console.log('gif task has been cancelled'); - return; - } - - donwload(blob, outputFileName); - - closeDialog(); - } catch (err) { - console.error(err); - } - }; - - const getContent = () => { - if (isDownloading) { - return ( - <> -
-

- Generating animated GIF file... -

-

Your download will begin shortly.

-
- -
-
{ - gifStream.cancel(); - isCancelled.current = true; - closeDialog(); - }} - > - Cancel -
-
- -
- -
- - ); - } - - return ( - <> -
- Download GIF -
- -
-
- File name: - -
-
- -
-
- Cancel -
-
- Download -
-
- - ); - }; - - return ( -
-
- {getContent()} -
-
- ); -}; - -export default DownloadGIFDialog; diff --git a/src/components/AnimationPanel/ImageAutoPlay.tsx b/src/components/AnimationPanel/ImageAutoPlay.tsx deleted file mode 100644 index 2d39229..0000000 --- a/src/components/AnimationPanel/ImageAutoPlay.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useEffect, useRef, useState } from 'react'; -import { useSelector } from 'react-redux'; -import { indexOfCurrentAnimationFrameSelector } from '@store/AnimationMode/reducer'; - -import { FrameData } from './generateFrames4GIF'; - -type Props = { - frameData: FrameData[]; -}; - -const ImageAutoPlay: React.FC = ({ frameData }: Props) => { - const idx = useSelector(indexOfCurrentAnimationFrameSelector); - - // const isPlaying = useSelector(isAnimationPlayingSelector) - - const getCurrentFrame = () => { - if (!frameData || !frameData.length) { - return null; - } - - const { frameDataURI } = frameData[idx] || frameData[0]; - - return ( -
- ); - }; - - return getCurrentFrame(); -}; - -export default ImageAutoPlay; diff --git a/src/components/AnimationPanel/LoadingIndicator.tsx b/src/components/AnimationPanel/LoadingIndicator.tsx deleted file mode 100644 index 0291a57..0000000 --- a/src/components/AnimationPanel/LoadingIndicator.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React from 'react'; - -import LoadingSpinner from './LoadingSpinner'; - -const LoadingIndicator = () => { - return ( -
-
- -
- - Loading imagery -
- ); -}; - -export default LoadingIndicator; diff --git a/src/components/AnimationPanel/LoadingSpinner.css b/src/components/AnimationPanel/LoadingSpinner.css deleted file mode 100644 index b627cde..0000000 --- a/src/components/AnimationPanel/LoadingSpinner.css +++ /dev/null @@ -1,26 +0,0 @@ -@keyframes bounce { - 0% { - margin-left:-25%; - } - 50% { - margin-left:100%; - } -} - -.spinner-wrap { - position: relative; - /* top: -1px; - left: 0; */ - width: 100%; - height: 3px; - overflow: hidden; -} - -.spinner-line { - background-color: #fff; - width: 30%; - height: 100%; - margin-top: 0; - margin-left: -25%; - animation: bounce 2s infinite ease-in; -} \ No newline at end of file diff --git a/src/components/AnimationPanel/LoadingSpinner.tsx b/src/components/AnimationPanel/LoadingSpinner.tsx deleted file mode 100644 index 266166e..0000000 --- a/src/components/AnimationPanel/LoadingSpinner.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import './LoadingSpinner.css'; -import React from 'react'; - -// import styled, { keyframes } from 'styled-components'; - -// const bounce = keyframes` -// 0% { -// margin-left:-25%; -// } -// 50% { -// margin-left:100%; -// } -// `; - -// const SpinnerWrap = styled.div` -// position: relative; -// /* top: -1px; -// left: 0; */ -// width: 100%; -// height: 3px; -// overflow: hidden; -// `; - -// const SpinnerLine = styled.div` -// background-color: #fff; -// width: 30%; -// height: 100%; -// margin-top: 0; -// margin-left: -25%; -// animation: ${bounce} 2s infinite ease-in; -// `; - -const LoadingSpinner = () => { - return ( -
-
-
- ); -}; - -export default LoadingSpinner; diff --git a/src/components/AnimationPanel/Resizable.tsx b/src/components/AnimationPanel/Resizable.tsx deleted file mode 100644 index 5a0e0be..0000000 --- a/src/components/AnimationPanel/Resizable.tsx +++ /dev/null @@ -1,216 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { - useRef, - // useMemo, - useState, - useEffect, - useCallback, -} from 'react'; -import { - PREVIEW_WINDOW_HEIGHT, - PREVIEW_WINDOW_WIDTH, -} from '../PreviewWindow/PreviewWindow'; - -import { PARENT_CONTAINER_LEFT_OFFSET } from './AnimationPanel'; - -type Position = { - top: number; - left: number; -}; - -type Size = { - height: number; - width: number; -}; - -type Props = { - containerRef: React.RefObject; - children?: React.ReactNode; - // eithe size or position is updated - onChange?: () => void; -}; - -const CONTAINER_MIN_SIZE = 260; - -// const CONTAINER_DEFAULT_SIZE = 300; - -const Resizable: React.FC = ({ - containerRef, - onChange, - children, -}: Props) => { - // const containerRef = useRef(); - - // const resizeBtnRef = useRef(); - - const [position, setPosition] = useState(); - - const [size, setSize] = useState({ - width: PREVIEW_WINDOW_WIDTH, - height: PREVIEW_WINDOW_HEIGHT, - }); - - // when the container is being dragged, we keep updating it's position using current mouse position, - // by default, the top left corner of the container will be snapped to the new position, - // but this is not an ideal user experience, consider user dragging the container by holding the bottom right corner of the container, - // let's say user moves the mouse to the right by 50px, and what they want by doing that is moving the top left corner of the container by 50px, - // instead of moving the top left corner down to the current mouse position - // therefore we need to know/save the offset between container's top left corner and mouse position when the drag event is started, - // and we will use it when calculate the new position for the container - const positionOffset = useRef(null); - - const mouseOnMoveHandler = useCallback((evt: MouseEvent) => { - const { clientX, clientY } = evt; - - const { offsetLeft, offsetTop, offsetHeight, offsetWidth } = - containerRef.current; - - if (!positionOffset.current) { - positionOffset.current = { - top: clientY - offsetTop, - left: clientX - offsetLeft, - }; - } - - let left = clientX - positionOffset.current.left; - - if (left < 0) { - left = 0; - } - - // reach to the right end of view port - if ( - left + PARENT_CONTAINER_LEFT_OFFSET + offsetWidth >= - window.innerWidth - ) { - left = - window.innerWidth - offsetWidth - PARENT_CONTAINER_LEFT_OFFSET; - } - - let top = clientY - positionOffset.current.top; - - if (top < 0) { - top = 0; - } else if (top + offsetHeight > window.innerHeight) { - top = window.innerHeight - offsetHeight; - } - - setPosition({ - top, - left, - }); - - onChange(); - }, []); - - const resize = useCallback((evt: MouseEvent) => { - // console.log('resizing') - - const { clientX, clientY } = evt; - - const { offsetLeft, offsetTop } = containerRef.current; - - const newWidth = clientX - PARENT_CONTAINER_LEFT_OFFSET - offsetLeft; - const newHeight = clientY - offsetTop; - - setSize({ - width: - newWidth >= CONTAINER_MIN_SIZE ? newWidth : CONTAINER_MIN_SIZE, - height: - newHeight >= CONTAINER_MIN_SIZE - ? newHeight - : CONTAINER_MIN_SIZE, - }); - - onChange(); - }, []); - - const addUpdatePositionHanlder = useCallback( - (evt: React.MouseEvent) => { - window.addEventListener('mousemove', mouseOnMoveHandler); - }, - [] - ); - - const removeUpdatePositionHanlder = useCallback(() => { - positionOffset.current = null; - window.removeEventListener('mousemove', mouseOnMoveHandler); - }, []); - - const addResizeHandler = useCallback((evt: any) => { - evt.stopPropagation(); - window.addEventListener('mousemove', resize); - }, []); - - const removeResizeHandler = useCallback((evt: any) => { - window.removeEventListener('mousemove', resize); - }, []); - - useEffect(() => { - window.addEventListener('mouseup', removeUpdatePositionHanlder); - window.addEventListener('mouseup', removeResizeHandler); - - return () => { - window.removeEventListener('mouseup', removeUpdatePositionHanlder); - window.removeEventListener('mouseup', removeResizeHandler); - }; - }, []); - - return ( -
- {children} - -
-
- ); -}; - -export default Resizable; diff --git a/src/components/AppLayout/AppLayout.tsx b/src/components/AppLayout/AppLayout.tsx index 8165cbe..28c6934 100644 --- a/src/components/AppLayout/AppLayout.tsx +++ b/src/components/AppLayout/AppLayout.tsx @@ -43,7 +43,7 @@ import { TilePreviewWindow, // Title4ActiveItem, WaybackLayer, - AnimationPanel, + // AnimationPanel, AnimationModeToggleBtn, ZoomWidget, OpenDownloadPanelBtn, @@ -53,12 +53,16 @@ import { import { getServiceUrl } from '@utils/Tier'; import useCurrenPageBecomesVisible from '@hooks/useCurrenPageBecomesVisible'; import { revalidateToken } from '@utils/Esri-OAuth'; +import { AnimationLayer } from '@components/AnimationLayer/AnimationLayer'; +import { useSaveAppState2URLHashParams } from '@hooks/useSaveAppState2URLHashParams'; const AppLayout: React.FC = () => { // const { onPremises } = React.useContext(AppContext); const currentPageIsVisibleAgain = useCurrenPageBecomesVisible(); + useSaveAppState2URLHashParams(); + useEffect(() => { if (!currentPageIsVisibleAgain) { return; @@ -102,7 +106,8 @@ const AppLayout: React.FC = () => { - + {/* */} + diff --git a/src/components/CloseButton/CloseButton.css b/src/components/CloseButton/CloseButton.css new file mode 100644 index 0000000..84b5cff --- /dev/null +++ b/src/components/CloseButton/CloseButton.css @@ -0,0 +1,5 @@ +.close-button::before { + @apply absolute top-0 right-0 w-52 h-52 pointer-events-none; + content: ' '; + background: linear-gradient(215deg, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 40%); +} \ No newline at end of file diff --git a/src/components/AnimationPanel/CloseBtn.tsx b/src/components/CloseButton/CloseButton.tsx similarity index 61% rename from src/components/AnimationPanel/CloseBtn.tsx rename to src/components/CloseButton/CloseButton.tsx index a6ad793..c0548d7 100644 --- a/src/components/AnimationPanel/CloseBtn.tsx +++ b/src/components/CloseButton/CloseButton.tsx @@ -13,44 +13,30 @@ * limitations under the License. */ -import React, { useCallback } from 'react'; +import './CloseButton.css'; +import React, { FC } from 'react'; -import { useDispatch } from 'react-redux'; -import { toggleAnimationMode } from '@store/AnimationMode/reducer'; - -const CloseBtn = () => { - const dispatch = useDispatch(); - - const onClickHandler = useCallback(() => { - dispatch(toggleAnimationMode()); - }, []); +type Props = { + onClick: () => void; +}; +export const CloseButton: FC = ({ onClick }: Props) => { return ( -
+
); }; - -export default CloseBtn; diff --git a/src/components/ModalAboutApp/config.ts b/src/components/CloseButton/index.ts similarity index 92% rename from src/components/ModalAboutApp/config.ts rename to src/components/CloseButton/index.ts index 5e57314..0507326 100644 --- a/src/components/ModalAboutApp/config.ts +++ b/src/components/CloseButton/index.ts @@ -13,6 +13,4 @@ * limitations under the License. */ -export default { - 'modal-id': 'about', -}; +export { CloseButton } from './CloseButton'; diff --git a/src/components/Gutter/index.tsx b/src/components/Gutter/index.tsx index 52223b1..aeef179 100644 --- a/src/components/Gutter/index.tsx +++ b/src/components/Gutter/index.tsx @@ -50,7 +50,7 @@ export const Gutter: FC = ({ > {/* gradient effect on right side of gutter */}
= ({ }, 3000); }} > - +
{ - constructor(props: IProps) { - super(props); +export const ListViewCard: FC = ({ + data, + isActive, + isSelected, + isHighlighted, + shouldDownloadButtonBeDisabled, + downloadButtonTooltipText, + onClick, + onMouseEnter, + onMouseOut, + toggleSelect, + downloadButtonOnClick, +}: Props) => { + const { isMobile } = useContext(AppContext); - this.openItem = this.openItem.bind(this); - // this.showTooltip = this.showTooltip.bind(this); - this.hideTooltip = this.hideTooltip.bind(this); - } - - openItem() { - const { data } = this.props; + const showControlButtons = isActive || isSelected; + const openItem = () => { const itemId = data.itemID; const agolHost = getServiceUrl('portal-url'); @@ -66,127 +73,246 @@ class ListViewCard extends React.PureComponent { const itemUrl = `${agolHost}/home/item.html?id=${itemId}`; window.open(itemUrl, '_blank'); - } - - // showTooltip(evt: React.MouseEvent) { - // const { toggleTooltip } = this.props; - // const boundingRect = evt.currentTarget.getBoundingClientRect(); - - // toggleTooltip({ - // content: evt.currentTarget.getAttribute('data-tooltip-content'), - // left: boundingRect.left + evt.currentTarget.clientWidth + 5, - // top: boundingRect.top + 3, - // }); - // } - - hideTooltip() { - const { toggleTooltip } = this.props; - toggleTooltip(); - } - - render() { - const { - data, - isActive, - isSelected, - isHighlighted, - shouldDownloadButtonBeDisabled, - downloadButtonTooltipText, - onClick, - onMouseEnter, - onMouseOut, - toggleSelect, - downloadButtonOnClick, - } = this.props; - - const showControlButtons = isActive || isSelected; - - return ( + }; + + return ( +
+ {/*
*/}
- ); - } -} +
+ ); +}; + +// class ListViewCard extends React.PureComponent { +// constructor(props: IProps) { +// super(props); + +// this.openItem = this.openItem.bind(this); +// // this.showTooltip = this.showTooltip.bind(this); +// this.hideTooltip = this.hideTooltip.bind(this); +// } + +// openItem() { +// const { data } = this.props; + +// const itemId = data.itemID; + +// const agolHost = getServiceUrl('portal-url'); + +// const itemUrl = `${agolHost}/home/item.html?id=${itemId}`; + +// window.open(itemUrl, '_blank'); +// } + +// // showTooltip(evt: React.MouseEvent) { +// // const { toggleTooltip } = this.props; +// // const boundingRect = evt.currentTarget.getBoundingClientRect(); + +// // toggleTooltip({ +// // content: evt.currentTarget.getAttribute('data-tooltip-content'), +// // left: boundingRect.left + evt.currentTarget.clientWidth + 5, +// // top: boundingRect.top + 3, +// // }); +// // } + +// hideTooltip() { +// const { toggleTooltip } = this.props; +// toggleTooltip(); +// } + +// render() { +// const { +// data, +// isActive, +// isSelected, +// isHighlighted, +// shouldDownloadButtonBeDisabled, +// downloadButtonTooltipText, +// onClick, +// onMouseEnter, +// onMouseOut, +// toggleSelect, +// downloadButtonOnClick, +// } = this.props; + +// const showControlButtons = isActive || isSelected; + +// return ( +//
+//
+// + +//
+// +//
+ +//
{ +// if (shouldDownloadButtonBeDisabled) { +// return; +// } + +// downloadButtonOnClick(data.releaseNum); +// }} +// title={downloadButtonTooltipText} +// > +// +//
+ +//
+// +//
+ +// {/*
*/} +//
+//
+// ); +// } +// } -export default ListViewCard; +// export default ListViewCard; diff --git a/src/components/ListView/ListViewContainer.tsx b/src/components/ListView/ListViewContainer.tsx index 8a10e61..f04df01 100644 --- a/src/components/ListView/ListViewContainer.tsx +++ b/src/components/ListView/ListViewContainer.tsx @@ -51,7 +51,7 @@ type Props = { const ListViewWrapper: React.FC = ({ children }) => { return (
{ return ( <> -
{cards}
+
+ {cards} +
{staticTooltip} ); diff --git a/src/components/MapView/CustomMapViewStyle.css b/src/components/MapView/CustomMapViewStyle.css new file mode 100644 index 0000000..07627f3 --- /dev/null +++ b/src/components/MapView/CustomMapViewStyle.css @@ -0,0 +1,11 @@ +.hide-map-control .esri-zoom, +.hide-map-control .esri-search { + display: none; +} + +.esri-input, +.esri-widget--button, +.esri-menu { + @apply bg-custom-background; + /* color: var(--custom-light-blue); */ +} \ No newline at end of file diff --git a/src/components/MapView/MapViewConatiner.tsx b/src/components/MapView/MapViewConatiner.tsx index fa20ff2..60f6079 100644 --- a/src/components/MapView/MapViewConatiner.tsx +++ b/src/components/MapView/MapViewConatiner.tsx @@ -12,7 +12,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - +import './CustomMapViewStyle.css'; import React, { useContext, useEffect, useMemo } from 'react'; import { useSelector, useDispatch } from 'react-redux'; @@ -27,6 +27,7 @@ import { } from '@store/Map/reducer'; import { + isLoadingWaybackItemsToggled, // activeWaybackItemSelector, releaseNum4ItemsWithLocalChangesUpdated, // previewWaybackItemSelector @@ -43,6 +44,12 @@ import { } from '@utils/UrlSearchParam'; import { batch } from 'react-redux'; import { getWaybackItemsWithLocalChanges } from '@vannizhang/wayback-core'; +import { + isAnimationModeOnSelector, + selectAnimationStatus, +} from '@store/AnimationMode/reducer'; +import { queryLocalChanges } from '@store/Wayback/thunks'; +import { Point } from '@arcgis/core/geometry'; type Props = { children?: React.ReactNode; @@ -75,6 +82,8 @@ const MapViewConatiner: React.FC = ({ children }) => { const mapExtent = useSelector(mapExtentSelector); + const isAnimationModeOn = useSelector(isAnimationModeOnSelector); + const { center, zoom } = useSelector(selectMapCenterAndZoom); const defaultMapExtent = useMemo((): IExtentGeomety => { @@ -90,19 +99,14 @@ const MapViewConatiner: React.FC = ({ children }) => { mapCenterPoint: IMapPointInfo ) => { try { - const waybackItems = await getWaybackItemsWithLocalChanges( - { - longitude: mapCenterPoint.longitude, - latitude: mapCenterPoint.latitude, - }, - mapCenterPoint.zoom - ); - console.log(waybackItems); - - const rNums = waybackItems.map((d) => d.releaseNum); - - // console.log(rNums); - dispatch(releaseNum4ItemsWithLocalChangesUpdated(rNums)); + const { longitude, latitude, zoom } = mapCenterPoint; + + const point = new Point({ + longitude, + latitude, + }); + + dispatch(queryLocalChanges(point, zoom)); } catch (err) { console.error('failed to query local changes', err); } @@ -124,6 +128,11 @@ const MapViewConatiner: React.FC = ({ children }) => { saveMapCenterToHashParams(center, zoom); }, [center, zoom]); + useEffect(() => { + // adding this class will hide map zoom widget when animation mode is on + document.body.classList.toggle('hide-map-control', isAnimationModeOn); + }, [isAnimationModeOn]); + return ( void; metadataOnChange: (data: IWaybackMetadataQueryResult) => void; anchorPointOnChange: (data: IScreenPoint) => void; }; @@ -50,6 +50,7 @@ const MetadataQueryLayer: React.FC = ({ isSwipeWidgetOpen, swipeWidgetPosition, mapView, + metadataQueryOnStart, metadataOnChange, anchorPointOnChange, }) => { @@ -88,28 +89,34 @@ const MetadataQueryLayer: React.FC = ({ // zoom: mapView.zoom, // getCurrZoomLevel(mapView) // }); + updateScreenPoint4PopupAnchor(); + + metadataQueryOnStart(); + + const queryLocation = { + latitude: mapPoint.latitude, + longitude: mapPoint.longitude, + }; + const res = await getMetadata( - { - latitude: mapPoint.latitude, - longitude: mapPoint.longitude, - }, + queryLocation, mapView.zoom, // getCurrZoomLevel(mapView) releaseNum ); - console.log(res); + // console.log(res); const metadata: IWaybackMetadataQueryResult = res ? { ...res, releaseDate: releaseDateLabel, + queryLocation, } : null; - updateScreenPoint4PopupAnchor(); - metadataOnChange(metadata); } catch (err) { console.error(err); + metadataOnChange(null); } }; diff --git a/src/components/MetadataQueryTask/MetadataQueryTaskContainer.tsx b/src/components/MetadataQueryTask/MetadataQueryTaskContainer.tsx index 9d64ae0..0994c79 100644 --- a/src/components/MetadataQueryTask/MetadataQueryTaskContainer.tsx +++ b/src/components/MetadataQueryTask/MetadataQueryTaskContainer.tsx @@ -29,6 +29,7 @@ import { import { metadataQueryResultUpdated, metadataPopupAnchorUpdated, + isQueryingMetadataToggled, } from '@store/Map/reducer'; import MetadataQueryTask from './MetadataQueryTask'; @@ -62,6 +63,9 @@ const MetadataQueryTaskContainer: React.FC = ({ mapView }: Props) => { swipeWidgetTrailingLayer={swipeWidgetTrailingLayer} isSwipeWidgetOpen={isSwipeWidgetOpen} swipeWidgetPosition={swipeWidgetPosition} + metadataQueryOnStart={() => { + disptach(isQueryingMetadataToggled(true)); + }} metadataOnChange={(metadata) => { // console.log(metadata) disptach(metadataQueryResultUpdated(metadata)); diff --git a/src/components/MobileFooter/MobileFooter.tsx b/src/components/MobileFooter/MobileFooter.tsx index 7e9918b..921d783 100644 --- a/src/components/MobileFooter/MobileFooter.tsx +++ b/src/components/MobileFooter/MobileFooter.tsx @@ -14,7 +14,7 @@ */ import React from 'react'; -import { DEFAULT_BACKGROUND_COLOR, GUTTER_WIDTH } from '@constants/UI'; +import { GUTTER_WIDTH } from '@constants/UI'; import { MobileShow } from '../MobileVisibility'; import Title4ActiveItem from '../Title4ActiveItem/Title4ActiveItemContainer'; @@ -28,25 +28,27 @@ const MobileFooter: React.FC = ({ isGutterHide, OnClick }: Props) => { return (
- +
+ +
{ const isGutterHide = useSelector(isGutterHideSelector); const isSideBarHide = useSelector(isSideBarHideSelector); - return isSideBarHide ? ( + if (!isSideBarHide) { + return null; + } + + return ( { dispatch(isSideBarHideToggled()); }} /> - ) : null; + ); }; export default MobileFooterContainer; diff --git a/src/components/MobileHeader/index.tsx b/src/components/MobileHeader/index.tsx index 71f2dd9..0fec5d4 100644 --- a/src/components/MobileHeader/index.tsx +++ b/src/components/MobileHeader/index.tsx @@ -57,7 +57,7 @@ class TitleText extends React.PureComponent { return (
{leftNavBtnIcon} diff --git a/src/components/Modal/Modal.tsx b/src/components/Modal/Modal.tsx new file mode 100644 index 0000000..3aa149f --- /dev/null +++ b/src/components/Modal/Modal.tsx @@ -0,0 +1,68 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { FC } from 'react'; +import classnames from 'classnames'; + +type Props = { + isOpen: boolean; + width?: 's' | 'm' | 'l'; + onClose: () => void; + children?: React.ReactNode; +}; + +export const Modal: FC = ({ + isOpen, + width = 'm', + children, + onClose, +}) => { + if (!isOpen) { + return null; + } + return ( +
+
+
+ +
+ +
+ {children} +
+
+
+ ); +}; diff --git a/src/components/ModalAboutApp/AboutThisAppContainer.tsx b/src/components/ModalAboutApp/AboutThisAppContainer.tsx index d91fdaa..10e8362 100644 --- a/src/components/ModalAboutApp/AboutThisAppContainer.tsx +++ b/src/components/ModalAboutApp/AboutThisAppContainer.tsx @@ -22,7 +22,8 @@ import { isAboutThisAppModalOpenToggled, } from '@store/UI/reducer'; -import AboutThisApp from './index'; +import { AboutThiAppContent } from './AboutThisAppContent'; +import { Modal } from '@components/Modal/Modal'; const AboutThisAppContainer = () => { const isOpen = useSelector(isAboutThisAppModalOpenSelector); @@ -33,7 +34,13 @@ const AboutThisAppContainer = () => { dispatch(isAboutThisAppModalOpenToggled()); }; - return isOpen ? : null; + return ( + + + + ); + + // return isOpen ? : null; }; export default AboutThisAppContainer; diff --git a/src/components/ModalAboutApp/AboutThisAppContent.tsx b/src/components/ModalAboutApp/AboutThisAppContent.tsx new file mode 100644 index 0000000..de15db0 --- /dev/null +++ b/src/components/ModalAboutApp/AboutThisAppContent.tsx @@ -0,0 +1,96 @@ +/* Copyright 2024 Esri + * + * Licensed under the Apache License Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import React, { useContext } from 'react'; +import { AppContext } from '@contexts/AppContextProvider'; + +// interface IProps { +// onClose: () => void; +// } +// interface IState {} + +export const AboutThiAppContent: React.FC = () => { + const { onPremises } = useContext(AppContext); + + const githubRepoInfo = ( + <> + The source code for this app is available on{' '} + + Github + + . + + ); + + return ( + <> +

World Imagery Wayback

+
WHAT
+

+ Wayback is a digital archive, providing users with access to the + different versions of{' '} + + World Imagery + {' '} + created over time. Each layer in the archive represents a + snapshot of the entire World Imagery map, as it existed on the + date it was published. Wayback currently provides access to all + published versions of World Imagery, dating back to February 20, + 2014. There is an ArcGIS Online item for every version which can + be accessed directly from this app or within the{' '} + + Wayback Imagery group + + . {!onPremises ? githubRepoInfo : null} +

+ +
WHY
+

+ As World Imagery is updated with more current imagery, new + versions of the map are published. When and where updates occur, + the previous imagery is replaced and is no longer visible. For + many use cases, the new imagery is more desirable and typically + preferred. Other times, however, the previous imagery may + support use cases that the new imagery does not. In these cases, + a user may need to access a previous version of World Imagery. +

+ +
HOW
+

+ Available versions of the World Imagery map are presented within + a timeline and as layers in a list. Versions that resulted in + local changes are highlighted in bold white, and the layer + currently selected is highlighted in blue. Point and click on + the map for additional imagery details within the selected + layer. One or more layers can be added to a queue and pushed to + a new ArcGIS Online web map. +

+ + ); +}; + +// export default About; diff --git a/src/components/ModalAboutApp/index.tsx b/src/components/ModalAboutApp/index.tsx deleted file mode 100644 index bcdbb15..0000000 --- a/src/components/ModalAboutApp/index.tsx +++ /dev/null @@ -1,125 +0,0 @@ -/* Copyright 2024 Esri - * - * Licensed under the Apache License Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import React, { useContext } from 'react'; -import { AppContext } from '@contexts/AppContextProvider'; - -interface IProps { - onClose: () => void; -} -// interface IState {} - -const About: React.FC = ({ onClose }: IProps) => { - const { onPremises } = useContext(AppContext); - - const githubRepoInfo = ( - <> - The source code for this app is available on{' '} - - Github - - . - - ); - - return ( -
-
- - - - - - -

- World Imagery Wayback -

-
WHAT
-

- Wayback is a digital archive, providing users with access to - the different versions of{' '} - - World Imagery - {' '} - created over time. Each layer in the archive represents a - snapshot of the entire World Imagery map, as it existed on - the date it was published. Wayback currently provides access - to all published versions of World Imagery, dating back to - February 20, 2014. There is an ArcGIS Online item for every - version which can be accessed directly from this app or - within the{' '} - - Wayback Imagery group - - . {!onPremises ? githubRepoInfo : null} -

- -
WHY
-

- As World Imagery is updated with more current imagery, new - versions of the map are published. When and where updates - occur, the previous imagery is replaced and is no longer - visible. For many use cases, the new imagery is more - desirable and typically preferred. Other times, however, the - previous imagery may support use cases that the new imagery - does not. In these cases, a user may need to access a - previous version of World Imagery. -

- -
HOW
-

- Available versions of the World Imagery map are presented - within a timeline and as layers in a list. Versions that - resulted in local changes are highlighted in bold white, and - the layer currently selected is highlighted in blue. Point - and click on the map for additional imagery details within - the selected layer. One or more layers can be added to a - queue and pushed to a new ArcGIS Online web map. -

-
-
- ); -}; - -export default About; diff --git a/src/components/PopUp/MetadataPopupContainer.tsx b/src/components/PopUp/MetadataPopupContainer.tsx index 3bf911b..a3f6c95 100644 --- a/src/components/PopUp/MetadataPopupContainer.tsx +++ b/src/components/PopUp/MetadataPopupContainer.tsx @@ -22,6 +22,7 @@ import { metadataPopupAnchorSelector, metadataQueryResultSelector, metadataQueryResultUpdated, + selectIsQueringMetadata, } from '@store/Map/reducer'; import MetadataPopUp from './index'; @@ -33,17 +34,24 @@ const MetadataPopupContainer = () => { const anchorPoint = useSelector(metadataPopupAnchorSelector); + const isQueryingMetadata = useSelector(selectIsQueringMetadata); + const isAnimationModeOn = useSelector(isAnimationModeOnSelector); - return !isAnimationModeOn ? ( + if (isAnimationModeOn) { + return null; + } + + return ( { dispatch(metadataQueryResultUpdated(null)); }} /> - ) : null; + ); }; export default MetadataPopupContainer; diff --git a/src/components/PopUp/index.tsx b/src/components/PopUp/index.tsx index 9490bcc..1d18543 100644 --- a/src/components/PopUp/index.tsx +++ b/src/components/PopUp/index.tsx @@ -15,7 +15,7 @@ // import { loadModules } from 'esri-loader'; import './style.css'; -import React from 'react'; +import React, { FC } from 'react'; import { dateFns } from 'helper-toolkit-ts'; import { @@ -25,21 +25,24 @@ import { } from '@typings/index'; interface IProps { + /** + * if true, it is in process of querying metadata + */ + isQueryingMetadata: boolean; metadata: IWaybackMetadataQueryResult; metadataAnchorScreenPoint: IScreenPoint; onClose: () => void; } -class PopUp extends React.PureComponent { - private readonly Width = 360; - private readonly PositionOffset = 22.5; +const Width = 360; +const PositionOffset = 22.5; - constructor(props: IProps) { - super(props); - } +const PopUp: FC = (props: IProps) => { + const { metadata, isQueryingMetadata, metadataAnchorScreenPoint, onClose } = + props; - formatMetadataDate() { - const { metadata } = this.props; + const formatMetadataDate = () => { + const { metadata } = props; const { date } = metadata; const metadataDate = new Date(date); @@ -49,76 +52,113 @@ class PopUp extends React.PureComponent { const day = metadataDate.getDate(); return `${month} ${day}, ${year}`; + }; + + const copyQueryLocation = () => { + const { queryLocation } = metadata; + + const text = `x: ${queryLocation.longitude.toFixed( + 5 + )} y:${queryLocation.latitude.toFixed(5)}`; + navigator.clipboard.writeText(text); + }; + + if (!metadataAnchorScreenPoint) { + return null; } - render() { - // const { targetLayer } = this.props; - - const { metadata, metadataAnchorScreenPoint } = this.props; - - if (!metadata || !metadataAnchorScreenPoint) { - return null; - } - - const containerStyle = { - position: 'absolute', - top: metadataAnchorScreenPoint.y - this.PositionOffset, - left: metadataAnchorScreenPoint.x - this.PositionOffset, - width: this.Width, - } as React.CSSProperties; - - const { provider, source, resolution, accuracy, releaseDate, date } = - metadata; - - // const releaseDate = 'targetLayer.releaseDateLabel'; - const formattedDate = this.formatMetadataDate(); - - const providerAndCaptureDateInfo = date ? ( - - {provider} ({source}) image captured on {formattedDate}{' '} - as shown in the {releaseDate} version of the World - Imagery map. - - ) : ( - - {provider} ({source}) imagery as shown in the{' '} - {releaseDate} version of the World Imagery map. - - ); + if (!metadata && !isQueryingMetadata) { + return null; + } + const containerStyle = { + position: 'absolute', + top: metadataAnchorScreenPoint.y - PositionOffset, + left: metadataAnchorScreenPoint.x - PositionOffset, + width: Width, + } as React.CSSProperties; + + if (isQueryingMetadata) { return (
-
-

- {providerAndCaptureDateInfo} -

-

- Resolution: Pixels in the source image -
- represent a ground distance of{' '} - {+resolution.toFixed(2)} meters. -

-

- Accuracy: Objects displayed in this image -
- are within {+accuracy.toFixed(2)} meters of - true location. -

-
- -
- -
+
); } -} + + const { + provider, + source, + resolution, + accuracy, + releaseDate, + date, + queryLocation, + } = metadata; + + // const releaseDate = 'targetLayer.releaseDateLabel'; + const formattedDate = formatMetadataDate(); + + const providerAndCaptureDateInfo = date ? ( + + {provider} ({source}) image captured on {formattedDate} as + shown in the {releaseDate} version of the World Imagery map. + + ) : ( + + {provider} ({source}) imagery as shown in the {releaseDate}{' '} + version of the World Imagery map. + + ); + + return ( +
+
+ +
+
+

{providerAndCaptureDateInfo}

+

+ Resolution: Pixels in the source image +
+ represent a ground distance of{' '} + {+resolution.toFixed(2)} meters. +

+

+ Accuracy: Objects displayed in this image +
+ are within {+accuracy.toFixed(2)} meters of true + location. +

+ +

+ x: {queryLocation.longitude.toFixed(4)} + {' '}y: {queryLocation.latitude.toFixed(4)} +

+
+ +
+ {/* */} + +
+
+
+ ); +}; export default PopUp; diff --git a/src/components/PopUp/style.css b/src/components/PopUp/style.css index 7a9340e..c268333 100644 --- a/src/components/PopUp/style.css +++ b/src/components/PopUp/style.css @@ -30,9 +30,9 @@ .popup-container .content-wrap .close-btn { position: absolute; - top: 3px; - right: 10px; - width: 10px; + top: 5px; + right: .25rem; + /* width: 10px; */ font-size: 0.75rem; cursor: pointer; } diff --git a/src/components/ReferenceLayerToggle/ReferenceLayerToggle.tsx b/src/components/ReferenceLayerToggle/ReferenceLayerToggle.tsx index 45a094c..eb09b0e 100644 --- a/src/components/ReferenceLayerToggle/ReferenceLayerToggle.tsx +++ b/src/components/ReferenceLayerToggle/ReferenceLayerToggle.tsx @@ -52,11 +52,8 @@ class ReferenceLayerToggle extends React.PureComponent { ); return ( -
-
+
+
{icon}
reference label overlay
diff --git a/src/components/ReferenceLayerToggle/style.css b/src/components/ReferenceLayerToggle/style.css index a15494a..d742f58 100644 --- a/src/components/ReferenceLayerToggle/style.css +++ b/src/components/ReferenceLayerToggle/style.css @@ -5,11 +5,10 @@ width: 240px; height: 32px; line-height: 32px; - background: #242424; + /* background: #242424; */ z-index: 0; font-size: 0.8125rem; - color: #d1d1d1; - + /* color: #d1d1d1; */ display:flex; align-items:center; } diff --git a/src/components/SaveAsWebmapDialog/SaveAsWebmapDialogContainer.tsx b/src/components/SaveAsWebmapDialog/SaveAsWebmapDialogContainer.tsx index c8f5bbb..498604f 100644 --- a/src/components/SaveAsWebmapDialog/SaveAsWebmapDialogContainer.tsx +++ b/src/components/SaveAsWebmapDialog/SaveAsWebmapDialogContainer.tsx @@ -40,6 +40,7 @@ import { isAnonymouns, signInUsingDifferentAccount, } from '@utils/Esri-OAuth'; +import { Modal } from '@components/Modal/Modal'; const SaveAsWebmapDialogContainer = () => { const dispatch = useDispatch(); @@ -64,21 +65,39 @@ const SaveAsWebmapDialogContainer = () => { // console.log(isOpen); // }, [isOpen]); - return isOpen ? ( - { - signInUsingDifferentAccount(); - }} - /> - ) : null; + return ( + + { + signInUsingDifferentAccount(); + }} + /> + + ); + + // return isOpen ? ( + // { + // signInUsingDifferentAccount(); + // }} + // /> + // ) : null; }; export default SaveAsWebmapDialogContainer; diff --git a/src/components/SaveAsWebmapDialog/config.ts b/src/components/SaveAsWebmapDialog/config.ts index ed6d427..47a121a 100644 --- a/src/components/SaveAsWebmapDialog/config.ts +++ b/src/components/SaveAsWebmapDialog/config.ts @@ -14,7 +14,7 @@ */ export default { - 'modal-id': 'save-as-webmap-dialog', + // 'modal-id': 'save-as-webmap-dialog', title: 'Custom Wayback Imagery Web Map', tags: 'wayback', description: diff --git a/src/components/SaveAsWebmapDialog/index.tsx b/src/components/SaveAsWebmapDialog/index.tsx index 50e1df0..b7e69eb 100644 --- a/src/components/SaveAsWebmapDialog/index.tsx +++ b/src/components/SaveAsWebmapDialog/index.tsx @@ -39,7 +39,7 @@ interface IProps { portalBaseURL: string; mapExtent: IExtentGeomety; - onClose: (val: boolean) => void; + // onClose: (val: boolean) => void; signInButtonOnClick: () => void; } @@ -195,41 +195,49 @@ class SaveAsWebmapDialog extends React.PureComponent { } = this.state; const creatingIndicator = isCreatingWebmap ? ( - + Creating Web Map... ) : null; - const creatWebMapBtnClasses = classnames('btn upload-webmap-btn', { - 'btn-disabled': isRequiredFieldMissing, + const creatWebMapBtnClasses = classnames({ + disabled: isRequiredFieldMissing, }); const creatWebMapBtn = !isCreatingWebmap ? (
- Create Wayback Map + Create Wayback Map
) : null; return (
-
Wayback Map Settings:
-