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,
+ };
},
},
});