Revize a7ae217f
Přidáno uživatelem Václav Honzík před téměř 3 roky(ů)
frontend/src/features/Notification/notificationSlice.ts | ||
---|---|---|
1 | 1 |
import { AlertColor } from '@mui/material' |
2 |
import { createSlice } from '@reduxjs/toolkit' |
|
2 |
import { Action, createSlice } from '@reduxjs/toolkit'
|
|
3 | 3 |
|
4 | 4 |
export interface NotificationState { |
5 | 5 |
message?: string |
... | ... | |
7 | 7 |
autohideSecs?: number |
8 | 8 |
} |
9 | 9 |
|
10 |
const initialState = { |
|
10 |
const initialState: NotificationState = {
|
|
11 | 11 |
message: undefined, |
12 | 12 |
severity: 'info', |
13 | 13 |
autohideSecs: undefined |
... | ... | |
17 | 17 |
name: 'notification', |
18 | 18 |
initialState, |
19 | 19 |
reducers: { |
20 |
showNotification: (state, action) => ({
|
|
20 |
showNotification: (state: NotificationState, action: { payload: NotificationState }) => ({
|
|
21 | 21 |
...state, |
22 | 22 |
message: action.payload.message, |
23 | 23 |
severity: action.payload.severity, |
24 | 24 |
autohideSecs: action.payload.autohideSecs, |
25 | 25 |
}), |
26 | 26 |
// consumes the message so it is not displayed after the page gets refreshed |
27 |
consumeNotification: (state) => ({
|
|
27 |
consumeNotification: () => ({ |
|
28 | 28 |
...initialState, |
29 | 29 |
}), |
30 | 30 |
}, |
frontend/src/features/Reusables/SingleFileSelectionForm.tsx | ||
---|---|---|
1 |
import { Button, Link, Stack, Typography } from '@mui/material' |
|
2 |
import { Fragment, FunctionComponent } from 'react' |
|
3 |
import AttachmentIcon from '@mui/icons-material/Attachment' |
|
4 |
import DeleteIcon from '@mui/icons-material/Delete' |
|
5 |
import SendIcon from '@mui/icons-material/Send' |
|
6 |
|
|
7 |
export interface SingleFileSelectionFormProps { |
|
8 |
filename?: string |
|
9 |
onFileSelected: (event: any) => void |
|
10 |
formik: any |
|
11 |
onClearSelectedFile: () => void |
|
12 |
} |
|
13 |
|
|
14 |
const SingleFileSelectionForm: FunctionComponent< |
|
15 |
SingleFileSelectionFormProps |
|
16 |
> = ({ filename, onFileSelected, formik, onClearSelectedFile }) => { |
|
17 |
return ( |
|
18 |
<form onSubmit={formik.handleSubmit}> |
|
19 |
{!filename ? ( |
|
20 |
<Fragment> |
|
21 |
<Stack |
|
22 |
direction="row" |
|
23 |
justifyContent="flex-end" |
|
24 |
alignItems="center" |
|
25 |
> |
|
26 |
<Button |
|
27 |
variant="contained" |
|
28 |
color="primary" |
|
29 |
component="label" |
|
30 |
// size="small" |
|
31 |
startIcon={<AttachmentIcon />} |
|
32 |
> |
|
33 |
Select File |
|
34 |
<input |
|
35 |
id="file" |
|
36 |
name="file" |
|
37 |
type="file" |
|
38 |
hidden |
|
39 |
onChange={onFileSelected} |
|
40 |
/> |
|
41 |
</Button> |
|
42 |
</Stack> |
|
43 |
</Fragment> |
|
44 |
) : ( |
|
45 |
<Fragment> |
|
46 |
<Stack direction="row" spacing={1}> |
|
47 |
<Typography variant="body1">Selected File: </Typography> |
|
48 |
<Typography |
|
49 |
sx={{ |
|
50 |
textOverflow: 'ellipsis', |
|
51 |
overflow: 'hidden', |
|
52 |
}} |
|
53 |
component={Link} |
|
54 |
> |
|
55 |
{filename} |
|
56 |
</Typography> |
|
57 |
</Stack> |
|
58 |
<Stack |
|
59 |
direction="row" |
|
60 |
justifyContent="flex-end" |
|
61 |
alignItems="center" |
|
62 |
spacing={2} |
|
63 |
sx={{ mt: 2 }} |
|
64 |
> |
|
65 |
<Button |
|
66 |
// sx={{ mb: 2, mt: 1 }} |
|
67 |
variant="contained" |
|
68 |
size="small" |
|
69 |
endIcon={<DeleteIcon />} |
|
70 |
onClick={onClearSelectedFile} |
|
71 |
> |
|
72 |
Remove Selection |
|
73 |
</Button> |
|
74 |
<Button |
|
75 |
size="small" |
|
76 |
type="submit" |
|
77 |
variant="contained" |
|
78 |
startIcon={<SendIcon />} |
|
79 |
> |
|
80 |
Submit |
|
81 |
</Button> |
|
82 |
</Stack> |
|
83 |
</Fragment> |
|
84 |
)} |
|
85 |
</form> |
|
86 |
) |
|
87 |
} |
|
88 |
|
|
89 |
export default SingleFileSelectionForm |
frontend/src/features/TrackingTool/FileUpload.tsx | ||
---|---|---|
1 |
import { |
|
2 |
Button, |
|
3 |
DialogContent, |
|
4 |
DialogTitle, |
|
5 |
Link, |
|
6 |
Stack, |
|
7 |
Typography, |
|
8 |
} from "@mui/material" |
|
9 |
import { useFormik } from "formik" |
|
10 |
import { Fragment, useState } from "react" |
|
11 |
import * as yup from "yup" |
|
12 |
import axiosInstance from "../../api/api" |
|
13 |
import ButtonOpenableDialog from "../Reusables/ButtonOpenableDialog" |
|
14 |
import AttachmentIcon from "@mui/icons-material/Attachment" |
|
15 |
import DeleteIcon from "@mui/icons-material/Delete" |
|
16 |
import SendIcon from "@mui/icons-material/Send" |
|
17 |
import { useDispatch } from "react-redux" |
|
18 |
import { sendTextForProcessing } from "./trackingToolThunks" |
|
19 |
|
|
20 |
interface UploadValues { |
|
21 |
file?: File |
|
22 |
} |
|
23 |
|
|
24 |
const initialValues: UploadValues = {} |
|
25 |
|
|
26 |
const FileUpload = () => { |
|
27 |
const dispatch = useDispatch() |
|
28 |
|
|
29 |
const [filename, setFilename] = useState<string | undefined>(undefined) |
|
30 |
const [fileProcessing, setFileProcessing] = useState(false) |
|
31 |
|
|
32 |
const validationSchema = yup.object().shape({ |
|
33 |
file: yup.mixed().required("File is required"), |
|
34 |
}) |
|
35 |
|
|
36 |
const formik = useFormik({ |
|
37 |
initialValues, |
|
38 |
validationSchema, |
|
39 |
onSubmit: async (values) => { |
|
40 |
setFileProcessing(true) |
|
41 |
const reader = new FileReader() |
|
42 |
reader.readAsText(values.file as File) |
|
43 |
reader.onload = async () => { |
|
44 |
dispatch(sendTextForProcessing(reader.result as string)) |
|
45 |
setFileProcessing(false) |
|
46 |
} |
|
47 |
}, |
|
48 |
}) |
|
49 |
|
|
50 |
// Callback when user selects the file |
|
51 |
const onFileSelected = (event: any) => { |
|
52 |
const file = event.currentTarget.files[0] |
|
53 |
if (file) { |
|
54 |
setFilename(file.name) |
|
55 |
formik.setFieldValue("file", file) |
|
56 |
} |
|
57 |
} |
|
58 |
|
|
59 |
const onClose = () => { |
|
60 |
if (fileProcessing) { |
|
61 |
return |
|
62 |
} |
|
63 |
setFilename(undefined) |
|
64 |
formik.resetForm() |
|
65 |
} |
|
66 |
|
|
67 |
const onClearSelectedFile = () => { |
|
68 |
setFilename(undefined) |
|
69 |
formik.setFieldValue("file", undefined) |
|
70 |
} |
|
71 |
|
|
72 |
return ( |
|
73 |
<ButtonOpenableDialog |
|
74 |
buttonText="File" |
|
75 |
buttonColor="primary" |
|
76 |
buttonVariant="contained" |
|
77 |
onCloseCallback={onClose} |
|
78 |
maxWidth="xs" |
|
79 |
> |
|
80 |
<DialogTitle>Upload New File</DialogTitle> |
|
81 |
<DialogContent> |
|
82 |
<form onSubmit={formik.handleSubmit}> |
|
83 |
{!filename ? ( |
|
84 |
<Fragment> |
|
85 |
<Stack |
|
86 |
direction="row" |
|
87 |
justifyContent="flex-end" |
|
88 |
alignItems="center" |
|
89 |
> |
|
90 |
<Button |
|
91 |
variant="contained" |
|
92 |
color="primary" |
|
93 |
component="label" |
|
94 |
// size="small" |
|
95 |
startIcon={<AttachmentIcon />} |
|
96 |
> |
|
97 |
Select File |
|
98 |
<input |
|
99 |
id="file" |
|
100 |
name="file" |
|
101 |
type="file" |
|
102 |
hidden |
|
103 |
onChange={onFileSelected} |
|
104 |
/> |
|
105 |
</Button> |
|
106 |
</Stack> |
|
107 |
</Fragment> |
|
108 |
) : ( |
|
109 |
<Fragment> |
|
110 |
<Stack direction="row" spacing={1}> |
|
111 |
<Typography |
|
112 |
sx={ |
|
113 |
{ |
|
114 |
// textOverflow: 'ellipsis', |
|
115 |
// overflow: 'hidden', |
|
116 |
} |
|
117 |
} |
|
118 |
variant="body1" |
|
119 |
> |
|
120 |
Selected File:{" "} |
|
121 |
</Typography> |
|
122 |
<Typography |
|
123 |
sx={{ |
|
124 |
textOverflow: "ellipsis", |
|
125 |
overflow: "hidden", |
|
126 |
}} |
|
127 |
// color="text.secondary" |
|
128 |
component={Link} |
|
129 |
// download={(formik.values?.file as File).} |
|
130 |
// align="right" |
|
131 |
> |
|
132 |
{filename} |
|
133 |
</Typography> |
|
134 |
</Stack> |
|
135 |
<Stack |
|
136 |
direction="row" |
|
137 |
justifyContent="flex-end" |
|
138 |
alignItems="center" |
|
139 |
spacing={2} |
|
140 |
sx={{ mt: 2 }} |
|
141 |
> |
|
142 |
<Button |
|
143 |
// sx={{ mb: 2, mt: 1 }} |
|
144 |
variant="contained" |
|
145 |
size="small" |
|
146 |
endIcon={<DeleteIcon />} |
|
147 |
onClick={onClearSelectedFile} |
|
148 |
> |
|
149 |
Remove Selection |
|
150 |
</Button> |
|
151 |
<Button |
|
152 |
size="small" |
|
153 |
type="submit" |
|
154 |
variant="contained" |
|
155 |
startIcon={<SendIcon />} |
|
156 |
> |
|
157 |
Submit |
|
158 |
</Button> |
|
159 |
</Stack> |
|
160 |
</Fragment> |
|
161 |
)} |
|
162 |
</form> |
|
163 |
</DialogContent> |
|
164 |
</ButtonOpenableDialog> |
|
165 |
) |
|
166 |
} |
|
167 |
|
|
168 |
export default FileUpload |
frontend/src/features/TrackingTool/MapPath.tsx | ||
---|---|---|
1 |
import { Fragment, FunctionComponent, useEffect, useState } from "react"
|
|
2 |
import { useDispatch, useSelector } from "react-redux"
|
|
3 |
import { RootState } from "../redux/store"
|
|
4 |
import { PathVariant, MapPoint } from "./buildPathVariants"
|
|
5 |
import TextPath from "react-leaflet-textpath"
|
|
6 |
import { setPrimaryIdx, updateMapMarker } from "./trackingToolSlice"
|
|
7 |
import MapMarker from "./MapMarker"
|
|
8 |
import { LatLngTuple } from "leaflet"
|
|
9 |
import { Popup, Tooltip } from "react-leaflet"
|
|
10 |
import { Checkbox, FormControlLabel, Stack, Typography } from "@mui/material"
|
|
11 |
import { formatHtmlStringToReactDom } from "../../utils/formatting/HtmlUtils"
|
|
12 |
import { DialogCatalogItemDetail as CatalogItemDetailDialog } from "../Catalog/CatalogItemDetail"
|
|
1 |
import { Fragment, FunctionComponent, useEffect, useState } from 'react'
|
|
2 |
import { useDispatch, useSelector } from 'react-redux'
|
|
3 |
import { RootState } from '../redux/store'
|
|
4 |
import { PathVariant, MapPoint, isMapPointDisplayable } from './pathUtils'
|
|
5 |
import TextPath from 'react-leaflet-textpath'
|
|
6 |
import { setPrimaryIdx, updateMapMarker } from './trackingToolSlice'
|
|
7 |
import MapMarker from './MapMarker'
|
|
8 |
import { LatLngTuple } from 'leaflet'
|
|
9 |
import { Popup, Tooltip } from 'react-leaflet'
|
|
10 |
import { Checkbox, FormControlLabel, Stack, Typography } from '@mui/material'
|
|
11 |
import { formatHtmlStringToReactDom } from '../../utils/formatting/HtmlUtils'
|
|
12 |
import { DialogCatalogItemDetail as CatalogItemDetailDialog } from '../Catalog/CatalogItemDetail'
|
|
13 | 13 |
|
14 | 14 |
export interface MapPathProps { |
15 | 15 |
idx: number // index of the path in the list |
... | ... | |
18 | 18 |
type EdgeElement = any |
19 | 19 |
|
20 | 20 |
// Blue |
21 |
export const primaryPathColor = "#346eeb"
|
|
21 |
export const primaryPathColor = '#346eeb'
|
|
22 | 22 |
|
23 | 23 |
// Grey |
24 |
export const secondaryPathColor = "#878e9c"
|
|
24 |
export const secondaryPathColor = '#878e9c'
|
|
25 | 25 |
|
26 | 26 |
const MapPath: FunctionComponent<MapPathProps> = ({ idx }) => { |
27 | 27 |
const dispatch = useDispatch() |
... | ... | |
48 | 48 |
>([]) |
49 | 49 |
useEffect(() => { |
50 | 50 |
// Set all displayable vertices |
51 |
setDisplayableMapPoints(path.filter((vertex) => vertex.displayable)) |
|
51 |
setDisplayableMapPoints( |
|
52 |
path.filter((mapPoint) => isMapPointDisplayable(mapPoint)) |
|
53 |
) |
|
52 | 54 |
}, [path]) |
53 | 55 |
|
54 | 56 |
// List of all edges in the path |
... | ... | |
81 | 83 |
text="►" |
82 | 84 |
// text=" > > > > " |
83 | 85 |
attributes={{ |
84 |
"font-size": 19,
|
|
86 |
'font-size': 19,
|
|
85 | 87 |
// Set to primaryPathColor if primary index in the tracking tool is equal to this index |
86 | 88 |
fill: |
87 | 89 |
primaryPathIdx === idx |
... | ... | |
114 | 116 |
dispatch( |
115 | 117 |
updateMapMarker({ |
116 | 118 |
idx, |
117 |
item: new MapPoint(item.idx, item.active, { |
|
118 |
...item.catalogItem, |
|
119 |
latitude: position[0], |
|
120 |
longitude: position[1], |
|
121 |
}), |
|
119 |
item: { |
|
120 |
idx: item.idx, |
|
121 |
active: item.active, |
|
122 |
catalogItem: { |
|
123 |
...item.catalogItem, |
|
124 |
latitude: position[0], |
|
125 |
longitude: position[1], |
|
126 |
}, |
|
127 |
}, |
|
122 | 128 |
}) |
123 | 129 |
) |
124 | 130 |
}} |
... | ... | |
126 | 132 |
<Fragment> |
127 | 133 |
<Tooltip> |
128 | 134 |
{/* <Typography> */} |
129 |
{item.catalogItem.name ?? ""}
|
|
135 |
{item.catalogItem.name ?? ''}
|
|
130 | 136 |
{/* </Typography> */} |
131 | 137 |
</Tooltip> |
132 | 138 |
<Popup> |
... | ... | |
149 | 155 |
dispatch( |
150 | 156 |
updateMapMarker({ |
151 | 157 |
idx, |
152 |
item: new MapPoint( |
|
153 |
item.idx, |
|
154 |
!item.active, |
|
155 |
item.catalogItem |
|
156 |
), |
|
158 |
item: { |
|
159 |
...item, |
|
160 |
active: !item.active, |
|
161 |
}, |
|
157 | 162 |
}) |
158 | 163 |
) |
159 | 164 |
}} |
... | ... | |
163 | 168 |
label="Active" |
164 | 169 |
/> |
165 | 170 |
<CatalogItemDetailDialog |
166 |
itemId={item.catalogItem.id ?? ""}
|
|
171 |
itemId={item.catalogItem.id ?? ''}
|
|
167 | 172 |
/> |
168 | 173 |
</Stack> |
169 | 174 |
</Fragment> |
frontend/src/features/TrackingTool/PlaintextUpload.tsx | ||
---|---|---|
1 |
import { |
|
2 |
Button, |
|
3 |
Dialog, |
|
4 |
DialogContent, |
|
5 |
DialogTitle, |
|
6 |
Stack, |
|
7 |
TextField, |
|
8 |
} from '@mui/material' |
|
9 |
import { useFormik } from 'formik' |
|
10 |
import { Fragment, FunctionComponent, useEffect, useState } from 'react' |
|
11 |
import SendIcon from '@mui/icons-material/Send' |
|
12 |
import ClearIcon from '@mui/icons-material/Clear' |
|
13 |
import { useDispatch, useSelector } from 'react-redux' |
|
14 |
import { RootState } from '../redux/store' |
|
15 |
import { sendTextForProcessing } from './trackingToolThunks' |
|
16 |
import * as yup from 'yup' |
|
17 |
import { resetDialogApiCallSuccess } from './trackingToolSlice' |
|
18 |
|
|
19 |
const PlaintextUpload = () => { |
|
20 |
const loading = useSelector( |
|
21 |
(state: RootState) => state.trackingTool.isLoading |
|
22 |
) |
|
23 |
const [open, setOpen] = useState(false) // controls whether the dialog is open |
|
24 |
|
|
25 |
// This controls whether to keep dialog open after sending the request to the API |
|
26 |
const dialogApiCallSuccess = useSelector( |
|
27 |
(state: RootState) => state.trackingTool.dialogApiCallSuccess |
|
28 |
) |
|
29 |
|
|
30 |
const dispatch = useDispatch() |
|
31 |
|
|
32 |
const validationSchema = yup.object().shape({ |
|
33 |
text: yup.mixed().required('Text is required'), |
|
34 |
}) |
|
35 |
|
|
36 |
const formik = useFormik({ |
|
37 |
initialValues: { |
|
38 |
text: '', |
|
39 |
}, |
|
40 |
validationSchema, |
|
41 |
onSubmit: async () => { |
|
42 |
// Dispatch the thunk |
|
43 |
dispatch(sendTextForProcessing(formik.values.text)) |
|
44 |
}, |
|
45 |
}) |
|
46 |
|
|
47 |
const onCloseDialog = () => { |
|
48 |
formik.resetForm() |
|
49 |
setOpen(false) |
|
50 |
} |
|
51 |
|
|
52 |
const resetForm = () => { |
|
53 |
formik.resetForm() |
|
54 |
} |
|
55 |
|
|
56 |
useEffect(() => { |
|
57 |
if (!dialogApiCallSuccess) { |
|
58 |
return |
|
59 |
} |
|
60 |
dispatch(resetDialogApiCallSuccess()) |
|
61 |
setOpen(false) |
|
62 |
}, [dialogApiCallSuccess, dispatch]) |
|
63 |
|
|
64 |
return ( |
|
65 |
<Fragment> |
|
66 |
<Button variant="contained" onClick={() => setOpen(true)}> |
|
67 |
Text |
|
68 |
</Button> |
|
69 |
<Dialog |
|
70 |
open={open} |
|
71 |
fullWidth={true} |
|
72 |
onClose={onCloseDialog} |
|
73 |
maxWidth="lg" |
|
74 |
> |
|
75 |
<DialogTitle>Plaintext Input</DialogTitle> |
|
76 |
<DialogContent> |
|
77 |
<form onSubmit={formik.handleSubmit}> |
|
78 |
<TextField |
|
79 |
sx={{ my: 2 }} |
|
80 |
fullWidth |
|
81 |
multiline |
|
82 |
label="Plaintext input" |
|
83 |
rows={10} |
|
84 |
name="text" |
|
85 |
value={formik.values.text} |
|
86 |
onChange={formik.handleChange} |
|
87 |
/> |
|
88 |
<Stack |
|
89 |
alignItems="flex-end" |
|
90 |
justifyContent="flex-end" |
|
91 |
spacing={2} |
|
92 |
direction="row" |
|
93 |
> |
|
94 |
<Button |
|
95 |
variant="contained" |
|
96 |
color="secondary" |
|
97 |
onClick={resetForm} |
|
98 |
startIcon={<ClearIcon />} |
|
99 |
> |
|
100 |
Clear |
|
101 |
</Button> |
|
102 |
<Button |
|
103 |
type="submit" |
|
104 |
variant="contained" |
|
105 |
startIcon={<SendIcon />} |
|
106 |
disabled={loading} |
|
107 |
> |
|
108 |
Submit |
|
109 |
</Button> |
|
110 |
</Stack> |
|
111 |
</form> |
|
112 |
</DialogContent> |
|
113 |
</Dialog> |
|
114 |
</Fragment> |
|
115 |
) |
|
116 |
} |
|
117 |
|
|
118 |
export default PlaintextUpload |
frontend/src/features/TrackingTool/TrackingTool.tsx | ||
---|---|---|
5 | 5 |
Grid, |
6 | 6 |
Stack, |
7 | 7 |
Typography, |
8 |
} from "@mui/material" |
|
9 |
import { Fragment, useEffect, useRef, useState } from "react" |
|
10 |
import { MapContainer, TileLayer, useMap } from "react-leaflet" |
|
11 |
import mapConfig from "../../config/mapConfig" |
|
12 |
import TextPath from "react-leaflet-textpath" |
|
13 |
import PlaintextUpload from "./PlaintextUpload" |
|
14 |
import FileUpload from "./FileUpload" |
|
15 |
import L, { Map } from "leaflet" |
|
16 |
import DeleteIcon from "@mui/icons-material/Delete" |
|
17 |
import { PathDto } from "../../swagger/data-contracts" |
|
18 |
import { formatHtmlStringToReactDom } from "../../utils/formatting/HtmlUtils" |
|
19 |
import MapPath from "./MapPath" |
|
20 |
import EditIcon from "@mui/icons-material/Edit" |
|
21 |
import { useDispatch, useSelector } from "react-redux" |
|
22 |
import { RootState } from "../redux/store" |
|
23 |
import { clear, consumeErr as consumeError } from "./trackingToolSlice" |
|
24 |
import { showNotification } from "../Notification/notificationSlice" |
|
25 |
import ClearIcon from "@mui/icons-material/Clear" |
|
8 |
} from '@mui/material' |
|
9 |
import { Fragment, useEffect, useRef } from 'react' |
|
10 |
import { MapContainer, TileLayer } from 'react-leaflet' |
|
11 |
import mapConfig from '../../config/mapConfig' |
|
12 |
import PlaintextUpload from './Upload/PlaintextUpload' |
|
13 |
import FileUpload from './Upload/FileUpload' |
|
14 |
import { Map } from 'leaflet' |
|
15 |
import { formatHtmlStringToReactDom } from '../../utils/formatting/HtmlUtils' |
|
16 |
import MapPath from './MapPath' |
|
17 |
import { useDispatch, useSelector } from 'react-redux' |
|
18 |
import { RootState } from '../redux/store' |
|
19 |
import { clear, consumeErr as consumeError } from './trackingToolSlice' |
|
20 |
import { showNotification } from '../Notification/notificationSlice' |
|
21 |
import ClearIcon from '@mui/icons-material/Clear' |
|
22 |
import GeoJsonExportButton from './Upload/GeoJsonExportButton' |
|
23 |
import GeoJsonImportDialog from './Upload/GeoJsonImportDialog' |
|
26 | 24 |
|
27 | 25 |
// Page with tracking tool |
28 | 26 |
const TrackingTool = () => { |
... | ... | |
50 | 48 |
dispatch( |
51 | 49 |
showNotification({ |
52 | 50 |
message: error, |
53 |
severity: "error",
|
|
51 |
severity: 'error',
|
|
54 | 52 |
}) |
55 | 53 |
) |
56 | 54 |
}, [err, dispatch]) |
... | ... | |
58 | 56 |
const mapRef = useRef<Map | undefined>(undefined) |
59 | 57 |
useEffect(() => { |
60 | 58 |
if (!mapRef || !mapRef.current) { |
61 |
console.log("No map ref")
|
|
59 |
console.log('No map ref')
|
|
62 | 60 |
return |
63 | 61 |
} |
64 | 62 |
|
... | ... | |
87 | 85 |
Looks like no path / catalog items match this query. |
88 | 86 |
</Typography> |
89 | 87 |
)} |
90 |
{!pathDto && ( |
|
91 |
<Stack |
|
92 |
direction="row" |
|
93 |
alignItems="flex-start" |
|
94 |
spacing={2} |
|
95 |
sx={{ mt: 1 }} |
|
96 |
> |
|
97 |
<Typography |
|
98 |
variant="h5" |
|
99 |
sx={{ mb: 2 }} |
|
100 |
fontWeight="500" |
|
101 |
> |
|
102 |
Upload: |
|
103 |
</Typography> |
|
104 |
<PlaintextUpload /> |
|
105 |
<FileUpload /> |
|
106 |
</Stack> |
|
107 |
)} |
|
88 |
<Stack |
|
89 |
direction="row" |
|
90 |
alignItems="flex-start" |
|
91 |
spacing={2} |
|
92 |
sx={{ mt: 1 }} |
|
93 |
> |
|
94 |
{!pathDto && ( |
|
95 |
<Fragment> |
|
96 |
<Typography |
|
97 |
variant="h5" |
|
98 |
sx={{ mb: 2 }} |
|
99 |
fontWeight="500" |
|
100 |
> |
|
101 |
Upload: |
|
102 |
</Typography> |
|
103 |
<PlaintextUpload /> |
|
104 |
<FileUpload /> |
|
105 |
</Fragment> |
|
106 |
)} |
|
107 |
<GeoJsonImportDialog /> |
|
108 |
{pathVariants && pathVariants.length > 0 && ( |
|
109 |
<GeoJsonExportButton /> |
|
110 |
)} |
|
111 |
</Stack> |
|
108 | 112 |
|
109 | 113 |
{pathDto && ( |
110 | 114 |
<Stack alignItems="flex-end"> |
... | ... | |
125 | 129 |
xs={12} |
126 | 130 |
md={12} |
127 | 131 |
style={{ |
128 |
minHeight: "60vh",
|
|
129 |
maxHeight: "100vh",
|
|
130 |
width: "100%",
|
|
132 |
minHeight: '60vh',
|
|
133 |
maxHeight: '100vh',
|
|
134 |
width: '100%',
|
|
131 | 135 |
}} |
132 | 136 |
> |
133 | 137 |
<MapContainer |
134 | 138 |
center={[mapCenter[0], mapCenter[1]]} |
135 | 139 |
zoom={mapConfig.defaultZoom} |
136 |
style={{ height: "100%", minHeight: "100%" }} |
|
137 |
whenCreated={(map) => { mapRef.current = map }} |
|
140 |
style={{ height: '100%', minHeight: '100%' }} |
|
141 |
whenCreated={(map) => { |
|
142 |
mapRef.current = map |
|
143 |
}} |
|
138 | 144 |
> |
139 | 145 |
<TileLayer |
140 | 146 |
attribution={mapConfig.attribution} |
... | ... | |
158 | 164 |
</Typography> |
159 | 165 |
<Typography variant="body2"> |
160 | 166 |
{formatHtmlStringToReactDom( |
161 |
pathDto.text ?? ""
|
|
167 |
pathDto.text ?? ''
|
|
162 | 168 |
)} |
163 | 169 |
</Typography> |
164 | 170 |
</Stack> |
frontend/src/features/TrackingTool/TrackingToolState.ts | ||
---|---|---|
1 |
import { LatLngTuple } from 'leaflet' |
|
2 |
import { PathDto } from '../../swagger/data-contracts' |
|
3 |
import { PathVariant } from './pathUtils' |
|
4 |
|
|
5 |
export default interface TrackingToolState { |
|
6 |
isLoading: boolean // whether the data is being loaded |
|
7 |
pathDto?: PathDto // the data |
|
8 |
pathVariants?: PathVariant[] // undefined signals that no path variants were yet fetched from the API |
|
9 |
lastError?: string // consumable for errors during thunks |
|
10 |
mapCenter: LatLngTuple // pair of latitude and longitude |
|
11 |
primaryPathIdx: number // index of the primary path. This index is always in relation to the pathVariants array and not the current page |
|
12 |
// trigger to close the dialog when API call is finished |
|
13 |
dialogApiCallSuccess: boolean |
|
14 |
pathsPerPage: number // max number of paths to show on the map at once |
|
15 |
currentPage: number // current page of paths - starts from 0 |
|
16 |
} |
frontend/src/features/TrackingTool/Upload/FileUpload.tsx | ||
---|---|---|
1 |
import { |
|
2 |
Button, |
|
3 |
DialogContent, |
|
4 |
DialogTitle, |
|
5 |
Link, |
|
6 |
Stack, |
|
7 |
Typography, |
|
8 |
} from '@mui/material' |
|
9 |
import { useFormik } from 'formik' |
|
10 |
import { Fragment, useState } from 'react' |
|
11 |
import * as yup from 'yup' |
|
12 |
import axiosInstance from '../../../api/api' |
|
13 |
import ButtonOpenableDialog from '../../Reusables/ButtonOpenableDialog' |
|
14 |
import { useDispatch } from 'react-redux' |
|
15 |
import { sendTextForProcessing } from '../trackingToolThunks' |
|
16 |
import SingleFileSelectionForm from '../../Reusables/SingleFileSelectionForm' |
|
17 |
|
|
18 |
interface UploadValues { |
|
19 |
file?: File |
|
20 |
} |
|
21 |
|
|
22 |
const initialValues: UploadValues = {} |
|
23 |
|
|
24 |
const FileUpload = () => { |
|
25 |
const dispatch = useDispatch() |
|
26 |
|
|
27 |
const [filename, setFilename] = useState<string | undefined>(undefined) |
|
28 |
const [fileProcessing, setFileProcessing] = useState(false) |
|
29 |
|
|
30 |
const validationSchema = yup.object().shape({ |
|
31 |
file: yup.mixed().required('File is required'), |
|
32 |
}) |
|
33 |
|
|
34 |
const formik = useFormik({ |
|
35 |
initialValues, |
|
36 |
validationSchema, |
|
37 |
onSubmit: async (values) => { |
|
38 |
setFileProcessing(true) |
|
39 |
const reader = new FileReader() |
|
40 |
reader.readAsText(values.file as File) |
|
41 |
reader.onload = async () => { |
|
42 |
dispatch(sendTextForProcessing(reader.result as string)) |
|
43 |
setFileProcessing(false) |
|
44 |
} |
|
45 |
}, |
|
46 |
}) |
|
47 |
|
|
48 |
// Callback when user selects the file |
|
49 |
const onFileSelected = (event: any) => { |
|
50 |
const file = event.currentTarget.files[0] |
|
51 |
if (file) { |
|
52 |
setFilename(file.name) |
|
53 |
formik.setFieldValue('file', file) |
|
54 |
} |
|
55 |
} |
|
56 |
|
|
57 |
const onClose = () => { |
|
58 |
if (fileProcessing) { |
|
59 |
return |
|
60 |
} |
|
61 |
setFilename(undefined) |
|
62 |
formik.resetForm() |
|
63 |
} |
|
64 |
|
|
65 |
const onClearSelectedFile = () => { |
|
66 |
setFilename(undefined) |
|
67 |
formik.setFieldValue('file', undefined) |
|
68 |
} |
|
69 |
|
|
70 |
return ( |
|
71 |
<ButtonOpenableDialog |
|
72 |
buttonText="File" |
|
73 |
buttonColor="primary" |
|
74 |
buttonVariant="contained" |
|
75 |
onCloseCallback={onClose} |
|
76 |
maxWidth="xs" |
|
77 |
> |
|
78 |
<DialogTitle>Upload New File</DialogTitle> |
|
79 |
<DialogContent> |
|
80 |
<SingleFileSelectionForm |
|
81 |
onFileSelected={onFileSelected} |
|
82 |
onClearSelectedFile={onClearSelectedFile} |
|
83 |
filename={filename} |
|
84 |
formik={formik} |
|
85 |
/> |
|
86 |
</DialogContent> |
|
87 |
</ButtonOpenableDialog> |
|
88 |
) |
|
89 |
} |
|
90 |
|
|
91 |
export default FileUpload |
frontend/src/features/TrackingTool/Upload/GeoJsonExportButton.tsx | ||
---|---|---|
1 |
import { Button } from '@mui/material' |
|
2 |
import { useEffect, useState } from 'react' |
|
3 |
import { useSelector } from 'react-redux' |
|
4 |
import { RootState } from '../../redux/store' |
|
5 |
import { isMapPointDisplayable, PathVariant } from '../pathUtils' |
|
6 |
import { exportAsGeoJsonString } from './GeoJsonIo' |
|
7 |
|
|
8 |
const GeoJsonExportButton = () => { |
|
9 |
const [path, setPath] = useState<PathVariant | undefined>(undefined) |
|
10 |
const primaryPathIdx = useSelector( |
|
11 |
(state: RootState) => state.trackingTool.primaryPathIdx |
|
12 |
) |
|
13 |
const pathVariants = useSelector( |
|
14 |
(state: RootState) => state.trackingTool.pathVariants |
|
15 |
) |
|
16 |
useEffect(() => { |
|
17 |
if ( |
|
18 |
!pathVariants || |
|
19 |
pathVariants.length === 0 || |
|
20 |
pathVariants.length <= primaryPathIdx |
|
21 |
) { |
|
22 |
setPath(undefined) |
|
23 |
return |
|
24 |
} |
|
25 |
|
|
26 |
setPath(pathVariants[primaryPathIdx]) |
|
27 |
}, [primaryPathIdx, pathVariants]) |
|
28 |
|
|
29 |
const exportPath = () => { |
|
30 |
if (!path) { |
|
31 |
return |
|
32 |
} |
|
33 |
|
|
34 |
const exportPath = path.filter( |
|
35 |
(vertex) => isMapPointDisplayable(vertex) && vertex.active |
|
36 |
) |
|
37 |
const exportPathString = exportAsGeoJsonString(exportPath) |
|
38 |
const blob = new Blob([exportPathString], { type: 'application/json' }) |
|
39 |
const url = window.URL.createObjectURL(blob) |
|
40 |
const link = document.createElement('a') |
|
41 |
link.href = url |
|
42 |
link.setAttribute('download', 'path.json') |
|
43 |
document.body.appendChild(link) |
|
44 |
link.click() |
|
45 |
document.body.removeChild(link) |
|
46 |
} |
|
47 |
|
|
48 |
return ( |
|
49 |
<Button variant="contained" onClick={exportPath}> |
|
50 |
Export |
|
51 |
</Button> |
|
52 |
) |
|
53 |
} |
|
54 |
|
|
55 |
export default GeoJsonExportButton |
frontend/src/features/TrackingTool/Upload/GeoJsonImportDialog.tsx | ||
---|---|---|
1 |
import { Button, Dialog, DialogContent, DialogTitle } from '@mui/material' |
|
2 |
import { useFormik } from 'formik' |
|
3 |
import { Fragment, useState } from 'react' |
|
4 |
import { useDispatch, useSelector } from 'react-redux' |
|
5 |
import { RootState } from '../../redux/store' |
|
6 |
import ButtonOpenableDialog from '../../Reusables/ButtonOpenableDialog' |
|
7 |
import { PathVariant } from '../pathUtils' |
|
8 |
import * as yup from 'yup' |
|
9 |
import { showNotification } from '../../Notification/notificationSlice' |
|
10 |
import SingleFileSelectionForm from '../../Reusables/SingleFileSelectionForm' |
|
11 |
import { mergeWithCurrentPath } from '../trackingToolSlice' |
|
12 |
import { parseGeoJsonToPathVariant } from './GeoJsonIo' |
|
13 |
|
|
14 |
const GeoJsonImportDialog = () => { |
|
15 |
const dispatch = useDispatch() |
|
16 |
|
|
17 |
const [filename, setFilename] = useState<string | undefined>(undefined) |
|
18 |
const [fileProcessing, setFileProcessing] = useState(false) |
|
19 |
|
|
20 |
const validationSchema = yup.object().shape({ |
|
21 |
file: yup.mixed().required('File is required'), |
|
22 |
}) |
|
23 |
|
|
24 |
const initialValues: { file?: File } = { |
|
25 |
file: undefined, |
|
26 |
} |
|
27 |
|
|
28 |
// Callback when user selects the file |
|
29 |
const onFileSelected = (event: any) => { |
|
30 |
const file = event.currentTarget.files[0] |
|
31 |
if (file) { |
|
32 |
setFilename(file.name) |
|
33 |
formik.setFieldValue('file', file) |
|
34 |
} |
|
35 |
} |
|
36 |
|
|
37 |
const onClose = () => { |
|
38 |
if (fileProcessing) { |
|
39 |
return |
|
40 |
} |
|
41 |
setFilename(undefined) |
|
42 |
formik.resetForm() |
|
43 |
} |
|
44 |
|
|
45 |
const onClearSelectedFile = () => { |
|
46 |
setFilename(undefined) |
|
47 |
formik.setFieldValue('file', undefined) |
|
48 |
} |
|
49 |
|
|
50 |
const formik = useFormik({ |
|
51 |
initialValues, |
|
52 |
validationSchema, |
|
53 |
onSubmit: async (values) => { |
|
54 |
setFileProcessing(true) |
|
55 |
const reader = new FileReader() |
|
56 |
reader.readAsText(values.file as File) |
|
57 |
reader.onload = async () => { |
|
58 |
try { |
|
59 |
const pathVariant = parseGeoJsonToPathVariant(reader.result as string) |
|
60 |
console.log(pathVariant) |
|
61 |
// Merge current path variant with the new one |
|
62 |
dispatch(mergeWithCurrentPath(pathVariant)) |
|
63 |
} catch (e: any) { |
|
64 |
dispatch( |
|
65 |
showNotification({ |
|
66 |
message: e.message, |
|
67 |
// message: 'Error importing GeoJson, the file has invalid format', |
|
68 |
severity: 'error', |
|
69 |
autohideSecs: 5, |
|
70 |
}) |
|
71 |
) |
|
72 |
} |
|
73 |
setFileProcessing(false) |
|
74 |
} |
|
75 |
}, |
|
76 |
}) |
|
77 |
|
|
78 |
return ( |
|
79 |
<ButtonOpenableDialog |
|
80 |
buttonText="Import" |
|
81 |
buttonColor="primary" |
|
82 |
buttonVariant="contained" |
|
83 |
onCloseCallback={() => {}} |
|
84 |
maxWidth="xs" |
|
85 |
> |
|
86 |
<DialogTitle>Import Path</DialogTitle> |
|
87 |
<DialogContent> |
|
88 |
<SingleFileSelectionForm |
|
89 |
onFileSelected={onFileSelected} |
|
90 |
onClearSelectedFile={onClearSelectedFile} |
|
91 |
filename={filename} |
|
92 |
formik={formik} |
|
93 |
/> |
|
94 |
</DialogContent> |
|
95 |
</ButtonOpenableDialog> |
|
96 |
) |
|
97 |
} |
|
98 |
|
|
99 |
export default GeoJsonImportDialog |
frontend/src/features/TrackingTool/Upload/GeoJsonIo.ts | ||
---|---|---|
1 |
import { isMapPointDisplayable, PathVariant } from '../pathUtils' |
|
2 |
import * as yup from 'yup' |
|
3 |
|
|
4 |
export const exportAsGeoJsonString = (path: PathVariant) => JSON.stringify({ |
|
5 |
type: 'FeatureCollection', |
|
6 |
features: path.filter(item => item.active && isMapPointDisplayable(item)).map((item) => { |
|
7 |
const catalogItem = item.catalogItem |
|
8 |
return { |
|
9 |
type: 'Feature', |
|
10 |
properties: { |
|
11 |
catalogItem: { |
|
12 |
id: catalogItem.id, |
|
13 |
name: catalogItem.name, |
|
14 |
allNames: catalogItem.allNames, |
|
15 |
description: catalogItem.description, |
|
16 |
latitude: catalogItem.latitude, |
|
17 |
longitude: catalogItem.longitude, |
|
18 |
}, |
|
19 |
idx: item.idx, |
|
20 |
displayable: isMapPointDisplayable(item), |
|
21 |
}, |
|
22 |
geometry: { |
|
23 |
type: 'Point', |
|
24 |
coordinates: [catalogItem.longitude, catalogItem.latitude], |
|
25 |
}, |
|
26 |
} |
|
27 |
}), |
|
28 |
}) |
|
29 |
|
|
30 |
const catalogItemValidationSchema = yup.object({ |
|
31 |
id: yup.string().required(), |
|
32 |
name: yup.string().required(), |
|
33 |
allNames: yup.array().of(yup.string()).required(), |
|
34 |
description: yup.string(), |
|
35 |
latitude: yup.number().required(), |
|
36 |
longitude: yup.number().required(), |
|
37 |
}) |
|
38 |
|
|
39 |
/** |
|
40 |
* Parses a GeoJson string and returns a list of MapPoints |
|
41 |
* @param geoJson loaded file |
|
42 |
* @returns |
|
43 |
*/ |
|
44 |
export const parseGeoJsonToPathVariant = (geoJson: string) => { |
|
45 |
const parsed = JSON.parse(geoJson) |
|
46 |
if (parsed.type !== 'FeatureCollection') { |
|
47 |
throw new Error('Invalid GeoJson') |
|
48 |
} |
|
49 |
const features = parsed.features |
|
50 |
if (!features) { |
|
51 |
throw new Error('Invalid GeoJson provided') |
|
52 |
} |
|
53 |
const path: PathVariant = features.map((feature: any) => { |
|
54 |
const catalogItemDto = feature.properties.catalogItem |
|
55 |
|
|
56 |
if (!catalogItemDto) { |
|
57 |
throw new Error('GeoJson file does not have a valid structure') |
|
58 |
} |
|
59 |
// validate catalog item |
|
60 |
const catalogItem = catalogItemValidationSchema.validateSync(catalogItemDto) |
|
61 |
|
|
62 |
return { |
|
63 |
idx: feature.properties.idx, |
|
64 |
active: true, |
|
65 |
catalogItem: { |
|
66 |
id: catalogItem.id, |
|
67 |
name: catalogItem.name, |
|
68 |
description: catalogItem.description, |
|
69 |
latitude: catalogItem.latitude, |
|
70 |
longitude: catalogItem.longitude, |
|
71 |
}, |
|
72 |
} |
|
73 |
}) |
|
74 |
return path |
|
75 |
} |
|
76 |
|
frontend/src/features/TrackingTool/Upload/PlaintextUpload.tsx | ||
---|---|---|
1 |
import { |
|
2 |
Button, |
|
3 |
Dialog, |
|
4 |
DialogContent, |
|
5 |
DialogTitle, |
|
6 |
Stack, |
|
7 |
TextField, |
|
8 |
} from '@mui/material' |
|
9 |
import { useFormik } from 'formik' |
|
10 |
import { Fragment, useEffect, useState } from 'react' |
|
11 |
import SendIcon from '@mui/icons-material/Send' |
|
12 |
import ClearIcon from '@mui/icons-material/Clear' |
|
13 |
import { useDispatch, useSelector } from 'react-redux' |
|
14 |
import { RootState } from '../../redux/store' |
|
15 |
import { sendTextForProcessing } from '../trackingToolThunks' |
|
16 |
import * as yup from 'yup' |
|
17 |
import { resetDialogApiCallSuccess } from '../trackingToolSlice' |
|
18 |
|
|
19 |
const PlaintextUpload = () => { |
|
20 |
const loading = useSelector( |
|
21 |
(state: RootState) => state.trackingTool.isLoading |
|
22 |
) |
|
23 |
const [open, setOpen] = useState(false) // controls whether the dialog is open |
|
24 |
|
|
25 |
// This controls whether to keep dialog open after sending the request to the API |
|
26 |
const dialogApiCallSuccess = useSelector( |
|
27 |
(state: RootState) => state.trackingTool.dialogApiCallSuccess |
|
28 |
) |
|
29 |
|
|
30 |
const dispatch = useDispatch() |
|
31 |
|
|
32 |
const validationSchema = yup.object().shape({ |
|
33 |
text: yup.mixed().required('Text is required'), |
|
34 |
}) |
|
35 |
|
|
36 |
const formik = useFormik({ |
|
37 |
initialValues: { |
|
38 |
text: '', |
|
39 |
}, |
|
40 |
validationSchema, |
|
41 |
onSubmit: async () => { |
|
42 |
// Dispatch the thunk |
|
43 |
dispatch(sendTextForProcessing(formik.values.text)) |
|
44 |
}, |
|
45 |
}) |
|
46 |
|
|
47 |
const onCloseDialog = () => { |
|
48 |
formik.resetForm() |
|
49 |
setOpen(false) |
|
50 |
} |
|
51 |
|
|
52 |
const resetForm = () => { |
|
53 |
formik.resetForm() |
|
54 |
} |
|
55 |
|
|
56 |
useEffect(() => { |
|
57 |
if (!dialogApiCallSuccess) { |
|
58 |
return |
|
59 |
} |
|
60 |
dispatch(resetDialogApiCallSuccess()) |
|
61 |
setOpen(false) |
|
62 |
}, [dialogApiCallSuccess, dispatch]) |
|
63 |
|
|
64 |
return ( |
|
65 |
<Fragment> |
|
66 |
<Button variant="contained" onClick={() => setOpen(true)}> |
|
67 |
Text |
|
68 |
</Button> |
|
69 |
<Dialog |
|
70 |
open={open} |
|
71 |
fullWidth={true} |
|
72 |
onClose={onCloseDialog} |
|
73 |
maxWidth="lg" |
|
74 |
> |
|
75 |
<DialogTitle>Plaintext Input</DialogTitle> |
|
76 |
<DialogContent> |
|
77 |
<form onSubmit={formik.handleSubmit}> |
|
78 |
<TextField |
|
79 |
sx={{ my: 2 }} |
|
80 |
fullWidth |
|
81 |
multiline |
|
82 |
label="Plaintext input" |
|
83 |
rows={10} |
|
84 |
name="text" |
|
85 |
value={formik.values.text} |
|
86 |
onChange={formik.handleChange} |
|
87 |
/> |
|
88 |
<Stack |
|
89 |
alignItems="flex-end" |
|
90 |
justifyContent="flex-end" |
|
91 |
spacing={2} |
|
92 |
direction="row" |
|
93 |
> |
|
94 |
<Button |
|
95 |
variant="contained" |
|
96 |
color="secondary" |
|
97 |
onClick={resetForm} |
|
98 |
startIcon={<ClearIcon />} |
|
99 |
> |
|
100 |
Clear |
|
101 |
</Button> |
|
102 |
<Button |
|
103 |
type="submit" |
|
104 |
variant="contained" |
|
105 |
startIcon={<SendIcon />} |
|
106 |
disabled={loading} |
|
107 |
> |
|
108 |
Submit |
|
109 |
</Button> |
|
110 |
</Stack> |
|
111 |
</form> |
|
112 |
</DialogContent> |
|
113 |
</Dialog> |
|
114 |
</Fragment> |
|
115 |
) |
|
116 |
} |
|
117 |
|
|
118 |
export default PlaintextUpload |
frontend/src/features/TrackingTool/buildPathVariants.ts | ||
---|---|---|
1 |
// Business logic for tracking tool |
|
2 |
|
|
3 |
import { CatalogItemDto, PathDto } from '../../swagger/data-contracts' |
|
4 |
|
|
5 |
// For more comprehensive code alias CatalogItemDto[] as path variant |
|
6 |
export type PathVariant = MapPoint[] |
|
7 |
|
|
8 |
export class MapPoint { |
|
9 |
constructor( |
|
10 |
public idx: number, |
|
11 |
public active: boolean, |
|
12 |
public catalogItem: CatalogItemDto |
|
13 |
) { } |
|
14 |
|
|
15 |
/** |
|
16 |
* @returns true if the map point is displayable - i.e. it has a valid lat/lng |
|
17 |
*/ |
|
18 |
get displayable() { |
|
19 |
return !!this.catalogItem.latitude && !!this.catalogItem.longitude |
|
20 |
} |
|
21 |
} |
|
22 |
|
|
23 |
/** |
|
24 |
* Cartesian product of two arrays |
|
25 |
* @param sets |
|
26 |
* @returns |
|
27 |
*/ |
|
28 |
const cartesianProduct = (sets: CatalogItemDto[][]): CatalogItemDto[][] => |
|
29 |
sets.reduce<CatalogItemDto[][]>( |
|
30 |
(results, ids) => |
|
31 |
results |
|
32 |
.map((result) => ids.map((id) => [...result, id])) |
|
33 |
.reduce((nested, result) => [...nested, ...result]), |
|
34 |
[[]] |
|
35 |
) |
|
36 |
|
|
37 |
/** |
|
38 |
* Builds a list of all possible path variants from pathDto |
|
39 |
* @param pathDto |
|
40 |
* @returns |
|
41 |
*/ |
|
42 |
export const buildPathVariants = (pathDto: PathDto): PathVariant[] => { |
|
43 |
if (!pathDto.foundCatalogItems) { |
|
44 |
return [] |
|
45 |
} |
|
46 |
|
|
47 |
return ( |
|
48 |
pathDto.foundCatalogItems.length === 1 |
|
49 |
? pathDto.foundCatalogItems |
|
50 |
: cartesianProduct(pathDto.foundCatalogItems) |
|
51 |
).map((variant, _) => |
|
52 |
variant.map( |
|
53 |
(catalogItem, idx) => |
|
54 |
new MapPoint( |
|
55 |
idx, |
|
56 |
!!catalogItem.latitude && !!catalogItem.longitude, |
|
57 |
catalogItem |
|
58 |
) |
|
59 |
) |
|
60 |
) |
|
61 |
} |
|
62 |
|
|
63 |
export default buildPathVariants |
frontend/src/features/TrackingTool/pathUtils.ts | ||
---|---|---|
1 |
// Business logic for tracking tool |
|
2 |
|
|
3 |
import { CatalogItemDto, PathDto } from '../../swagger/data-contracts' |
|
4 |
|
|
5 |
// For more comprehensive code alias CatalogItemDto[] as path variant |
|
6 |
export type PathVariant = MapPoint[] |
|
7 |
|
|
8 |
export interface MapPoint { |
|
9 |
idx: number, |
|
10 |
active: boolean, |
|
11 |
catalogItem: CatalogItemDto, |
|
12 |
} |
|
13 |
|
|
14 |
export const isMapPointDisplayable = (mapPoint: MapPoint): boolean => |
|
15 |
!!mapPoint.catalogItem.latitude && !!mapPoint.catalogItem.longitude |
|
16 |
|
|
17 |
/** |
|
18 |
* Cartesian product of two arrays |
|
19 |
* @param sets |
|
20 |
* @returns |
|
21 |
*/ |
|
22 |
const cartesianProduct = (sets: CatalogItemDto[][]): CatalogItemDto[][] => |
|
23 |
sets.reduce<CatalogItemDto[][]>( |
|
24 |
(results, ids) => |
|
25 |
results |
|
26 |
.map((result) => ids.map((id) => [...result, id])) |
|
27 |
.reduce((nested, result) => [...nested, ...result]), |
|
28 |
[[]] |
|
29 |
) |
|
30 |
|
|
31 |
/** |
|
32 |
* Builds a list of all possible path variants from pathDto |
|
33 |
* @param pathDto |
|
34 |
* @returns |
|
35 |
*/ |
|
36 |
export const buildPathVariants = (pathDto: PathDto): PathVariant[] => { |
|
37 |
if (!pathDto.foundCatalogItems) { |
|
38 |
return [] |
|
39 |
} |
|
40 |
|
|
41 |
return ( |
|
42 |
pathDto.foundCatalogItems.length === 1 |
|
43 |
? pathDto.foundCatalogItems |
|
44 |
: cartesianProduct(pathDto.foundCatalogItems) |
|
45 |
).map((variant, _) => |
|
46 |
variant.map( |
|
47 |
(catalogItem, idx) => ( |
|
48 |
{ |
|
49 |
idx, |
|
50 |
active: !!catalogItem.latitude && !!catalogItem.longitude, |
|
51 |
catalogItem |
|
52 |
}) |
|
53 |
) |
|
54 |
) |
|
55 |
} |
|
56 |
|
|
57 |
export default buildPathVariants |
frontend/src/features/TrackingTool/trackingToolSlice.ts | ||
---|---|---|
1 | 1 |
import { createSlice } from "@reduxjs/toolkit" |
2 | 2 |
import { LatLngTuple } from "leaflet" |
3 |
import { persistReducer } from "redux-persist" |
|
4 | 3 |
import mapConfig from "../../config/mapConfig" |
5 | 4 |
import { PathDto } from "../../swagger/data-contracts" |
6 |
import buildPathVariants, { MapPoint, PathVariant } from "./buildPathVariants"
|
|
5 |
import buildPathVariants, { isMapPointDisplayable, MapPoint, PathVariant } from "./pathUtils"
|
|
7 | 6 |
import { sendTextForProcessing } from "./trackingToolThunks" |
8 | 7 |
import storage from "redux-persist/lib/storage" |
8 |
import TrackingToolState from './TrackingToolState' |
|
9 | 9 |
|
10 |
export interface TrackingToolState { |
|
11 |
isLoading: boolean // whether the data is being loaded |
|
12 |
pathDto?: PathDto // the data |
|
13 |
pathVariants?: PathVariant[] // undefined signals that no path variants were yet fetched from the API |
|
14 |
lastError?: string // consumable for errors during thunks |
|
15 |
mapCenter: LatLngTuple // pair of latitude and longitude |
|
16 |
primaryPathIdx: number // index of the primary path |
|
17 |
// trigger to close the dialog when API call is finished |
|
18 |
dialogApiCallSuccess: boolean |
|
19 |
pathsPerPage: number // max number of paths to show on the map at once |
|
20 |
currentPage: number // current page of paths - starts from 0 |
|
21 |
} |
|
22 | 10 |
|
23 | 11 |
const defaultPathsPerPage = 5 |
24 | 12 |
|
... | ... | |
32 | 20 |
} |
33 | 21 |
|
34 | 22 |
const calculateMapCenter = (pathVariant: PathVariant): LatLngTuple | undefined => { |
35 |
const displayableItems = pathVariant.filter((item) => item.displayable)
|
|
23 |
const displayableItems = pathVariant.filter((item) => isMapPointDisplayable(item))
|
|
36 | 24 |
if (displayableItems.length === 0) { |
37 | 25 |
return undefined |
38 | 26 |
} |
... | ... | |
94 | 82 |
} |
95 | 83 |
}, |
96 | 84 |
clear: () => ({ ...initialState }), |
85 |
mergeWithCurrentPath: (state: TrackingToolState, action: { payload: PathVariant }) => { |
|
86 |
const { payload: jsonPath } = action |
|
87 |
if (!jsonPath) { |
|
88 |
return { ...state } |
|
89 |
} |
|
90 |
|
|
91 |
const pathVariants = [...state.pathVariants ?? []] |
|
92 |
let primaryPathIdx = state.primaryPathIdx |
|
93 |
let currentPage = state.currentPage |
|
94 |
|
|
95 |
// If there are no path append a new array to the pathVariants array and set primaryIdx to 0 |
|
96 |
if (pathVariants.length === 0) { |
|
97 |
primaryPathIdx = 0 |
|
98 |
currentPage = 0 |
|
99 |
pathVariants.push([]) |
|
100 |
} |
|
101 |
|
|
102 |
// Get the path and create a map to check whether some point with the same id already exists |
|
103 |
const path = pathVariants[primaryPathIdx] |
|
104 |
const pathMap = new Map(path.map((item) => [item.catalogItem.id as string, item])) |
|
105 |
|
|
106 |
// Create an array of items to be replaced and items to be added to the end |
|
107 |
const itemsToReplace: MapPoint[] = [] |
|
108 |
const itemsToAdd: MapPoint[] = [] |
|
109 |
jsonPath.forEach((item) => { |
|
110 |
if (!pathMap.has(item.catalogItem.id as string)) { |
|
111 |
itemsToAdd.push(item) |
|
112 |
return |
|
113 |
} |
|
114 |
|
|
115 |
const idx = pathMap.get(item.catalogItem.id as string)!.idx |
|
116 |
item.idx = idx |
|
117 |
itemsToReplace.push(item) |
|
118 |
}) |
|
119 |
|
|
120 |
// Iterate over items to replace and replace them |
|
121 |
const newPath = [...path] |
|
122 |
itemsToReplace.forEach((item) => { |
|
123 |
newPath[item.idx] = item |
|
124 |
}) |
|
125 |
|
|
126 |
// Add items to the end |
|
127 |
itemsToAdd.forEach((item) => { |
|
128 |
item.active = false |
|
129 |
item.idx = newPath.length |
|
130 |
newPath.push(item) |
|
131 |
}) |
|
132 |
|
|
133 |
// Return the new path |
|
134 |
return { |
|
135 |
...state, |
|
136 |
pathVariants: [ |
|
137 |
...pathVariants.slice(0, primaryPathIdx), |
|
138 |
newPath, |
|
139 |
...pathVariants.slice(primaryPathIdx + 1), |
|
140 |
], |
|
141 |
primaryPathIdx, // in case the list is empty |
|
142 |
currentPage, // in case the list is empty |
|
143 |
} |
|
144 |
} |
|
97 | 145 |
}, |
98 | 146 |
extraReducers: (builder) => { |
99 | 147 |
builder.addCase(sendTextForProcessing.fulfilled, (state, action) => { |
... | ... | |
128 | 176 |
}, |
129 | 177 |
}) |
130 | 178 |
|
131 |
export const { consumeErr, setPrimaryIdx, resetDialogApiCallSuccess, clear, updateMapMarker } = |
|
179 |
export const { consumeErr, setPrimaryIdx, resetDialogApiCallSuccess, clear, updateMapMarker, mergeWithCurrentPath } =
|
|
132 | 180 |
trackingToolSlice.actions |
133 | 181 |
const trackingToolReducer = trackingToolSlice.reducer |
134 | 182 |
export default trackingToolReducer |
frontend/src/features/TrackingTool/trackingToolThunks.ts | ||
---|---|---|
1 | 1 |
import { createAsyncThunk } from '@reduxjs/toolkit' |
2 | 2 |
import axiosInstance from '../../api/api' |
3 |
import { RootState } from '../redux/store' |
|
4 |
import { MapPoint, PathVariant } from './pathUtils' |
|
3 | 5 |
|
4 | 6 |
export const sendTextForProcessing = createAsyncThunk( |
5 | 7 |
'trackingTool/sendTextForProcessing', |
... | ... | |
17 | 19 |
} |
18 | 20 |
} |
19 | 21 |
) |
22 |
|
|
23 |
// export const mergeWithImportedPath = createAsyncThunk('trackingTool/mergeWithImportedPath', async (jsonPath: PathVariant, { getState }) => { |
|
24 |
// if (!jsonPath) { |
|
25 |
// return undefined |
|
26 |
// } |
|
27 |
|
|
28 |
// // Get current state |
|
29 |
// const state = getState() as RootState |
|
30 |
// const { primaryPathIdx, pathVariants } = state.trackingTool |
|
31 |
|
|
32 |
// // Return undefined if there is no pathVariants or index is out of range |
|
33 |
// if (!pathVariants || pathVariants.length === 0 || primaryPathIdx >= pathVariants.length) { |
|
34 |
// return undefined |
|
35 |
// } |
|
36 |
|
|
37 |
// // Get the path and create a map to check whether some point with the same id already exists |
|
38 |
// const path = pathVariants[primaryPathIdx] |
|
39 |
// const pathMap = new Map(path.map((item) => [item.catalogItem.id as string, item])) |
|
40 |
|
|
41 |
// // Create an array of items to be replaced and items to be added to the end |
|
42 |
// const itemsToReplace: MapPoint[] = [] |
|
43 |
// const itemsToAdd: MapPoint[] = [] |
|
44 |
|
|
45 |
// jsonPath.forEach((item) => { |
|
46 |
// if (pathMap.has(item.catalogItem.id as string)) { |
|
47 |
// // @ts-ignore - we know that the id is a string and typescript refuses to acknowledge that |
|
48 |
// item.idx = pathMap[item.catalogItem.id as string].idx |
|
49 |
// itemsToReplace.push(item) |
|
50 |
// } else { |
|
51 |
// itemsToAdd.push(item) |
|
52 |
// } |
|
53 |
// }) |
|
54 |
|
|
55 |
// // Iterate over items to replace and replace them |
|
56 |
// const newPath = [...path] |
|
57 |
// itemsToReplace.forEach((item) => { |
|
58 |
// newPath[item.idx] = item |
|
59 |
// }) |
|
60 |
|
|
61 |
// // Add items to the end |
|
62 |
// itemsToAdd.forEach((item) => { |
|
63 |
// item.active = false |
Také k dispozici: Unified diff
import export part 1
re #9741