Skip to content

Commit 8f64b65

Browse files
JerryVincentfreds-devscheidtdav
authored
Feat/download function (#540)
* get all devices in bounds - still need to make it typesafe * fetching data for download * working Download Code * fetching data for download * modified * updated * updates * working Download Code * Fixed unit error in csv * Eslint warnings fixed * Warnings solved * removed unwanted imports * warnings resolved * local changes * ADjusted height of Dialogue to match Screen size * removed unused code * Removed unwanted codes and comments * modified components * Modified download component * Removed EsLint Warnings * Moved convert file functions to Public Folder * Warnings fixed * refactor: remove unnecessary debounce * updated * Added spider cluster layer * Revert "Added spider cluster layer" This reverts commit 5790758. * feat: add minor accessibility improvements --------- Co-authored-by: freds-dev <[email protected]> Co-authored-by: David Scheidt <[email protected]>
1 parent 61da702 commit 8f64b65

File tree

13 files changed

+582
-29
lines changed

13 files changed

+582
-29
lines changed

app/components/device-detail/device-detail-box.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ import {
7474
import { useToast } from '../ui/use-toast'
7575
import EntryLogs from './entry-logs'
7676
import ShareLink from './share-link'
77+
import { useGlobalCompareMode } from './useGlobalCompareMode'
7778
import { type loader } from '~/routes/explore.$deviceId'
7879
import { type SensorWithLatestMeasurement } from '~/schema'
7980
import { getArchiveLink } from '~/utils/device'
80-
import { useGlobalCompareMode } from './useGlobalCompareMode'
8181

8282
export interface MeasurementProps {
8383
sensorId: string

app/components/device-detail/graph.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
import 'chartjs-adapter-date-fns'
1414
// import { de, enGB } from "date-fns/locale";
1515
import { Download, RefreshCcw, X } from 'lucide-react'
16-
import { useMemo, useRef, useState, useEffect, useContext, RefObject } from 'react'
16+
import { useMemo, useRef, useState, useEffect, useContext,type RefObject } from 'react'
1717
import { Scatter } from 'react-chartjs-2'
1818
import { isBrowser, isTablet } from 'react-device-detect'
1919
import Draggable, { type DraggableData } from 'react-draggable'

app/components/header/download.tsx

Lines changed: 327 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,327 @@
1+
import {type BBox } from 'geojson'
2+
import { Download as DownloadIcon } from 'lucide-react'
3+
import { useEffect, useState } from 'react'
4+
import { useTranslation } from 'react-i18next'
5+
import { useMap } from 'react-map-gl'
6+
import { Form, useNavigation, useActionData } from 'react-router'
7+
import { Button } from '../ui/button'
8+
import { Checkbox } from '../ui/checkbox'
9+
import {
10+
Dialog,
11+
DialogContent,
12+
DialogDescription,
13+
DialogFooter,
14+
DialogHeader,
15+
DialogTitle,
16+
DialogTrigger,
17+
} from '../ui/dialog'
18+
import { Input } from '../ui/input'
19+
import { Label } from '../ui/label'
20+
import {
21+
Select,
22+
SelectContent,
23+
SelectItem,
24+
SelectTrigger,
25+
SelectValue,
26+
} from '../ui/select'
27+
import { toast } from '../ui/use-toast'
28+
29+
// Custom Loading Animation Component
30+
const PulsingDownloadAnimation = () => {
31+
const { t } = useTranslation('download')
32+
return (
33+
<div className="flex items-center justify-center">
34+
<div className="relative">
35+
{/* Main download icon */}
36+
<div className="text-blue-600 animate-bounce">
37+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
38+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
39+
<polyline points="7 10 12 15 17 10"></polyline>
40+
<line x1="12" y1="15" x2="12" y2="3"></line>
41+
</svg>
42+
</div>
43+
44+
{/* Animated ripples */}
45+
<div className="absolute top-0 left-0 h-full w-full animate-ping rounded-full border-2 border-blue-400 opacity-75"></div>
46+
<div className="absolute top-0 left-0 h-full w-full animate-pulse rounded-full border border-blue-300 opacity-75" style={{ animationDelay: "0.3s" }}></div>
47+
48+
{/* Small data points moving toward the download icon */}
49+
<div className="absolute -top-4 -left-4 h-2 w-2 animate-ping rounded-full bg-blue-500" style={{ animationDelay: "0.1s" }}></div>
50+
<div className="absolute -top-4 left-0 h-2 w-2 animate-ping rounded-full bg-blue-500" style={{ animationDelay: "0.4s" }}></div>
51+
<div className="absolute -top-4 left-4 h-2 w-2 animate-ping rounded-full bg-blue-500" style={{ animationDelay: "0.7s" }}></div>
52+
</div>
53+
<span className="ml-3 text-blue-600 font-medium">{t('processingData')}</span>
54+
</div>
55+
);
56+
};
57+
58+
// Data Ready Animation
59+
const DataReadyAnimation = () => {
60+
const { t } = useTranslation('download')
61+
return (
62+
<div className="flex items-center justify-center text-light-blue">
63+
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="animate-pulse">
64+
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
65+
<polyline points="22 4.3 12 14.01 9 11.01"></polyline>
66+
</svg>
67+
<span className="ml-2">{t('readyToDownload')}</span>
68+
</div>
69+
);
70+
};
71+
72+
export default function Download(props: any) {
73+
const { t } = useTranslation('download')
74+
const actionData = useActionData()
75+
const navigation = useNavigation()
76+
const isLoading = navigation.state === "submitting"
77+
const devices = props.devices.features || []
78+
const { osem: mapRef } = useMap()
79+
80+
const [isDownloadReady, setIsDownloadReady] = useState(false)
81+
const [showReadyAnimation, setShowReadyAnimation] = useState(false)
82+
const [errorMessage, setErrorMessage] = useState<string | null>(null)
83+
84+
// Update download ready state when actionData changes
85+
useEffect(() => {
86+
if (actionData && actionData.error) {
87+
setErrorMessage(actionData.error)
88+
} else {
89+
setErrorMessage(null)
90+
// Only set download ready if there's no error
91+
if (actionData) {
92+
setIsDownloadReady(true)
93+
setShowReadyAnimation(true)
94+
}
95+
}
96+
}, [actionData])
97+
98+
// Reset download ready state when format changes
99+
const [format, setFormat] = useState<string>('csv')
100+
const handleFormatChange = (value: string) => {
101+
setFormat(value)
102+
setShowReadyAnimation(false)
103+
setIsDownloadReady(false)
104+
setErrorMessage(null);
105+
}
106+
107+
const [downloadStarted, setDownloadStarted] = useState(false)
108+
109+
// Add this function to handle download start
110+
const handleDownloadStart = () => {
111+
const Delay = 3500;
112+
setDownloadStarted(true)
113+
setShowReadyAnimation(false)
114+
toast({
115+
description: t('toast'),
116+
duration: Delay,
117+
variant:"success"
118+
})
119+
120+
// Reset the download started state after a delay
121+
setTimeout(() => {
122+
setDownloadStarted(false)
123+
setOpen(false)
124+
}, Delay)
125+
}
126+
127+
// Filter devices inside the current bounds
128+
const bounds = mapRef?.getMap().getBounds().toArray().flat() as BBox ?? undefined;
129+
const devicesInBounds =
130+
bounds && bounds.length === 4
131+
? devices.filter((device: any) => {
132+
// Ensure the device has coordinates
133+
134+
if (!device.geometry || !device.geometry.coordinates) return false
135+
136+
const [longitude, latitude] = device.geometry.coordinates
137+
138+
// Check if bounds are defined properly
139+
const [minLon, minLat] = bounds.slice(0, 2) // [minLongitude, minLatitude]
140+
const [maxLon, maxLat] = bounds.slice(2, 4) // [maxLongitude, maxLatitude]
141+
142+
return (
143+
longitude >= minLon &&
144+
longitude <= maxLon &&
145+
latitude >= minLat &&
146+
latitude <= maxLat
147+
)
148+
})
149+
: []
150+
151+
let deviceIDs: Array<string> = [];
152+
devicesInBounds.map((device: any) => {
153+
deviceIDs.push(device.properties.id);
154+
})
155+
156+
const [aggregate, setAggregate] = useState<string>('10m')
157+
const [fields, setFields] = useState({
158+
title: true,
159+
unit: true,
160+
value: true,
161+
timestamp: true,
162+
})
163+
const [open, setOpen] = useState(false)
164+
const handleFieldChange = (field: keyof typeof fields) => {
165+
setFields((prev) => ({ ...prev, [field]: !prev[field] }))
166+
setIsDownloadReady(false)
167+
setErrorMessage(null);
168+
setShowReadyAnimation(false);
169+
}
170+
171+
return (
172+
<Dialog open={open} onOpenChange={()=>{
173+
setOpen(!open);
174+
setIsDownloadReady(false);
175+
setErrorMessage(null);
176+
setShowReadyAnimation(false);}}>
177+
<DialogTrigger asChild className="pointer-events-auto" onClick={()=>setOpen(true)}>
178+
<div className="pointer-events-auto box-border h-10 w-10">
179+
<button
180+
type="button"
181+
className="h-10 w-10 rounded-full border border-green-700 bg-white text-center text-black hover:bg-slate-50 transition-all hover:shadow-md"
182+
aria-label={t('download')}
183+
>
184+
<DownloadIcon className="mx-auto h-6 w-6" />
185+
</button>
186+
</div>
187+
</DialogTrigger>
188+
<DialogContent className="max-w-1/2" style={{ maxHeight: '100vh', overflowY: 'auto' }}>
189+
<DialogHeader>
190+
<DialogTitle>{t('downloadOptions')}</DialogTitle>
191+
<DialogDescription>
192+
{t('downloadDescription')}
193+
</DialogDescription>
194+
</DialogHeader>
195+
<div className="grid gap-4 py-3">
196+
<Form action={'/explore'} method='post'>
197+
<div className="grid gap-2">
198+
<div className="flex justify-between items-center">
199+
<Label htmlFor='devices'>{t('devices')}</Label>
200+
<span className="text-sm text-blue-600 font-medium">{deviceIDs.length} 📡 {t('selected')}</span>
201+
</div>
202+
<Input type="text" id='devices' name='devices' value={deviceIDs} readOnly/>
203+
<Label htmlFor="format">{t('format')}</Label>
204+
<Select value={format} onValueChange={handleFormatChange} name='format'>
205+
<SelectTrigger id="format">
206+
<SelectValue placeholder={t('selectFormat')} />
207+
</SelectTrigger>
208+
<SelectContent>
209+
<SelectItem value="csv">CSV</SelectItem>
210+
<SelectItem value="json">JSON</SelectItem>
211+
<SelectItem value="txt">{t('text')}</SelectItem>
212+
</SelectContent>
213+
</Select>
214+
<Label htmlFor="aggregate">{t('aggregateTo')}</Label>
215+
<Select value={aggregate} onValueChange={(value) => { setAggregate(value); setIsDownloadReady(false); setErrorMessage(null); setShowReadyAnimation(false);}} name='aggregate'>
216+
<SelectTrigger id="aggregate">
217+
<SelectValue placeholder={t('aggregateTo')} />
218+
</SelectTrigger>
219+
<SelectContent>
220+
<SelectItem value="raw">{t('rawData')}</SelectItem>
221+
<SelectItem value="10m">{t('10minutes')}</SelectItem>
222+
<SelectItem value="1h">{t('1hour')}</SelectItem>
223+
<SelectItem value="1d">{t('1day')}</SelectItem>
224+
<SelectItem value="1m">{t('1month')}</SelectItem>
225+
<SelectItem value="1y">{t('1year')}</SelectItem>
226+
</SelectContent>
227+
</Select>
228+
</div>
229+
<div className="grid gap-2 mt-4">
230+
<fieldset className="grid grid-cols-2 gap-3" id='fields'>
231+
<legend>{t('fieldsToInclude')}</legend>
232+
<div className="flex items-center space-x-2">
233+
<Checkbox
234+
id="title"
235+
checked={fields.title}
236+
onCheckedChange={() => handleFieldChange('title')}
237+
name="title"
238+
/>
239+
<Label htmlFor="title" className="cursor-pointer">
240+
{t('title')}
241+
</Label>
242+
</div>
243+
<div className="flex items-center space-x-2">
244+
<Checkbox
245+
id="unit"
246+
checked={fields.unit}
247+
onCheckedChange={() => handleFieldChange('unit')}
248+
name="unit"
249+
/>
250+
<Label htmlFor="unit" className="cursor-pointer">
251+
{t('unit')}
252+
</Label>
253+
</div>
254+
<div className="flex items-center space-x-2">
255+
<Checkbox
256+
id="value"
257+
checked={fields.value}
258+
onCheckedChange={() => handleFieldChange('value')}
259+
name="value"
260+
/>
261+
<Label htmlFor="value" className="cursor-pointer">
262+
{t('value')}
263+
</Label>
264+
</div>
265+
<div className="flex items-center space-x-2">
266+
<Checkbox
267+
id="timestamp"
268+
checked={fields.timestamp}
269+
onCheckedChange={() => handleFieldChange('timestamp')}
270+
name="timestamp"
271+
/>
272+
<Label htmlFor="timestamp" className="cursor-pointer">
273+
{t('timestamp')}
274+
</Label>
275+
</div>
276+
</fieldset>
277+
</div>
278+
279+
<div className="h-16 flex items-center justify-center mt-2">
280+
{isLoading ? (
281+
<PulsingDownloadAnimation />
282+
) : showReadyAnimation ? (
283+
<DataReadyAnimation />
284+
) : null}
285+
</div>
286+
{errorMessage && (
287+
<div className="p-2 bg-red-100 border border-red-300 text-red-700 rounded flex items-center">
288+
<svg xmlns="http://www.w3.org/2000/svg" width="50" height="50" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2 text-red-500 animate-pulse">
289+
<circle cx="12" cy="12" r="10"></circle>
290+
<line x1="12" y1="8" x2="12" y2="12"></line>
291+
<line x1="12" y1="16" x2="12.01" y2="16"></line>
292+
</svg>
293+
<p>{t('error')} <a href={actionData?.link} className='text-blue-100' target='_blank'>{t('clickHere')}</a>{" "} {t('toGoToArchive')}</p>
294+
</div>
295+
)}
296+
<DialogFooter>
297+
<div className="w-full mt-4 flex items-center justify-center space-x-4">
298+
<Button
299+
type="submit"
300+
className="bg-blue-100 hover:bg-blue-200 transition-colors text-dark"
301+
disabled={isLoading || deviceIDs.length === 0}
302+
>
303+
{isLoading ? t('processing') : t('generateFile')}
304+
</Button>
305+
{actionData && isDownloadReady ? (
306+
<a
307+
href={actionData.href}
308+
download={actionData.download}
309+
className={`px-4 py-2 ${downloadStarted ? 'bg-blue-300 animate-pulse' : 'bg-green-100'} text-dark rounded hover:bg-green-400 transition-colors flex items-center`}
310+
onClick={handleDownloadStart}
311+
>
312+
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="mr-2">
313+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
314+
<polyline points="7 10 12 15 17 10"></polyline>
315+
<line x1="12" y1="15" x2="12" y2="3"></line>
316+
</svg>
317+
{downloadStarted ? t('downloading') : `${format.toUpperCase()} ${t('data')} ${t('download')}`}
318+
</a>
319+
) : null}
320+
</div>
321+
</DialogFooter>
322+
</Form>
323+
</div>
324+
</DialogContent>
325+
</Dialog>
326+
)
327+
}

app/components/header/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import Download from "./download";
12
import Home from "./home";
23
import Menu from "./menu";
34
import NavBar from "./nav-bar";
@@ -15,7 +16,8 @@ export default function Header(props: HeaderProps) {
1516
<div className="items-top pointer-events-none fixed z-10 flex h-14 w-full justify-between gap-4 p-2">
1617
<Home />
1718
<NavBar devices={props.devices} />
18-
<div className="flex">
19+
<div className="flex gap-2">
20+
<Download devices={props.devices} />
1921
{/* {data?.user?.email ? <Notification /> : null} */}
2022
<Menu />
2123
</div>

app/components/search/search-list.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ import { Cpu, Globe, MapPin } from 'lucide-react'
22
import { useState, useEffect, useCallback, useContext } from 'react'
33
import { useMap } from 'react-map-gl'
44
import { useMatches, useNavigate, useSearchParams } from 'react-router'
5-
5+
import { useGlobalCompareMode } from '../device-detail/useGlobalCompareMode'
66
import { NavbarContext } from '../header/nav-bar'
77
import useKeyboardNav from '../header/nav-bar/use-keyboard-nav'
88
import SearchListItem from './search-list-item'
99
import { goTo } from '~/lib/search-map-helper'
10-
import { useGlobalCompareMode } from '../device-detail/useGlobalCompareMode'
10+
1111

1212
interface SearchListProps {
1313
searchResultsLocation: any[]

app/lib/mobile-box-helper.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,3 @@
1-
import { getDistance } from "geolib";
2-
31
export interface LocationPoint {
42
geometry: {
53
x: number;

app/routes/explore.$deviceId.$sensorId.$.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { addDays } from 'date-fns'
22
import { redirect, type LoaderFunctionArgs, useLoaderData } from 'react-router'
33
import Graph from '~/components/device-detail/graph'
44
import MobileBoxView from '~/components/map/layers/mobile/mobile-box-view'
5-
import { categorizeIntoTrips, LocationPoint } from '~/lib/mobile-box-helper'
5+
import { categorizeIntoTrips, type LocationPoint} from '~/lib/mobile-box-helper'
66
import { getDevice } from '~/models/device.server'
77
import { getMeasurement } from '~/models/measurement.server'
88
import { getSensor } from '~/models/sensor.server'

0 commit comments

Comments
 (0)