diff --git a/CHANGELOG.md b/CHANGELOG.md index 5be33f04..582f3963 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add Parcel Layer [#50](https://github.com/azavea/iow-boundary-tool/pull/50) - Add distorable reference image layer [#46](https://github.com/azavea/iow-boundary-tool/pull/46) - Add Land & water basemap [#48](https://github.com/azavea/iow-boundary-tool/pull/48) +- Add user reference image upload [#60](https://github.com/azavea/iow-boundary-tool/pull/60) ### Changed diff --git a/src/app/src/components/Layers/ReferenceImageLayer.js b/src/app/src/components/Layers/ReferenceImageLayer.js index 1412bd4c..45cf3aa2 100644 --- a/src/app/src/components/Layers/ReferenceImageLayer.js +++ b/src/app/src/components/Layers/ReferenceImageLayer.js @@ -1,12 +1,11 @@ -import { useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import L from './L.DistortableImage.Edit.fix'; -import ReferenceImage from '../../img/raleigh_sanborn_map.jpg'; -import { useMapLayer } from '../../hooks'; import { customizePrototypeIcon } from '../../utils'; import { updateReferenceImage } from '../../store/mapSlice'; +import { useMap } from 'react-leaflet'; customizePrototypeIcon(L.DistortHandle.prototype, 'ref-handle'); customizePrototypeIcon(L.DragHandle.prototype, 'ref-handle'); @@ -20,21 +19,26 @@ const convertCornerFromStateFormat = corner => ({ lng: corner[1], }); -export default function ReferenceImageLayerVisbilityWrapper() { - const showLayer = useSelector(state => state.map.referenceImage.visible); - - return showLayer ? : null; -} - -function ReferenceImageLayer() { +export default function ReferenceImageLayer() { const dispatch = useDispatch(); + const map = useMap(); + const referenceImageLayers = useRef({}); - // TODO: Find a way to initialize the image with transparent/outlined enabled - const { corners, mode } = useSelector(state => state.map.referenceImage); + const images = useSelector(state => state.map.referenceImages); - const layer = useMemo( - () => { - const layer = new L.distortableImageOverlay(ReferenceImage, { + const visibleImages = useMemo( + () => + Object.fromEntries( + Object.entries(images).filter( + ([, imageInfo]) => imageInfo.visible + ) + ), + [images] + ); + + const createLayer = useCallback( + ({ url, corners, mode }) => { + const layer = new L.distortableImageOverlay(url, { actions: [ L.DragAction, L.ScaleAction, @@ -55,9 +59,14 @@ function ReferenceImageLayer() { const updateImageHandler = ({ target: layer }) => { dispatch( updateReferenceImage({ - corners: layer._corners.map(convertCornerToStateFormat), - mode: layer.editing._mode, - transparent: layer.editing._transparent, + url, + update: { + corners: layer._corners.map( + convertCornerToStateFormat + ), + mode: layer.editing._mode, + transparent: layer.editing._transparent, + }, }) ); }; @@ -78,9 +87,12 @@ function ReferenceImageLayer() { if (layer._corners) { dispatch( updateReferenceImage({ - corners: layer._corners.map( - convertCornerToStateFormat - ), + url, + update: { + corners: layer._corners.map( + convertCornerToStateFormat + ), + }, }) ); } @@ -89,14 +101,41 @@ function ReferenceImageLayer() { return layer; }, - // TODO: Figure out how to prevent deselect on re-render - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - // corners, - // mode, - dispatch, - ] + [dispatch] ); - useMapLayer(layer); + /** + * Since the reference image layers are stored in a mutable ref, + * they aren't updated when the redux state changes. This useEffect + * hook manually performs those updates by listening to changes of + * visibleImages. + */ + useEffect(() => { + const imageShouldBeAdded = url => + !(url in referenceImageLayers.current); + + const imageShouldBeHidden = url => !(url in visibleImages); + + for (const [url, { corners, mode }] of Object.entries(visibleImages)) { + if (imageShouldBeAdded(url)) { + referenceImageLayers.current[url] = createLayer({ + url, + corners, + mode, + }); + + map.addLayer(referenceImageLayers.current[url]); + } + } + + for (const url of Object.keys(referenceImageLayers.current)) { + if (imageShouldBeHidden(url)) { + if (map.hasLayer(referenceImageLayers.current[url])) { + map.removeLayer(referenceImageLayers.current[url]); + } + + delete referenceImageLayers.current[url]; + } + } + }, [visibleImages, map, createLayer]); } diff --git a/src/app/src/components/ModalSections/FileUpload.js b/src/app/src/components/ModalSections/FileUpload.js index 81e04f0d..2097c4f3 100644 --- a/src/app/src/components/ModalSections/FileUpload.js +++ b/src/app/src/components/ModalSections/FileUpload.js @@ -7,7 +7,6 @@ import { Icon, List, ListItem, - Progress, Text, } from '@chakra-ui/react'; import { useNavigate } from 'react-router-dom'; @@ -15,13 +14,14 @@ import { CloudUploadIcon } from '@heroicons/react/outline'; import { convertIndexedObjectToArray } from '../../utils'; import ModalSection from './ModalSection'; +import { useAddReferenceImage, useFilePicker } from '../../hooks'; +import { useSelector } from 'react-redux'; export default function FileUpload({ PreviousButton }) { const navigate = useNavigate(); + const addReferenceImage = useAddReferenceImage(); - const [files, setFiles] = useState([]); - - const addFiles = newFiles => setFiles(files => [...files, ...newFiles]); + const addFiles = newFiles => newFiles.forEach(addReferenceImage); return ( - - + + ); } -function UploadBox({ setFiles }) { +function UploadBox({ addFiles }) { const { hovering, handleUpload, startDrag, endDrag } = - useFileUpload(setFiles); + useFileUpload(addFiles); - const openFileDialog = useFilePicker(setFiles); + const openFileDialog = useFilePicker(addFiles); const onLeaveDragBox = event => { const enteredElement = event.relatedTarget; @@ -103,7 +103,7 @@ function UploadBox({ setFiles }) { Shapefiles: .SHP, .SHX and .DBF
- Other map files: PDF, JPEG, PNG, TIFF + Other map files: JPEG, PNG
@@ -154,24 +154,6 @@ function usePreventBackgroundUpload() { }, []); } -function useFilePicker(onChange) { - const openFileDialog = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.onchange = handlePickFiles; - - input.click(); - }; - - const handlePickFiles = event => { - onChange(convertIndexedObjectToArray(event.target.files)); - event.target.remove(); - }; - - return openFileDialog; -} - function CloudIconWithBackground() { return ( state.map.referenceImages) + ); + + if (imageEntries.length === 0) return null; return ( @@ -211,16 +197,11 @@ function FilesBox({ files }) { Uploaded Files - {files.map(file => ( - + {imageEntries.map(([url, { name }]) => ( + - {file.name} + {name} - ))} diff --git a/src/app/src/components/Sidebar.js b/src/app/src/components/Sidebar.js index 0e670aa3..f1cd1d26 100644 --- a/src/app/src/components/Sidebar.js +++ b/src/app/src/components/Sidebar.js @@ -8,6 +8,7 @@ import { Icon, Text, Circle, + Tooltip, } from '@chakra-ui/react'; import { MenuIcon, @@ -26,7 +27,8 @@ import { toggleLayer, toggleReferenceImageVisibility, } from '../store/mapSlice'; -import { DATA_LAYERS } from '../constants'; +import { DATA_LAYERS, SIDEBAR_TEXT_TOOLTIP_THRESHOLD } from '../constants'; +import { useAddReferenceImage, useFilePicker } from '../hooks'; const marginLeft = 4; @@ -55,9 +57,10 @@ function TitleBar() { function ReferenceLayers() { const dispatch = useDispatch(); - const referenceImageVisible = useSelector( - state => state.map.referenceImage.visible - ); + const addReferenceImage = useAddReferenceImage(); + const openFileDialog = useFilePicker(files => files.map(addReferenceImage)); + + const images = useSelector(state => state.map.referenceImages); return ( @@ -73,14 +76,22 @@ function ReferenceLayers() { variant='button' leftIcon={} mb={4} + onClick={openFileDialog} > Upload file - dispatch(toggleReferenceImageVisibility())} - label='Reference image' - /> + + {Object.entries(images).map(([url, image]) => ( + + dispatch(toggleReferenceImageVisibility(url)) + } + label={image.name} + /> + ))} + ); } @@ -137,31 +148,38 @@ function BasemapLayers() { function VisibilityButton({ label, visible, onChange, disabled = false }) { return ( - + + ); } diff --git a/src/app/src/constants.js b/src/app/src/constants.js index 6ccca119..27eaac04 100644 --- a/src/app/src/constants.js +++ b/src/app/src/constants.js @@ -25,3 +25,5 @@ export const NC_SOUTH = 33.851169; export const PARCELS_LAYER_URL = 'https://services.nconemap.gov/secure/rest/services/NC1Map_Parcels/MapServer'; + +export const SIDEBAR_TEXT_TOOLTIP_THRESHOLD = 30; diff --git a/src/app/src/hooks.js b/src/app/src/hooks.js index ef3fd23b..277eddcd 100644 --- a/src/app/src/hooks.js +++ b/src/app/src/hooks.js @@ -1,6 +1,12 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useMap } from 'react-leaflet'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; + +import { convertIndexedObjectToArray } from './utils'; +import { + createDefaultReferenceImage, + updateReferenceImage, +} from './store/mapSlice'; export function useDialogController() { const [isOpen, setIsOpen] = useState(false); @@ -71,3 +77,36 @@ export function useMapLayer(layer) { export function useLayerVisibility(layer) { return useSelector(state => state.map.layers).includes(layer); } + +export function useAddReferenceImage() { + const dispatch = useDispatch(); + + return file => { + const url = URL.createObjectURL(file); + dispatch( + updateReferenceImage({ + url, + update: createDefaultReferenceImage(file.name), + }) + ); + }; +} + +export function useFilePicker(onChange) { + const openFileDialog = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.onchange = handlePickFiles; + input.accept = 'image/png, image/jpeg, .png, .jpg, .jpeg'; + + input.click(); + }; + + const handlePickFiles = event => { + onChange(convertIndexedObjectToArray(event.target.files)); + event.target.remove(); + }; + + return openFileDialog; +} diff --git a/src/app/src/store/mapSlice.js b/src/app/src/store/mapSlice.js index baffe7ae..5e622b33 100644 --- a/src/app/src/store/mapSlice.js +++ b/src/app/src/store/mapSlice.js @@ -9,15 +9,18 @@ const initialState = { basemapType: 'default', geocodeResult: null, mapZoom: MAP_INITIAL_ZOOM, - referenceImage: { - visible: true, - corners: null, - mode: 'distort', - transparent: false, - outlined: false, - }, + referenceImages: {}, }; +export const createDefaultReferenceImage = name => ({ + name, + visible: true, + corners: null, + mode: 'distort', + transparent: false, + outlined: false, +}); + const DEFAULT_POLYGON = { points: [], visible: true, @@ -93,11 +96,15 @@ export const mapSlice = createSlice({ setMapZoom: (state, { payload: mapZoom }) => { state.mapZoom = mapZoom; }, - toggleReferenceImageVisibility: state => { - state.referenceImage.visible = !state.referenceImage.visible; + toggleReferenceImageVisibility: (state, { payload: url }) => { + state.referenceImages[url].visible = + !state.referenceImages[url].visible; }, - updateReferenceImage: (state, { payload: update }) => { - state.referenceImage = { ...state.referenceImage, ...update }; + updateReferenceImage: (state, { payload: { url, update } }) => { + state.referenceImages[url] = { + ...(state.referenceImages?.[url] ?? {}), + ...update, + }; }, }, });