From 4f01df9b1e5afe400c4fe37eb268fe48abd30e17 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Tue, 30 May 2023 16:19:41 +0100 Subject: [PATCH 001/208] Add experiment with timeline --- app/scripts/components/sandbox/index.js | 9 +- .../components/sandbox/timeline/datasets.ts | 110 ++++ .../components/sandbox/timeline/index.tsx | 56 ++ .../timeline/test-timeline-side-scrub.tsx | 555 ++++++++++++++++++ .../components/sandbox/timeline/timeline.tsx | 551 +++++++++++++++++ package.json | 2 + yarn.lock | 26 + 7 files changed, 1308 insertions(+), 1 deletion(-) create mode 100644 app/scripts/components/sandbox/timeline/datasets.ts create mode 100644 app/scripts/components/sandbox/timeline/index.tsx create mode 100644 app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx create mode 100644 app/scripts/components/sandbox/timeline/timeline.tsx diff --git a/app/scripts/components/sandbox/index.js b/app/scripts/components/sandbox/index.js index d759f93f6..9142fe8bf 100644 --- a/app/scripts/components/sandbox/index.js +++ b/app/scripts/components/sandbox/index.js @@ -17,6 +17,7 @@ import SandboxRequest from './request'; import SandboxColors from './colors'; import SandboxMDXEditor from './mdx-editor'; import SandboxTable from './table'; +import SandboxTimeline from './timeline'; import { resourceNotFound } from '$components/uhoh'; import { Card, CardList } from '$components/common/card'; import { Fold, FoldHeader, FoldTitle } from '$components/common/fold'; @@ -104,6 +105,12 @@ const pages = [ id: 'sandboxtable', name: 'Table', component: SandboxTable + }, + { + id: 'timeline', + name: 'Timeline', + component: SandboxTimeline, + noHero: true } ]; @@ -127,7 +134,7 @@ function SandboxLayout() { /> - + {!page.noHero ? : false} diff --git a/app/scripts/components/sandbox/timeline/datasets.ts b/app/scripts/components/sandbox/timeline/datasets.ts new file mode 100644 index 000000000..c6fdc84ca --- /dev/null +++ b/app/scripts/components/sandbox/timeline/datasets.ts @@ -0,0 +1,110 @@ +import { eachDayOfInterval } from 'date-fns'; + +export const datasets = [ + { + title: 'Monthly dataset', + timeDensity: 'month', + domain: [ + new Date('2020-01-01'), + new Date('2020-02-01'), + new Date('2020-03-01'), + new Date('2020-05-01'), + new Date('2020-06-01') + ] + }, + { + title: 'Daily dataset', + timeDensity: 'day', + domain: [ + new Date('2020-01-01'), + new Date('2020-01-02'), + new Date('2020-01-03'), + new Date('2020-01-04'), + new Date('2020-01-05'), + new Date('2020-01-07'), + new Date('2020-01-08'), + new Date('2020-01-09'), + new Date('2020-01-10'), + new Date('2020-01-11'), + new Date('2020-01-12'), + new Date('2020-01-13'), + new Date('2020-01-14'), + new Date('2020-01-15'), + new Date('2020-01-16'), + new Date('2020-01-19'), + new Date('2020-01-20'), + new Date('2020-01-21'), + new Date('2020-01-22'), + new Date('2020-01-23'), + new Date('2020-01-24'), + new Date('2020-01-25'), + new Date('2020-01-26'), + new Date('2020-01-27'), + new Date('2020-01-28'), + new Date('2020-01-29'), + new Date('2020-01-30'), + new Date('2020-01-31'), + new Date('2020-02-01'), + new Date('2020-02-02'), + new Date('2020-02-03'), + new Date('2020-02-04'), + new Date('2020-02-05'), + new Date('2020-02-06'), + new Date('2020-02-07'), + new Date('2020-02-08'), + new Date('2020-02-12'), + new Date('2020-02-13'), + new Date('2020-02-14'), + new Date('2020-02-15'), + new Date('2020-02-16'), + new Date('2020-02-17'), + new Date('2020-02-18'), + new Date('2020-02-19'), + new Date('2020-02-20'), + new Date('2020-02-22'), + new Date('2020-02-23'), + new Date('2020-02-24'), + new Date('2020-02-25'), + new Date('2020-02-26'), + new Date('2020-02-27'), + new Date('2020-02-28'), + new Date('2020-02-29'), + new Date('2020-03-01'), + new Date('2020-03-02'), + new Date('2020-03-03'), + new Date('2020-03-04'), + new Date('2020-03-05'), + new Date('2020-03-06'), + new Date('2020-03-07'), + new Date('2020-03-08') + ] + }, + { + title: 'Daily 2', + timeDensity: 'day', + domain: [ + new Date('2020-01-01'), + new Date('2020-02-01'), + new Date('2020-03-01'), + new Date('2020-05-01'), + new Date('2020-06-01') + ] + }, + { + title: 'Daily 3', + timeDensity: 'day', + domain: eachDayOfInterval({ + start: new Date('2020-01-01'), + end: new Date('2021-01-01') + }) + } +]; + +export const extraDataset = { + title: 'Daily infinity!', + timeDensity: 'day', + domain: eachDayOfInterval({ + start: new Date('2000-01-01'), + end: new Date('2021-12-12') + }) +}; diff --git a/app/scripts/components/sandbox/timeline/index.tsx b/app/scripts/components/sandbox/timeline/index.tsx new file mode 100644 index 000000000..c1772cb08 --- /dev/null +++ b/app/scripts/components/sandbox/timeline/index.tsx @@ -0,0 +1,56 @@ +import { + CollecticonArrowMove, + CollecticonEqual +} from '@devseed-ui/collecticons'; +import { themeVal } from '@devseed-ui/theme-provider'; +import React, { useRef } from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import styled from 'styled-components'; +import Timeline from './timeline'; + +const Container = styled.div` + display: flex; + flex-flow: column; + flex-grow: 1; + + .panel-wrapper { + flex-grow: 1; + } + + .panel { + display: flex; + flex-direction: column; + } + + .resize-handle { + flex: 0 0 1.5em; + position: relative; + outline: none; + display: flex; + background: ${themeVal('color.base-100')}; + align-items: center; + justify-content: center; + } +`; + +function SandboxTimeline() { + return ( + + + +
+ Top +
+
+ + + + + + +
+
+ ); +} + +export default SandboxTimeline; diff --git a/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx b/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx new file mode 100644 index 000000000..3681c0b97 --- /dev/null +++ b/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx @@ -0,0 +1,555 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import styled, { css, useTheme } from 'styled-components'; +import useDimensions from 'react-cool-dimensions'; +import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; +import { datasets as srcDatasets } from './datasets'; +import { + ScaleTime, + ZoomTransform, + axisBottom, + axisTop, + create, + drag, + easeCubicIn, + extent, + scaleLinear, + scalePow, + scaleTime, + select, + zoom +} from 'd3'; +import { + clamp, + endOfDay, + endOfMonth, + endOfYear, + format, + isWithinInterval, + startOfDay, + startOfMonth, + startOfYear +} from 'date-fns'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; +import { Button } from '@devseed-ui/button'; + +const TimelineWrapper = styled.div` + position: relative; + flex-grow: 1; + display: flex; + flex-flow: column; + height: 100%; + + svg { + display: block; + } +`; + +const InteractionRect = styled.div` + position: absolute; + inset: 0; + left: 20rem; + background-color: rgba(255, 0, 0, 0.08); + z-index: 1000; +`; + +const TimelineHeader = styled.header` + display: flex; + flex-shrink: 0; + box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; +`; + +const TimelineDetails = styled.div` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + padding: ${glsp(0.5)}; +`; + +const Headline = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const TimelineControls = styled.div` + width: 100%; + display: flex; + flex-flow: column; + + .date-axis { + margin-top: auto; + } +`; + +const TimelineContent = styled.div` + height: 100%; + min-height: 0; + display: flex; + width: 100%; + position: relative; +`; + +const TimelineContentInner = styled.div` + height: 100%; + min-height: 0; + display: flex; + overflow-y: scroll; + overflow-x: hidden; + width: 100%; + position: relative; +`; + +const DatasetList = styled.ul` + ${listReset()} + width: 100%; + ${({ gridBg }) => + gridBg && + css` + background-image: url('${gridBg}'); + background-repeat: repeat-y; + background-position-x: 20rem; + `} + + li { + display: flex; + box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; + } +`; + +const DatasetInfo = styled.div` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + padding: ${glsp(0.5)}; +`; + +const DatasetData = styled.div` + padding: ${glsp(0.25, 0)}; +`; + +const DatasetSvg = styled.svg``; + +const GridSvg = styled.svg` + position: absolute; + right: 0; + height: 100%; + pointer-events: none; +`; + +function Timeline() { + const [datasets, setDatasets] = useState(srcDatasets); + + const { observe, width, height } = useDimensions(); + + const interactionRef = useRef(null); + const axisSvgRef = useRef(null); + const datasetsContainerRef = useRef(null); + + const theme = useTheme(); + + const [selectedDay, setSelectedDay] = useState(); + + const [zoomTransform, setZoomTransform] = useState({ + x: 0, + y: 0, + k: 1 + }); + + const dataDomain = useMemo( + () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], + [datasets] + ); + + const domainDays = useMemo( + () => (dataDomain[1].getTime() - dataDomain[0].getTime()) / 86400000, + [dataDomain] + ); + + const xMain = useMemo(() => { + return scaleTime().domain(dataDomain).range([0, width]); + }, [dataDomain, width]); + + const xScaled = useMemo(() => { + return rescaleX(xMain, zoomTransform.x, zoomTransform.k); + }, [xMain, zoomTransform.x, zoomTransform.k]); + + const xAxis = useMemo(() => { + return xScaled ? axisBottom(xScaled) : undefined; + }, [xScaled]); + + const zoomBehavior = useMemo(() => { + return ( + zoom() + // Make the maximum zoom level as such as each day has maximum of 100px. + .scaleExtent([1, 100 / (width / domainDays)]) + .translateExtent([ + [0, 0], + [width, height] + ]) + .extent([ + [0, 0], + [width, height] + ]) + .filter((event) => { + if (event.type === 'wheel' && !event.altKey) { + // The zoom behavior traps the scroll event. Propagate to the data + // container to scroll it. + if (datasetsContainerRef.current) { + datasetsContainerRef.current.scrollBy(0, event.deltaY); + } + return false; + } + return true; + }) + .on('zoom', function (event) { + const { sourceEvent } = event; + + if (sourceEvent?.type === 'wheel') { + // Alt key plus wheel makes the browser go back in history. Prevent. + if (sourceEvent.altKey) { + sourceEvent.preventDefault(); + } + } + const { x, y, k } = event.transform; + setZoomTransform((t) => + isEqualTransform(t, { x, y, k }) ? t : { x, y, k } + ); + }) + ); + }, [width, height, domainDays]); + + useEffect(() => { + if (!interactionRef.current) return; + + select(interactionRef.current) + .call(zoomBehavior) + .on('dblclick.zoom', null) + .on('click', (event) => { + const d = xScaled?.invert(event.layerX); + d && setSelectedDay(startOfDay(d)); + }) + .on('wheel', function (event) { + // Wheel is triggered when an horizontal wheel is used or when shift + // wheel is used. The zoom event is only for vertical wheel so we have + // to mimic the pan behavior. + if (event.altKey) { + event.preventDefault(); + } + + const element = select(this); + // Get the current zoom transform. + const currentT = element.property('__zoom'); + // Apply the delta to the x axis and then constrains according to the + // zoom definition. + const updatedT = new ZoomTransform( + currentT.k, + currentT.x - event.deltaX, + currentT.y + ); + const constrainFn = zoomBehavior.constrain(); + // Constrain the transform according to the timeline bounds. + const newTransform = constrainFn( + updatedT, + [ + [0, 0], + [width, height] + ], + zoomBehavior.translateExtent() + ); + + // Apply transform which will cause the zoom event to be emitted without + // a sourceEvent. + zoomBehavior.transform(element, newTransform); + }); + }, [width, height, xScaled, zoomBehavior]); + + useEffect(() => { + if (!interactionRef.current) return; + + // Get the current zoom transform. + const element = select(interactionRef.current); + const currentT = element.property('__zoom'); + + // Programmatically update if different, meaning that it came from setting + // the state. + if (!isEqualTransform(currentT, zoomTransform)) { + const { x, y, k } = zoomTransform; + zoomBehavior.transform(element, new ZoomTransform(k, x, y)); + } + }, [zoomBehavior, zoomTransform]); + + useEffect(() => { + if (!xAxis) return; + select(axisSvgRef.current).select('.x.axis').call(xAxis); + }, [xAxis]); + + return ( + + + {selectedDay ? ( + { + // zoomBehavior.translateBy(select(interactionRef.current), val * -1, 0); + }} + /> + ) : ( + false + )} + + + +

Datasets

{' '} + +
+

X of Y

+
+ +
{selectedDay ? format(selectedDay, 'yyyy-MM-dd') : null}
+ + + +
+
+ + {xScaled ? ( + + {xScaled.ticks().map((tick) => ( + + ))} + + ) : null} + + + {datasets.map((dataset) => ( +
  • + {dataset.title} + + + {dataset.domain.map((date) => { + const [start, end] = getBlockBoundaries( + date, + dataset.timeDensity + ); + const s = xScaled(start); + const e = xScaled(end); + + const isSelected = selectedDay + ? isWithinInterval(selectedDay, { start, end }) + : false; + + const strokeWidth = 2; + return ( + + + + + ); + })} + + +
  • + ))} +
    +
    +
    +
    + ); +} + +export default Timeline; + +const TimelineHeadSVG = styled.svg` + position: absolute; + right: 0; + top: 2rem; + height: 100%; + pointer-events: none; + z-index: 2000; +`; + +function TimelineHead(props: any) { + const { + domain, + xScaled, + selectedDay, + width, + setSelectedDay, + onDistance + } = props; + + const rectRef = useRef(null); + const fnRef = useRef(onDistance); + fnRef.current = onDistance; + + useEffect(() => { + if (!rectRef.current) return; + + const anim = createAnimation(12); + + const dragger = drag() + .on('start', function dragstarted() { + document.body.style.cursor = 'grabbing'; + select(this).attr('cursor', 'grabbing'); + }) + .on('drag', function dragged(event) { + if (event.x < 0 || event.x > width) { + anim.run(() => { + // How much is out of bounds? + const excess = event.x > width ? event.x - width : event.x; + const val = dragDistanceScaler(excess); + console.log('x', event.x, 'excess', excess, 'val', val); + fnRef.current(val); + }); + return; + } + anim.stop(); + + const dx = event.x - event.subject.x; + const currPos = xScaled(selectedDay); + const newPos = currPos + dx; + + const dateFromPos = startOfDay(xScaled.invert(newPos)); + + const [start, end] = domain; + const interval = { start, end }; + + const newDate = clamp(dateFromPos, interval); + + if (selectedDay.getTime() !== newDate.getTime()) { + setSelectedDay(newDate); + } + }) + .on('end', function dragended() { + document.body.style.cursor = ''; + select(this).attr('cursor', 'grab'); + anim.stop(); + }); + + select(rectRef.current).call(dragger); + }, [width, domain, selectedDay, setSelectedDay, xScaled]); + + return ( + + + + + ); +} + +/** + * Rescales the given scale according to the given factors. + * @param scale Scale to rescale + * @param x X factor + * @param k Scale factor + * @returns new scale + */ +function rescaleX(scale, x, k) { + const range = scale.range(); + return scale.copy().domain( + range.map((v) => { + // New value after scaling + const value = (v - x) / k; + // Clamp value to the range + const valueClamped = Math.max(range[0], Math.min(value, range[1])); + return scale.invert(valueClamped); + }) + ); +} + +function isEqualTransform(t1, t2) { + return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; +} + +function getBlockBoundaries(date, timeDensity) { + switch (timeDensity) { + case 'month': + return [startOfMonth(date), endOfMonth(date)]; + case 'year': + return [startOfYear(date), endOfYear(date)]; + } + + return [startOfDay(date), endOfDay(date)]; +} + +function createAnimation(fps) { + let running = false; + let lastRun; + const frameTime = 1000 / fps; + let rafId; + let callback; + + return { + run(fn) { + callback = fn; + running = true; + function animate(timestamp) { + if (!running) return; + + const elapsed = lastRun ? timestamp - lastRun : null; + if (elapsed === null || elapsed >= frameTime) { + lastRun = timestamp; + callback?.(); + } + rafId = requestAnimationFrame(animate); + } + rafId = requestAnimationFrame(animate); + }, + stop() { + running = false; + cancelAnimationFrame(rafId); + } + }; +} + +function dragDistanceScaler(value) { + if (value === 0) return 0; + + const scale = scalePow() + .domain([-200, 0, 0, 200]) + .range([-100, -10, 10, 100]) + .clamp(true) + .exponent(2.5); + + return scale(value); +} diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx new file mode 100644 index 000000000..2f5b64271 --- /dev/null +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -0,0 +1,551 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import styled, { useTheme } from 'styled-components'; +import useDimensions from 'react-cool-dimensions'; +import { Reorder, useDragControls } from 'framer-motion'; +import { + ZoomTransform, + axisBottom, + drag, + extent, + scaleTime, + select, + zoom +} from 'd3'; +import { + addDays, + clamp, + endOfDay, + endOfMonth, + endOfYear, + format, + isWithinInterval, + startOfDay, + startOfMonth, + startOfYear, + subDays +} from 'date-fns'; +import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; +import { + CollecticonGripVertical, + CollecticonPlusSmall +} from '@devseed-ui/collecticons'; +import { Button } from '@devseed-ui/button'; + +import { extraDataset, datasets as srcDatasets } from './datasets'; + +const TimelineWrapper = styled.div` + position: relative; + flex-grow: 1; + display: flex; + flex-flow: column; + height: 100%; + + svg { + display: block; + } +`; + +const InteractionRect = styled.div` + position: absolute; + inset: 0; + left: 20rem; + background-color: rgba(255, 0, 0, 0.08); + z-index: 1000; +`; + +const TimelineHeader = styled.header` + display: flex; + flex-shrink: 0; + box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; +`; + +const TimelineDetails = styled.div` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + padding: ${glsp(0.5)}; +`; + +const Headline = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const TimelineControls = styled.div` + width: 100%; + display: flex; + flex-flow: column; + min-width: 0; + + .date-axis { + margin-top: auto; + } +`; + +const TimelineContent = styled.div` + height: 100%; + min-height: 0; + display: flex; + width: 100%; + position: relative; +`; + +const TimelineContentInner = styled.div` + height: 100%; + min-height: 0; + display: flex; + overflow-y: scroll; + overflow-x: hidden; + width: 100%; + position: relative; +`; + +const DatasetListSelf = styled.ul` + ${listReset()} + width: 100%; + + li { + display: flex; + box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; + } +`; + +const DatasetInfo = styled.div` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + padding: ${glsp(0.5)}; + display: flex; + align-items: center; + gap: 0.5rem; + + ${CollecticonGripVertical} { + cursor: grab; + color: ${themeVal('color.base-300')}; + + &:active { + cursor: grabbing; + } + } +`; + +const DatasetData = styled.div` + padding: ${glsp(0.25, 0)}; +`; + +const DatasetSvg = styled.svg``; + +const GridSvg = styled.svg` + position: absolute; + right: 0; + height: 100%; + pointer-events: none; +`; + +function Timeline() { + const [datasets, setDatasets] = useState(srcDatasets); + + const { observe, width, height } = useDimensions(); + + const interactionRef = useRef(null); + const axisSvgRef = useRef(null); + const datasetsContainerRef = useRef(null); + + const theme = useTheme(); + + const [selectedDay, setSelectedDay] = useState(); + + const [zoomTransform, setZoomTransform] = useState({ + x: 0, + y: 0, + k: 1 + }); + + const dataDomain = useMemo( + () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], + [datasets] + ); + + const domainDays = useMemo( + () => (dataDomain[1].getTime() - dataDomain[0].getTime()) / 86400000, + [dataDomain] + ); + + const xMain = useMemo(() => { + return scaleTime().domain(dataDomain).range([0, width]); + }, [dataDomain, width]); + + const xScaled = useMemo(() => { + return rescaleX(xMain, zoomTransform.x, zoomTransform.k); + }, [xMain, zoomTransform.x, zoomTransform.k]); + + const xAxis = useMemo(() => { + return xScaled ? axisBottom(xScaled) : undefined; + }, [xScaled]); + + const zoomBehavior = useMemo(() => { + return ( + zoom() + // Make the maximum zoom level as such as each day has maximum of 100px. + .scaleExtent([1, 100 / (width / domainDays)]) + .translateExtent([ + [0, 0], + [width, height] + ]) + .extent([ + [0, 0], + [width, height] + ]) + .filter((event) => { + if (event.type === 'wheel' && !event.altKey) { + // The zoom behavior traps the scroll event. Propagate to the data + // container to scroll it. + if (datasetsContainerRef.current) { + datasetsContainerRef.current.scrollBy(0, event.deltaY); + } + return false; + } + return true; + }) + .on('zoom', function (event) { + const { sourceEvent } = event; + + if (sourceEvent?.type === 'wheel') { + // Alt key plus wheel makes the browser go back in history. Prevent. + if (sourceEvent.altKey) { + sourceEvent.preventDefault(); + } + } + const { x, y, k } = event.transform; + setZoomTransform((t) => + isEqualTransform(t, { x, y, k }) ? t : { x, y, k } + ); + }) + ); + }, [width, height, domainDays]); + + useEffect(() => { + if (!interactionRef.current) return; + + select(interactionRef.current) + .call(zoomBehavior) + .on('dblclick.zoom', null) + .on('click', (event) => { + const d = xScaled?.invert(event.layerX); + d && setSelectedDay(startOfDay(d)); + }) + .on('wheel', function (event) { + // Wheel is triggered when an horizontal wheel is used or when shift + // wheel is used. The zoom event is only for vertical wheel so we have + // to mimic the pan behavior. + if (event.altKey) { + event.preventDefault(); + } + + const element = select(this); + // Get the current zoom transform. + const currentT = element.property('__zoom'); + // Apply the delta to the x axis and then constrains according to the + // zoom definition. + const updatedT = new ZoomTransform( + currentT.k, + currentT.x - event.deltaX, + currentT.y + ); + const constrainFn = zoomBehavior.constrain(); + // Constrain the transform according to the timeline bounds. + const newTransform = constrainFn( + updatedT, + [ + [0, 0], + [width, height] + ], + zoomBehavior.translateExtent() + ); + + // Apply transform which will cause the zoom event to be emitted without + // a sourceEvent. + zoomBehavior.transform(element, newTransform); + }); + }, [width, height, xScaled, zoomBehavior]); + + useEffect(() => { + if (!interactionRef.current) return; + + // Get the current zoom transform. + const element = select(interactionRef.current); + const currentT = element.property('__zoom'); + + // Programmatically update if different, meaning that it came from setting + // the state. + if (!isEqualTransform(currentT, zoomTransform)) { + const { x, y, k } = zoomTransform; + zoomBehavior.transform(element, new ZoomTransform(k, x, y)); + } + }, [zoomBehavior, zoomTransform]); + + useEffect(() => { + if (!xAxis) return; + select(axisSvgRef.current).select('.x.axis').call(xAxis); + }, [xAxis]); + + return ( + + + {selectedDay ? ( + + ) : ( + false + )} + + + +

    Datasets

    {' '} + +
    +

    X of Y

    +
    + +
    {selectedDay ? format(selectedDay, 'yyyy-MM-dd') : null}
    + + + +
    +
    + + {xScaled ? ( + + {xScaled.ticks().map((tick) => ( + + ))} + + ) : null} + + + + +
    + ); +} + +export default Timeline; + +function DatasetList(props: any) { + const { datasets, ...rest } = props; + + const [orderedDatasets, setOrderDatasets] = useState(datasets); + + useEffect(() => { + setOrderDatasets(datasets); + }, [datasets]); + + return ( + + {orderedDatasets.map((dataset) => ( + + ))} + + ); +} + +function DatasetListItem(props: any) { + const { dataset, width, xScaled, selectedDay } = props; + + const controls = useDragControls(); + + // Limit the items to render to increase performance. + const domainToRender = useMemo(() => { + const domain = xScaled.domain(); + const start = subDays(domain[0], 1); + const end = addDays(domain[1], 1); + return dataset.domain.filter((d) => { + return isWithinInterval(d, { start, end }); + }); + }, [xScaled, dataset]); + + return ( + + + controls.start(e)} /> + {dataset.title} + + + + {domainToRender.map((date) => { + const [start, end] = getBlockBoundaries(date, dataset.timeDensity); + const s = xScaled(start); + const e = xScaled(end); + + const isSelected = selectedDay + ? isWithinInterval(selectedDay, { start, end }) + : false; + + const strokeWidth = 2; + return ( + + + + + ); + })} + + + + ); +} + +const TimelineHeadSVG = styled.svg` + position: absolute; + right: 0; + top: 2rem; + height: 100%; + pointer-events: none; + z-index: 2000; +`; + +function TimelineHead(props: any) { + const { domain, xScaled, selectedDay, width, setSelectedDay } = props; + + const rectRef = useRef(null); + + useEffect(() => { + if (!rectRef.current) return; + + const dragger = drag() + .on('start', function dragstarted() { + document.body.style.cursor = 'grabbing'; + select(this).attr('cursor', 'grabbing'); + }) + .on('drag', function dragged(event) { + if (event.x < 0 || event.x > width) { + return; + } + + const dx = event.x - event.subject.x; + const currPos = xScaled(selectedDay); + const newPos = currPos + dx; + + const dateFromPos = startOfDay(xScaled.invert(newPos)); + + const [start, end] = domain; + const interval = { start, end }; + + const newDate = clamp(dateFromPos, interval); + + if (selectedDay.getTime() !== newDate.getTime()) { + setSelectedDay(newDate); + } + }) + .on('end', function dragended() { + document.body.style.cursor = ''; + select(this).attr('cursor', 'grab'); + }); + + select(rectRef.current).call(dragger); + }, [width, domain, selectedDay, setSelectedDay, xScaled]); + + return ( + + + + + ); +} + +/** + * Rescales the given scale according to the given factors. + * @param scale Scale to rescale + * @param x X factor + * @param k Scale factor + * @returns new scale + */ +function rescaleX(scale, x, k) { + const range = scale.range(); + return scale.copy().domain( + range.map((v) => { + // New value after scaling + const value = (v - x) / k; + // Clamp value to the range + const valueClamped = Math.max(range[0], Math.min(value, range[1])); + return scale.invert(valueClamped); + }) + ); +} + +function isEqualTransform(t1, t2) { + return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; +} + +function getBlockBoundaries(date, timeDensity) { + switch (timeDensity) { + case 'month': + return [startOfMonth(date), endOfMonth(date)]; + case 'year': + return [startOfYear(date), endOfYear(date)]; + } + + return [startOfDay(date), endOfDay(date)]; +} diff --git a/package.json b/package.json index 7808e15a7..8a7b5e002 100644 --- a/package.json +++ b/package.json @@ -133,6 +133,7 @@ "d3-scale-chromatic": "^3.0.0", "date-fns": "^2.28.0", "file-saver": "^2.0.5", + "framer-motion": "^10.12.21", "geojson-validation": "^1.0.2", "google-polyline": "^1.0.3", "history": "^5.1.0", @@ -158,6 +159,7 @@ "react-indiana-drag-scroll": "^2.2.0", "react-lazyload": "^3.2.0", "react-nl2br": "^1.0.2", + "react-resizable-panels": "^0.0.45", "react-router": "^6.0.0", "react-router-dom": "^6.0.0", "react-transition-group": "^4.4.2", diff --git a/yarn.lock b/yarn.lock index c20d61031..6011432f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1452,6 +1452,13 @@ dependencies: "@devseed-ui/theme-provider" "^4.1.0" +"@emotion/is-prop-valid@^0.8.2": + version "0.8.8" + resolved "http://3.220.121.64:4873/@emotion%2fis-prop-valid/-/is-prop-valid-0.8.8.tgz#db28b1c4368a259b60a97311d6a952d4fd01ac1a" + integrity sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA== + dependencies: + "@emotion/memoize" "0.7.4" + "@emotion/is-prop-valid@^1.1.0": version "1.1.3" resolved "http://verdaccio.ds.io:4873/@emotion%2fis-prop-valid/-/is-prop-valid-1.1.3.tgz#f0907a416368cf8df9e410117068e20fe87c0a3a" @@ -1459,6 +1466,11 @@ dependencies: "@emotion/memoize" "^0.7.4" +"@emotion/memoize@0.7.4": + version "0.7.4" + resolved "http://3.220.121.64:4873/@emotion%2fmemoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" + integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== + "@emotion/memoize@^0.7.4": version "0.7.5" resolved "http://verdaccio.ds.io:4873/@emotion%2fmemoize/-/memoize-0.7.5.tgz#2c40f81449a4e554e9fc6396910ed4843ec2be50" @@ -6485,6 +6497,15 @@ fragment-cache@^0.2.1: dependencies: map-cache "^0.2.2" +framer-motion@^10.12.21: + version "10.12.21" + resolved "http://3.220.121.64:4873/framer-motion/-/framer-motion-10.12.21.tgz#ea36b8db4dbb561d5fac7a978c2cd467f6d39a1b" + integrity sha512-EmnP73O5+1OGm2jtQNoBPPuAJvhySl+p4/9PL7PPJHt58nkPWeFaxhCJaUDXDf6N3jSLluefxopc0FrMCQ+/tQ== + dependencies: + tslib "^2.4.0" + optionalDependencies: + "@emotion/is-prop-valid" "^0.8.2" + fs-extra@^10.0.0: version "10.1.0" resolved "http://verdaccio.ds.io:4873/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" @@ -10630,6 +10651,11 @@ react-refresh@^0.9.0: resolved "http://verdaccio.ds.io:4873/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf" integrity sha512-Gvzk7OZpiqKSkxsQvO/mbTN1poglhmAV7gR/DdIrRrSMXraRQQlfikRJOr3Nb9GTMPC5kof948Zy6jJZIFtDvQ== +react-resizable-panels@^0.0.45: + version "0.0.45" + resolved "http://3.220.121.64:4873/react-resizable-panels/-/react-resizable-panels-0.0.45.tgz#4dc67df5cf09b1dd8c549224d44bde5287973366" + integrity sha512-ZgeW43qQemzRDLPnVc75l0U49r6u4I1WAfon2M9e6858atJCimXANsj6xxdEBXUd+bWptIRQ0JjXL3Q625cDnA== + react-resize-detector@^7.1.2: version "7.1.2" resolved "http://verdaccio.ds.io:4873/react-resize-detector/-/react-resize-detector-7.1.2.tgz#8ef975dd8c3d56f9a5160ac382ef7136dcd2d86c" From d5f1e485457de2004934568535d3e594f6db2bac Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 17 Jul 2023 17:37:48 +0100 Subject: [PATCH 002/208] Style dataset list items and timeline heads --- .../components/common/mapbox/layer-legend.tsx | 4 +- .../sandbox/timeline/dataset-list-item.tsx | 239 +++++++++++++++ .../components/sandbox/timeline/index.tsx | 23 +- .../sandbox/timeline/timeline-head.tsx | 197 ++++++++++++ .../components/sandbox/timeline/timeline.tsx | 280 +++++------------- 5 files changed, 521 insertions(+), 222 deletions(-) create mode 100644 app/scripts/components/sandbox/timeline/dataset-list-item.tsx create mode 100644 app/scripts/components/sandbox/timeline/timeline-head.tsx diff --git a/app/scripts/components/common/mapbox/layer-legend.tsx b/app/scripts/components/common/mapbox/layer-legend.tsx index 994c2b525..9548ec863 100644 --- a/app/scripts/components/common/mapbox/layer-legend.tsx +++ b/app/scripts/components/common/mapbox/layer-legend.tsx @@ -277,7 +277,7 @@ export function LayerLegendContainer(props: LayerLegendContainerProps) { ); } -function LayerCategoricalGraphic(props: LayerLegendCategorical) { +export function LayerCategoricalGraphic(props: LayerLegendCategorical) { const { stops } = props; return ( @@ -308,7 +308,7 @@ function LayerCategoricalGraphic(props: LayerLegendCategorical) { ); } -function LayerGradientGraphic(props: LayerLegendGradient) { +export function LayerGradientGraphic(props: LayerLegendGradient) { const { stops, min, max, unit } = props; const [hoverVal, setHoverVal] = useState(0); diff --git a/app/scripts/components/sandbox/timeline/dataset-list-item.tsx b/app/scripts/components/sandbox/timeline/dataset-list-item.tsx new file mode 100644 index 000000000..2a50ade2c --- /dev/null +++ b/app/scripts/components/sandbox/timeline/dataset-list-item.tsx @@ -0,0 +1,239 @@ +import React, { useMemo, useState } from 'react'; +import { Reorder, useDragControls } from 'framer-motion'; +import styled, { useTheme } from 'styled-components'; +import { + addDays, + isWithinInterval, + subDays, + endOfDay, + endOfMonth, + endOfYear, + startOfDay, + startOfMonth, + startOfYear, + areIntervalsOverlapping +} from 'date-fns'; +import { + CollecticonEye, + CollecticonEyeDisabled, + CollecticonGripVertical +} from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Toolbar, ToolbarIconButton } from '@devseed-ui/toolbar'; +import { Heading } from '@devseed-ui/typography'; +import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; + +function getBlockBoundaries(date, timeDensity) { + switch (timeDensity) { + case 'month': + return [startOfMonth(date), endOfMonth(date)]; + case 'year': + return [startOfYear(date), endOfYear(date)]; + } + + return [startOfDay(date), endOfDay(date)]; +} + +const DatasetItem = styled.article` + width: 100%; + display: flex; + position: relative; + + ::before, + ::after { + position: absolute; + content: ''; + display: block; + width: 100%; + background: ${themeVal('color.base-200')}; + height: 1px; + } + + ::before { + top: 0; + } + + ::after { + bottom: -1px; + } +`; + +const DatasetHeader = styled.header` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + background: ${themeVal('color.surface')}; + padding: ${glsp(0.5)}; + display: flex; + align-items: center; + gap: 0.5rem; + + ${CollecticonGripVertical} { + cursor: grab; + color: ${themeVal('color.base-300')}; + + &:active { + cursor: grabbing; + } + } +`; + +const DatasetInfo = styled.div` + width: 100%; + display: flex; + flex-flow: column; + gap: 0.5rem; +`; + +const DatasetHeadline = styled.div` + display: flex; + justify-content: space-between; + gap: ${glsp()}; +`; + +const DatasetData = styled.div` + padding: ${glsp(0.25, 0)}; + display: flex; + align-items: center; +`; + +const DatasetSvg = styled.svg``; + +export function DatasetListItem(props: any) { + const { dataset, width, xScaled, selectedDay } = props; + + const [isVisible, setVisible] = useState(true); + + const controls = useDragControls(); + + return ( + + + + controls.start(e)} /> + + + + {dataset.title} + + + setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + + )} + + + + + + + + + + + + ); +} + +const datasetTrackBlockHeight = 16; +function DatasetTrack(props: any) { + const { width, xScaled, dataset, selectedDay, isVisible } = props; + + // Limit the items to render to increase performance. + const domainToRender = useMemo(() => { + const domain = xScaled.domain(); + const start = subDays(domain[0], 1); + const end = addDays(domain[1], 1); + + return dataset.domain.filter((d) => { + const [blockStart, blockEnd] = getBlockBoundaries(d, dataset.timeDensity); + + return areIntervalsOverlapping( + { + start: blockStart, + end: blockEnd + }, + { start, end } + ); + }); + }, [xScaled, dataset]); + + return ( + + {domainToRender.map((date) => ( + + ))} + + ); +} + +function DatasetTrackBlock(props: any) { + const { xScaled, date, dataset, selectedDay, isVisible } = props; + + const [start, end] = getBlockBoundaries(date, dataset.timeDensity); + const s = xScaled(start); + const e = xScaled(end); + + const isSelected = selectedDay + ? isWithinInterval(selectedDay, { start, end }) + : false; + + const fill = useFillColors(isSelected, isVisible); + + return ( + + + + ); +} + +const useFillColors = ( + isSelected: boolean, + isVisible: boolean +): string | undefined => { + const theme = useTheme(); + + if (!isVisible) { + return theme.color?.['base-200']; + } + + if (isSelected) { + return theme.color?.primary; + } + + return theme.color?.['base-400']; +}; diff --git a/app/scripts/components/sandbox/timeline/index.tsx b/app/scripts/components/sandbox/timeline/index.tsx index c1772cb08..827651099 100644 --- a/app/scripts/components/sandbox/timeline/index.tsx +++ b/app/scripts/components/sandbox/timeline/index.tsx @@ -1,11 +1,8 @@ -import { - CollecticonArrowMove, - CollecticonEqual -} from '@devseed-ui/collecticons'; import { themeVal } from '@devseed-ui/theme-provider'; -import React, { useRef } from 'react'; +import React from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import styled from 'styled-components'; + import Timeline from './timeline'; const Container = styled.div` @@ -27,9 +24,19 @@ const Container = styled.div` position: relative; outline: none; display: flex; - background: ${themeVal('color.base-100')}; + background: #fff; align-items: center; justify-content: center; + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; + + ::before { + content: ''; + display: block; + width: 2rem; + background: ${themeVal('color.base-200')}; + height: 0.25rem; + border-radius: ${themeVal('shape.ellipsoid')}; + } } `; @@ -42,9 +49,7 @@ function SandboxTimeline() { Top - - - + diff --git a/app/scripts/components/sandbox/timeline/timeline-head.tsx b/app/scripts/components/sandbox/timeline/timeline-head.tsx new file mode 100644 index 000000000..3f9e7466d --- /dev/null +++ b/app/scripts/components/sandbox/timeline/timeline-head.tsx @@ -0,0 +1,197 @@ +import React, { useEffect, useRef } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { drag, select } from 'd3'; +import { clamp, startOfDay } from 'date-fns'; +import { themeVal } from '@devseed-ui/theme-provider'; + +const TimelineHeadSVG = styled.svg` + position: absolute; + right: 0; + top: 0; + height: 100%; + pointer-events: none; + z-index: 2000; +`; + +const dropShadowFilter = + 'drop-shadow(0px 2px 2px rgba(44, 62, 80, 0.08)) drop-shadow(0px 0px 4px rgba(44, 62, 80, 0.08))'; + +export function TimelineHead(props: any) { + const { domain, xScaled, selectedDay, width, setSelectedDay, children } = + props; + + const theme = useTheme(); + const rectRef = useRef(null); + + useEffect(() => { + if (!rectRef.current) return; + + const dragger = drag() + .on('start', function dragstarted() { + document.body.style.cursor = 'grabbing'; + select(this).attr('cursor', 'grabbing'); + }) + .on('drag', function dragged(event) { + if (event.x < 0 || event.x > width) { + return; + } + + const dx = event.x - event.subject.x; + const currPos = xScaled(selectedDay); + const newPos = currPos + dx; + + const dateFromPos = startOfDay(xScaled.invert(newPos)); + + const [start, end] = domain; + const interval = { start, end }; + + const newDate = clamp(dateFromPos, interval); + + if (selectedDay.getTime() !== newDate.getTime()) { + setSelectedDay(newDate); + } + }) + .on('end', function dragended() { + document.body.style.cursor = ''; + select(this).attr('cursor', 'grab'); + }); + + select(rectRef.current).call(dragger); + }, [width, domain, selectedDay, setSelectedDay, xScaled]); + + return ( + + + {/* */} + + {children} + + + ); +} + +export function TimelineHeadP(props: any) { + const theme = useTheme(); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const { children, ...rest } = props; + + return ( + + + + P + + + ); +} + +export function TimelineHeadL(props: any) { + const theme = useTheme(); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const { children, ...rest } = props; + + return ( + + + + L + + + ); +} + +export function TimelineHeadR(props: any) { + const theme = useTheme(); + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const { children, ...rest } = props; + + return ( + + + + R + + + ); +} + +const TimelineRangeTrackSelf = styled.div` + position: absolute; + right: 0; + + .shaded { + position: absolute; + background: ${themeVal('color.base-100')}; + height: 1rem; + } +`; + +export function TimelineRangeTrack(props: any) { + const { range, xScaled, width } = props; + + const start = xScaled(range.start); + const end = xScaled(range.end); + + return ( + +
    + + ); +} diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx index 2f5b64271..05c87b2f4 100644 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -1,37 +1,22 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import styled, { useTheme } from 'styled-components'; import useDimensions from 'react-cool-dimensions'; -import { Reorder, useDragControls } from 'framer-motion'; -import { - ZoomTransform, - axisBottom, - drag, - extent, - scaleTime, - select, - zoom -} from 'd3'; -import { - addDays, - clamp, - endOfDay, - endOfMonth, - endOfYear, - format, - isWithinInterval, - startOfDay, - startOfMonth, - startOfYear, - subDays -} from 'date-fns'; +import { Reorder } from 'framer-motion'; +import { ZoomTransform, axisBottom, extent, scaleTime, select, zoom } from 'd3'; +import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { - CollecticonGripVertical, - CollecticonPlusSmall -} from '@devseed-ui/collecticons'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { extraDataset, datasets as srcDatasets } from './datasets'; +import { DatasetListItem } from './dataset-list-item'; +import { + TimelineHead, + TimelineHeadL, + TimelineHeadP, + TimelineHeadR, + TimelineRangeTrack +} from './timeline-head'; const TimelineWrapper = styled.div` position: relative; @@ -49,7 +34,7 @@ const InteractionRect = styled.div` position: absolute; inset: 0; left: 20rem; - background-color: rgba(255, 0, 0, 0.08); + /* background-color: rgba(255, 0, 0, 0.08); */ z-index: 1000; `; @@ -104,38 +89,8 @@ const TimelineContentInner = styled.div` const DatasetListSelf = styled.ul` ${listReset()} width: 100%; - - li { - display: flex; - box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; - } `; -const DatasetInfo = styled.div` - width: 20rem; - flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - padding: ${glsp(0.5)}; - display: flex; - align-items: center; - gap: 0.5rem; - - ${CollecticonGripVertical} { - cursor: grab; - color: ${themeVal('color.base-300')}; - - &:active { - cursor: grabbing; - } - } -`; - -const DatasetData = styled.div` - padding: ${glsp(0.25, 0)}; -`; - -const DatasetSvg = styled.svg``; - const GridSvg = styled.svg` position: absolute; right: 0; @@ -156,6 +111,14 @@ function Timeline() { const [selectedDay, setSelectedDay] = useState(); + const [selectedInterval, setSelectedInterval] = useState<{ + start: Date; + end: Date; + }>({ + start: new Date('2020-03-03'), + end: new Date('2020-08-03') + }); + const [zoomTransform, setZoomTransform] = useState({ x: 0, y: 0, @@ -293,18 +256,6 @@ function Timeline() { return ( - {selectedDay ? ( - - ) : ( - false - )} @@ -333,6 +284,54 @@ function Timeline() { + {selectedDay ? ( + + ) : ( + false + )} + { + setSelectedInterval((interval) => { + const prevDay = sub(interval.end, { days: 1 }); + return { + ...interval, + start: isAfter(d, prevDay) ? prevDay : d + }; + }); + }} + selectedDay={selectedInterval.start} + width={width} + /> + { + setSelectedInterval((interval) => { + const nextDay = add(interval.start, { days: 1 }); + return { + ...interval, + end: isBefore(d, nextDay) ? nextDay : d + }; + }); + }} + selectedDay={selectedInterval.end} + width={width} + /> + + + {xScaled ? ( {xScaled.ticks().map((tick) => ( @@ -385,136 +384,6 @@ function DatasetList(props: any) { ); } -function DatasetListItem(props: any) { - const { dataset, width, xScaled, selectedDay } = props; - - const controls = useDragControls(); - - // Limit the items to render to increase performance. - const domainToRender = useMemo(() => { - const domain = xScaled.domain(); - const start = subDays(domain[0], 1); - const end = addDays(domain[1], 1); - return dataset.domain.filter((d) => { - return isWithinInterval(d, { start, end }); - }); - }, [xScaled, dataset]); - - return ( - - - controls.start(e)} /> - {dataset.title} - - - - {domainToRender.map((date) => { - const [start, end] = getBlockBoundaries(date, dataset.timeDensity); - const s = xScaled(start); - const e = xScaled(end); - - const isSelected = selectedDay - ? isWithinInterval(selectedDay, { start, end }) - : false; - - const strokeWidth = 2; - return ( - - - - - ); - })} - - - - ); -} - -const TimelineHeadSVG = styled.svg` - position: absolute; - right: 0; - top: 2rem; - height: 100%; - pointer-events: none; - z-index: 2000; -`; - -function TimelineHead(props: any) { - const { domain, xScaled, selectedDay, width, setSelectedDay } = props; - - const rectRef = useRef(null); - - useEffect(() => { - if (!rectRef.current) return; - - const dragger = drag() - .on('start', function dragstarted() { - document.body.style.cursor = 'grabbing'; - select(this).attr('cursor', 'grabbing'); - }) - .on('drag', function dragged(event) { - if (event.x < 0 || event.x > width) { - return; - } - - const dx = event.x - event.subject.x; - const currPos = xScaled(selectedDay); - const newPos = currPos + dx; - - const dateFromPos = startOfDay(xScaled.invert(newPos)); - - const [start, end] = domain; - const interval = { start, end }; - - const newDate = clamp(dateFromPos, interval); - - if (selectedDay.getTime() !== newDate.getTime()) { - setSelectedDay(newDate); - } - }) - .on('end', function dragended() { - document.body.style.cursor = ''; - select(this).attr('cursor', 'grab'); - }); - - select(rectRef.current).call(dragger); - }, [width, domain, selectedDay, setSelectedDay, xScaled]); - - return ( - - - - - ); -} - /** * Rescales the given scale according to the given factors. * @param scale Scale to rescale @@ -538,14 +407,3 @@ function rescaleX(scale, x, k) { function isEqualTransform(t1, t2) { return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; } - -function getBlockBoundaries(date, timeDensity) { - switch (timeDensity) { - case 'month': - return [startOfMonth(date), endOfMonth(date)]; - case 'year': - return [startOfYear(date), endOfYear(date)]; - } - - return [startOfDay(date), endOfDay(date)]; -} From 15b6aaae63e7741af3cdc27e482d7ccd73447716 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 19 Jul 2023 09:31:30 +0100 Subject: [PATCH 003/208] Add datepickers --- .../components/sandbox/timeline/index.tsx | 14 ++-- .../sandbox/timeline/timeline-head.tsx | 4 +- .../components/sandbox/timeline/timeline.tsx | 71 ++++++++++++++++--- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/app/scripts/components/sandbox/timeline/index.tsx b/app/scripts/components/sandbox/timeline/index.tsx index 827651099..d02f147a2 100644 --- a/app/scripts/components/sandbox/timeline/index.tsx +++ b/app/scripts/components/sandbox/timeline/index.tsx @@ -19,15 +19,21 @@ const Container = styled.div` flex-direction: column; } + .panel-timeline { + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; + } + .resize-handle { - flex: 0 0 1.5em; + flex: 0; position: relative; outline: none; display: flex; - background: #fff; align-items: center; justify-content: center; - box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; + width: 5rem; + margin: 0 auto -1.25rem auto; + padding: 0.25rem 0; + z-index: 5000; ::before { content: ''; @@ -50,7 +56,7 @@ function SandboxTimeline() {
    - + diff --git a/app/scripts/components/sandbox/timeline/timeline-head.tsx b/app/scripts/components/sandbox/timeline/timeline-head.tsx index 3f9e7466d..d2c88ed11 100644 --- a/app/scripts/components/sandbox/timeline/timeline-head.tsx +++ b/app/scripts/components/sandbox/timeline/timeline-head.tsx @@ -10,7 +10,7 @@ const TimelineHeadSVG = styled.svg` top: 0; height: 100%; pointer-events: none; - z-index: 2000; + z-index: 200; `; const dropShadowFilter = @@ -194,4 +194,4 @@ export function TimelineRangeTrack(props: any) { />
    ); -} +} \ No newline at end of file diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx index 05c87b2f4..08aa25de7 100644 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -5,13 +5,14 @@ import { Reorder } from 'framer-motion'; import { ZoomTransform, axisBottom, extent, scaleTime, select, zoom } from 'd3'; import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; +import { CollecticonChevronDownSmall, CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; +import { Heading } from '@devseed-ui/typography'; +import { DatePicker } from '@devseed-ui/date-picker'; import { extraDataset, datasets as srcDatasets } from './datasets'; import { DatasetListItem } from './dataset-list-item'; import { - TimelineHead, TimelineHeadL, TimelineHeadP, TimelineHeadR, @@ -34,8 +35,9 @@ const InteractionRect = styled.div` position: absolute; inset: 0; left: 20rem; + top: 3.5rem; /* background-color: rgba(255, 0, 0, 0.08); */ - z-index: 1000; + z-index: 100; `; const TimelineHeader = styled.header` @@ -48,7 +50,7 @@ const TimelineDetails = styled.div` width: 20rem; flex-shrink: 0; box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - padding: ${glsp(0.5)}; + padding: ${glsp(0.5, 0.5, 0.5, 2)}; `; const Headline = styled.div` @@ -68,6 +70,22 @@ const TimelineControls = styled.div` } `; +const ControlsToolbar = styled.div` + display: flex; + justify-content: space-between; + padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; +`; + +const DatePickerButton = styled(Button)` + gap: ${glsp(0.5)}; + + .head-reference { + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + font-size: 0.875rem; + } +`; + const TimelineContent = styled.div` height: 100%; min-height: 0; @@ -109,7 +127,7 @@ function Timeline() { const theme = useTheme(); - const [selectedDay, setSelectedDay] = useState(); + const [selectedDay, setSelectedDay] = useState(null); const [selectedInterval, setSelectedInterval] = useState<{ start: Date; @@ -259,7 +277,9 @@ function Timeline() { -

    Datasets

    {' '} + + Datasets +

    X of Y

    -
    {selectedDay ? format(selectedDay, 'yyyy-MM-dd') : null}
    + + { + setSelectedDay(d.start); + }} + renderTriggerElement={(props, label) => ( + + P + {label} + + + )} + /> + { + setSelectedInterval(d); + }} + isClearable={false} + isRange + alignment='right' + renderTriggerElement={(props) => ( + + L + {format(selectedInterval.start, 'MMM do, yyyy')} + R + {format(selectedInterval.end, 'MMM do, yyyy')} + + + )} + /> + + From 32e87b536d88bdcd502fa172ee6df3a0ef84caed Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Thu, 20 Jul 2023 13:23:06 +0100 Subject: [PATCH 004/208] Add custom date axis --- .../sandbox/timeline/dataset-list-item.tsx | 17 +- .../components/sandbox/timeline/date-axis.tsx | 172 ++++++++++++++++++ .../sandbox/timeline/timeline-head.tsx | 61 ++++--- .../components/sandbox/timeline/timeline.tsx | 48 +---- 4 files changed, 216 insertions(+), 82 deletions(-) create mode 100644 app/scripts/components/sandbox/timeline/date-axis.tsx diff --git a/app/scripts/components/sandbox/timeline/dataset-list-item.tsx b/app/scripts/components/sandbox/timeline/dataset-list-item.tsx index 2a50ade2c..5ee226f8e 100644 --- a/app/scripts/components/sandbox/timeline/dataset-list-item.tsx +++ b/app/scripts/components/sandbox/timeline/dataset-list-item.tsx @@ -195,17 +195,13 @@ function DatasetTrack(props: any) { } function DatasetTrackBlock(props: any) { - const { xScaled, date, dataset, selectedDay, isVisible } = props; + const { xScaled, date, dataset, isVisible } = props; const [start, end] = getBlockBoundaries(date, dataset.timeDensity); const s = xScaled(start); const e = xScaled(end); - const isSelected = selectedDay - ? isWithinInterval(selectedDay, { start, end }) - : false; - - const fill = useFillColors(isSelected, isVisible); + const fill = useFillColors(isVisible); return ( @@ -221,19 +217,12 @@ function DatasetTrackBlock(props: any) { ); } -const useFillColors = ( - isSelected: boolean, - isVisible: boolean -): string | undefined => { +const useFillColors = (isVisible: boolean): string | undefined => { const theme = useTheme(); if (!isVisible) { return theme.color?.['base-200']; } - if (isSelected) { - return theme.color?.primary; - } - return theme.color?.['base-400']; }; diff --git a/app/scripts/components/sandbox/timeline/date-axis.tsx b/app/scripts/components/sandbox/timeline/date-axis.tsx new file mode 100644 index 000000000..8bcf5f1d9 --- /dev/null +++ b/app/scripts/components/sandbox/timeline/date-axis.tsx @@ -0,0 +1,172 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ScaleTime } from 'd3'; +import { + format, + isSameMonth, + isSameYear, + startOfMonth, + startOfYear +} from 'date-fns'; +import { themeVal } from '@devseed-ui/theme-provider'; + +const GridLine = styled.line` + stroke: ${themeVal('color.base-200')}; +`; + +const DateAxisSVG = styled.svg` + text { + font-size: 0.75rem; + fill: ${themeVal('color.base')}; + + &.parent { + font-size: 0.625rem; + } + } +`; + +const GridSvg = styled.svg` + position: absolute; + right: 0; + height: 100%; + pointer-events: none; +`; + +enum TimeDensity { + YEAR = 'year', + MONTH = 'month', + DAY = 'day' +} + +function getTimeDensity(domain) { + if (domain.every((d) => d.getTime() === startOfYear(d).getTime())) { + return TimeDensity.YEAR; + } else if (domain.every((d) => d.getTime() === startOfMonth(d).getTime())) { + return TimeDensity.MONTH; + } else { + return TimeDensity.DAY; + } +} + +function timeDensityFormat(date: Date, timeDensity: TimeDensity) { + switch (timeDensity) { + case TimeDensity.YEAR: + return format(date, 'yyyy'); + case TimeDensity.MONTH: + return format(date, 'MMM dd'); + case TimeDensity.DAY: + return format(date, 'iii dd'); + } +} + +/** + * Returns date ticks that are spaced out enough to be readable taking into + * account a minimum width for each tick of 60px. + * If the width of each tick is less than 60px, every other tick is returned. + * + * @param scale The scale to get the ticks from. + * @returns Date[] + */ +function getTicks(scale: ScaleTime) { + const [min, max] = scale.range(); + const width = max - min; + + return scale + .ticks() + .filter((v, i, a) => (width / a.length < 60 ? !(i % 2) : true)); +} + +interface DateAxisProps { + xScaled: ScaleTime; + width: number; +} + +export function DateAxis(props: DateAxisProps) { + const { xScaled, width } = props; + + const ticks = getTicks(xScaled); + const axisDensity = getTimeDensity(ticks); + + return ( + + {ticks.map((d) => { + const xPos = xScaled(d); + return ( + + + + {timeDensityFormat(d, axisDensity)} + + + + ); + })} + + ); +} + +interface ParentIndicatorProps { + timeDensity: TimeDensity; + date: Date; + domain: Date[]; + xScaled: ScaleTime; +} + +function ParentIndicator(props: ParentIndicatorProps) { + const { timeDensity, date, domain, xScaled } = props; + + if (timeDensity === TimeDensity.YEAR) return <>{false}; + + let dateFormat: string; + if (timeDensity === TimeDensity.MONTH) { + // Only render the first date for a given month. + // Get the first date that has the same month as the one being rendered and + // check if they're the same. + const firstDate = domain.find((d) => isSameYear(d, date)); + if (firstDate !== date) return <>{false}; + + dateFormat = 'yyyy'; + } else { + const firstDate = domain.find((d) => isSameMonth(d, date)); + if (firstDate !== date) return <>{false}; + + dateFormat = 'MMM yyyy'; + } + + return ( + + {format(date, dateFormat)} + + ); +} + +interface DateGridProps { + xScaled: ScaleTime; + width: number; +} + +export function DateGrid(props: DateGridProps) { + const { width, xScaled } = props; + + return ( + + {getTicks(xScaled).map((tick) => { + const xPos = xScaled(tick); + return ( + + ); + })} + + ); +} diff --git a/app/scripts/components/sandbox/timeline/timeline-head.tsx b/app/scripts/components/sandbox/timeline/timeline-head.tsx index d2c88ed11..4bffda5d6 100644 --- a/app/scripts/components/sandbox/timeline/timeline-head.tsx +++ b/app/scripts/components/sandbox/timeline/timeline-head.tsx @@ -1,14 +1,14 @@ import React, { useEffect, useRef } from 'react'; import styled, { useTheme } from 'styled-components'; -import { drag, select } from 'd3'; +import { drag, ScaleTime, select } from 'd3'; import { clamp, startOfDay } from 'date-fns'; import { themeVal } from '@devseed-ui/theme-provider'; const TimelineHeadSVG = styled.svg` position: absolute; right: 0; - top: 0; - height: 100%; + top: -1rem; + height: calc(100% + 1rem); pointer-events: none; z-index: 200; `; @@ -16,7 +16,16 @@ const TimelineHeadSVG = styled.svg` const dropShadowFilter = 'drop-shadow(0px 2px 2px rgba(44, 62, 80, 0.08)) drop-shadow(0px 0px 4px rgba(44, 62, 80, 0.08))'; -export function TimelineHead(props: any) { +interface TimelineHeadProps { + domain: [Date, Date]; + xScaled: ScaleTime; + selectedDay: Date; + width: number; + setSelectedDay: (date: Date) => void; + children: React.ReactNode; +} + +export function TimelineHead(props: TimelineHeadProps) { const { domain, xScaled, selectedDay, width, setSelectedDay, children } = props; @@ -68,15 +77,6 @@ export function TimelineHead(props: any) { y2='100%' stroke={theme.color?.base} /> - {/* */} {children} @@ -84,13 +84,11 @@ export function TimelineHead(props: any) { ); } -export function TimelineHeadP(props: any) { +export function TimelineHeadP(props: Omit) { const theme = useTheme(); - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const { children, ...rest } = props; return ( - + ) { const theme = useTheme(); - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const { children, ...rest } = props; return ( - + ) { const theme = useTheme(); - /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ - const { children, ...rest } = props; - return ( - + ; + width: number; +} + +export function TimelineRangeTrack(props: TimelineRangeTrackProps) { const { range, xScaled, width } = props; const start = xScaled(range.start); @@ -194,4 +195,4 @@ export function TimelineRangeTrack(props: any) { /> ); -} \ No newline at end of file +} diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx index 08aa25de7..cb6605726 100644 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -5,7 +5,10 @@ import { Reorder } from 'framer-motion'; import { ZoomTransform, axisBottom, extent, scaleTime, select, zoom } from 'd3'; import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { CollecticonChevronDownSmall, CollecticonPlusSmall } from '@devseed-ui/collecticons'; +import { + CollecticonChevronDownSmall, + CollecticonPlusSmall +} from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { Heading } from '@devseed-ui/typography'; import { DatePicker } from '@devseed-ui/date-picker'; @@ -18,6 +21,7 @@ import { TimelineHeadR, TimelineRangeTrack } from './timeline-head'; +import { DateAxis, DateGrid } from './date-axis'; const TimelineWrapper = styled.div` position: relative; @@ -33,9 +37,10 @@ const TimelineWrapper = styled.div` const InteractionRect = styled.div` position: absolute; - inset: 0; left: 20rem; top: 3.5rem; + bottom: 0; + right: 0; /* background-color: rgba(255, 0, 0, 0.08); */ z-index: 100; `; @@ -109,24 +114,14 @@ const DatasetListSelf = styled.ul` width: 100%; `; -const GridSvg = styled.svg` - position: absolute; - right: 0; - height: 100%; - pointer-events: none; -`; - function Timeline() { const [datasets, setDatasets] = useState(srcDatasets); const { observe, width, height } = useDimensions(); const interactionRef = useRef(null); - const axisSvgRef = useRef(null); const datasetsContainerRef = useRef(null); - const theme = useTheme(); - const [selectedDay, setSelectedDay] = useState(null); const [selectedInterval, setSelectedInterval] = useState<{ @@ -161,10 +156,6 @@ function Timeline() { return rescaleX(xMain, zoomTransform.x, zoomTransform.k); }, [xMain, zoomTransform.x, zoomTransform.k]); - const xAxis = useMemo(() => { - return xScaled ? axisBottom(xScaled) : undefined; - }, [xScaled]); - const zoomBehavior = useMemo(() => { return ( zoom() @@ -266,11 +257,6 @@ function Timeline() { } }, [zoomBehavior, zoomTransform]); - useEffect(() => { - if (!xAxis) return; - select(axisSvgRef.current).select('.x.axis').call(xAxis); - }, [xAxis]); - return ( @@ -333,9 +319,7 @@ function Timeline() { /> - - - +
    @@ -387,20 +371,8 @@ function Timeline() { width={width} /> - {xScaled ? ( - - {xScaled.ticks().map((tick) => ( - - ))} - - ) : null} + + Date: Mon, 31 Jul 2023 15:02:12 +0100 Subject: [PATCH 005/208] Add right space to timeline --- app/scripts/components/sandbox/index.js | 4 +- .../components/sandbox/timeline/constants.ts | 14 +++++ .../sandbox/timeline/dataset-list-item.tsx | 51 +++++++++++++------ .../components/sandbox/timeline/date-axis.tsx | 10 ++-- .../sandbox/timeline/timeline-head.tsx | 6 ++- .../components/sandbox/timeline/timeline.tsx | 42 +++++++++++++-- app/scripts/utils/use-effect-previous.ts | 6 +-- 7 files changed, 100 insertions(+), 33 deletions(-) create mode 100644 app/scripts/components/sandbox/timeline/constants.ts diff --git a/app/scripts/components/sandbox/index.js b/app/scripts/components/sandbox/index.js index 9142fe8bf..113e2abbb 100644 --- a/app/scripts/components/sandbox/index.js +++ b/app/scripts/components/sandbox/index.js @@ -110,7 +110,8 @@ const pages = [ id: 'timeline', name: 'Timeline', component: SandboxTimeline, - noHero: true + noHero: true, + noFooter: true } ]; @@ -124,6 +125,7 @@ function SandboxLayout() { <> ; +} -export function DatasetListItem(props: any) { - const { dataset, width, xScaled, selectedDay } = props; +export function DatasetListItem(props: DatasetListItemProps) { + const { dataset, width, xScaled } = props; const [isVisible, setVisible] = useState(true); @@ -146,7 +153,6 @@ export function DatasetListItem(props: any) { width={width} xScaled={xScaled} dataset={dataset} - selectedDay={selectedDay} isVisible={isVisible} /> @@ -155,9 +161,16 @@ export function DatasetListItem(props: any) { ); } +interface DatasetTrackProps { + width: number; + xScaled: ScaleTime; + dataset: TimelineDataset; + isVisible: boolean; +} + const datasetTrackBlockHeight = 16; -function DatasetTrack(props: any) { - const { width, xScaled, dataset, selectedDay, isVisible } = props; +function DatasetTrack(props: DatasetTrackProps) { + const { width, xScaled, dataset, isVisible } = props; // Limit the items to render to increase performance. const domainToRender = useMemo(() => { @@ -179,22 +192,28 @@ function DatasetTrack(props: any) { }, [xScaled, dataset]); return ( - + {domainToRender.map((date) => ( ))} - + ); } -function DatasetTrackBlock(props: any) { +interface DatasetTrackBlockProps { + xScaled: ScaleTime; + date: Date; + dataset: TimelineDataset; + isVisible: boolean; +} + +function DatasetTrackBlock(props: DatasetTrackBlockProps) { const { xScaled, date, dataset, isVisible } = props; const [start, end] = getBlockBoundaries(date, dataset.timeDensity); diff --git a/app/scripts/components/sandbox/timeline/date-axis.tsx b/app/scripts/components/sandbox/timeline/date-axis.tsx index 8bcf5f1d9..cfb626415 100644 --- a/app/scripts/components/sandbox/timeline/date-axis.tsx +++ b/app/scripts/components/sandbox/timeline/date-axis.tsx @@ -10,6 +10,8 @@ import { } from 'date-fns'; import { themeVal } from '@devseed-ui/theme-provider'; +import { RIGHT_AXIS_SPACE, TimeDensity } from './constants'; + const GridLine = styled.line` stroke: ${themeVal('color.base-200')}; `; @@ -27,17 +29,11 @@ const DateAxisSVG = styled.svg` const GridSvg = styled.svg` position: absolute; - right: 0; + right: ${RIGHT_AXIS_SPACE}px; height: 100%; pointer-events: none; `; -enum TimeDensity { - YEAR = 'year', - MONTH = 'month', - DAY = 'day' -} - function getTimeDensity(domain) { if (domain.every((d) => d.getTime() === startOfYear(d).getTime())) { return TimeDensity.YEAR; diff --git a/app/scripts/components/sandbox/timeline/timeline-head.tsx b/app/scripts/components/sandbox/timeline/timeline-head.tsx index 4bffda5d6..e0d938dd0 100644 --- a/app/scripts/components/sandbox/timeline/timeline-head.tsx +++ b/app/scripts/components/sandbox/timeline/timeline-head.tsx @@ -4,9 +4,11 @@ import { drag, ScaleTime, select } from 'd3'; import { clamp, startOfDay } from 'date-fns'; import { themeVal } from '@devseed-ui/theme-provider'; +import { RIGHT_AXIS_SPACE } from './constants'; + const TimelineHeadSVG = styled.svg` position: absolute; - right: 0; + right: ${RIGHT_AXIS_SPACE}px; top: -1rem; height: calc(100% + 1rem); pointer-events: none; @@ -162,7 +164,7 @@ export function TimelineHeadR(props: Omit) { const TimelineRangeTrackSelf = styled.div` position: absolute; top: -1rem; - right: 0; + right: ${RIGHT_AXIS_SPACE}px; overflow: hidden; .shaded { diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx index cb6605726..2261f1333 100644 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -22,6 +22,8 @@ import { TimelineRangeTrack } from './timeline-head'; import { DateAxis, DateGrid } from './date-axis'; +import { RIGHT_AXIS_SPACE } from './constants'; +import { useEffectPrevious } from '$utils/use-effect-previous'; const TimelineWrapper = styled.div` position: relative; @@ -40,8 +42,9 @@ const InteractionRect = styled.div` left: 20rem; top: 3.5rem; bottom: 0; - right: 0; + right: ${RIGHT_AXIS_SPACE}px; /* background-color: rgba(255, 0, 0, 0.08); */ + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; z-index: 100; `; @@ -117,7 +120,8 @@ const DatasetListSelf = styled.ul` function Timeline() { const [datasets, setDatasets] = useState(srcDatasets); - const { observe, width, height } = useDimensions(); + const { observe, width: w, height } = useDimensions(); + const width = w - RIGHT_AXIS_SPACE; const interactionRef = useRef(null); const datasetsContainerRef = useRef(null); @@ -137,6 +141,10 @@ function Timeline() { y: 0, k: 1 }); + console.log( + '🚀 ~ file: timeline.tsx:143 ~ Timeline ~ zoomTransform:', + zoomTransform + ); const dataDomain = useMemo( () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], @@ -159,8 +167,9 @@ function Timeline() { const zoomBehavior = useMemo(() => { return ( zoom() - // Make the maximum zoom level as such as each day has maximum of 100px. - .scaleExtent([1, 100 / (width / domainDays)]) + // Make the maximum zoom level as such as each day has maximum of 100px + // and a minimum o 2px. + .scaleExtent([2 / (width / domainDays), 100 / (width / domainDays)]) .translateExtent([ [0, 0], [width, height] @@ -197,6 +206,31 @@ function Timeline() { ); }, [width, height, domainDays]); + // useEffectPrevious( + // ([zb]) => { + // if (zb && zb.scaleExtent()[1] > 0) { + // const prevScaleMax = zb.scaleExtent()[1]; + // const currScaleMax = zoomBehavior.scaleExtent()[1]; + // const prevXMax = zb.translateExtent()[1][0]; + // const currXMax = zoomBehavior.translateExtent()[1][0]; + + // console.log('zoomBehavior', zoomBehavior); + + // console.log('prevScaleMax, currScaleMax', prevScaleMax, currScaleMax); + // console.log('prevXMax, currXMax', prevXMax, currXMax); + + // setZoomTransform((t) => { + // return { + // ...t, + // x: (currXMax / prevXMax) * t.x, + // k: (currScaleMax / prevScaleMax) * t.k + // }; + // }); + // } + // }, + // [zoomBehavior] + // ); + useEffect(() => { if (!interactionRef.current) return; diff --git a/app/scripts/utils/use-effect-previous.ts b/app/scripts/utils/use-effect-previous.ts index 10ca3d6ec..14b8b3bc4 100644 --- a/app/scripts/utils/use-effect-previous.ts +++ b/app/scripts/utils/use-effect-previous.ts @@ -13,12 +13,12 @@ type EffectPreviousCb = ( * @param {array} deps Hook dependencies. */ export function useEffectPrevious( - cb: EffectPreviousCb, + cb: EffectPreviousCb, deps: T ) { - const prev = useRef([]); + const prev = useRef([]); const mounted = useRef(false); - const unchangingCb = useRef>(cb); + const unchangingCb = useRef>(cb); unchangingCb.current = cb; useEffect(() => { From 47395615a3999de05b0d827fe8aca5ca370d8130 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 2 Aug 2023 11:52:31 +0100 Subject: [PATCH 006/208] Add scale factors --- .../components/sandbox/timeline/index.tsx | 4 +- .../components/sandbox/timeline/timeline.tsx | 176 +++++++++--------- 2 files changed, 86 insertions(+), 94 deletions(-) diff --git a/app/scripts/components/sandbox/timeline/index.tsx b/app/scripts/components/sandbox/timeline/index.tsx index d02f147a2..ece118cd8 100644 --- a/app/scripts/components/sandbox/timeline/index.tsx +++ b/app/scripts/components/sandbox/timeline/index.tsx @@ -51,9 +51,7 @@ function SandboxTimeline() { -
    - Top -
    +
    Top
    diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx index 2261f1333..70ca53b5c 100644 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ b/app/scripts/components/sandbox/timeline/timeline.tsx @@ -1,9 +1,17 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; -import styled, { useTheme } from 'styled-components'; +import styled from 'styled-components'; import useDimensions from 'react-cool-dimensions'; import { Reorder } from 'framer-motion'; -import { ZoomTransform, axisBottom, extent, scaleTime, select, zoom } from 'd3'; -import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; +import { ZoomTransform, extent, scaleTime, select, zoom } from 'd3'; +import { + add, + differenceInCalendarDays, + format, + isAfter, + isBefore, + startOfDay, + sub +} from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; import { CollecticonChevronDownSmall, @@ -23,7 +31,6 @@ import { } from './timeline-head'; import { DateAxis, DateGrid } from './date-axis'; import { RIGHT_AXIS_SPACE } from './constants'; -import { useEffectPrevious } from '$utils/use-effect-previous'; const TimelineWrapper = styled.div` position: relative; @@ -120,14 +127,27 @@ const DatasetListSelf = styled.ul` function Timeline() { const [datasets, setDatasets] = useState(srcDatasets); + const dataDomain = useMemo( + () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], + [datasets] + ); + const { observe, width: w, height } = useDimensions(); - const width = w - RIGHT_AXIS_SPACE; + const width = Math.max(1, w - RIGHT_AXIS_SPACE); const interactionRef = useRef(null); const datasetsContainerRef = useRef(null); const [selectedDay, setSelectedDay] = useState(null); + const translateExtent = useMemo<[[number, number], [number, number]]>( + () => [ + [0, 0], + [width, height] + ], + [width, height] + ); + const [selectedInterval, setSelectedInterval] = useState<{ start: Date; end: Date; @@ -141,20 +161,18 @@ function Timeline() { y: 0, k: 1 }); - console.log( - '🚀 ~ file: timeline.tsx:143 ~ Timeline ~ zoomTransform:', - zoomTransform - ); - const dataDomain = useMemo( - () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], - [datasets] - ); - - const domainDays = useMemo( - () => (dataDomain[1].getTime() - dataDomain[0].getTime()) / 86400000, - [dataDomain] - ); + // Calculate min and max scale factors, such has each day has a minimum of 2px + // and a maximum of 100px. + const { k0, k1 } = useMemo(() => { + if (width <= 0) return { k0: 0, k1: 1 }; + // Calculate how many days are in the domain. + const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); + return { + k0: Math.max(1, 2 / (width / domainDays)), + k1: 100 / (width / domainDays) + }; + }, [width, dataDomain]); const xMain = useMemo(() => { return scaleTime().domain(dataDomain).range([0, width]); @@ -165,71 +183,36 @@ function Timeline() { }, [xMain, zoomTransform.x, zoomTransform.k]); const zoomBehavior = useMemo(() => { - return ( - zoom() - // Make the maximum zoom level as such as each day has maximum of 100px - // and a minimum o 2px. - .scaleExtent([2 / (width / domainDays), 100 / (width / domainDays)]) - .translateExtent([ - [0, 0], - [width, height] - ]) - .extent([ - [0, 0], - [width, height] - ]) - .filter((event) => { - if (event.type === 'wheel' && !event.altKey) { - // The zoom behavior traps the scroll event. Propagate to the data - // container to scroll it. - if (datasetsContainerRef.current) { - datasetsContainerRef.current.scrollBy(0, event.deltaY); - } - return false; + return zoom() + .scaleExtent([k0, k1]) + .translateExtent(translateExtent) + .extent(translateExtent) + .filter((event) => { + if (event.type === 'wheel' && !event.altKey) { + // The zoom behavior traps the scroll event. Propagate to the data + // container to scroll it. + if (datasetsContainerRef.current) { + datasetsContainerRef.current.scrollBy(0, event.deltaY); } - return true; - }) - .on('zoom', function (event) { - const { sourceEvent } = event; - - if (sourceEvent?.type === 'wheel') { - // Alt key plus wheel makes the browser go back in history. Prevent. - if (sourceEvent.altKey) { - sourceEvent.preventDefault(); - } + return false; + } + return true; + }) + .on('zoom', function (event) { + const { sourceEvent } = event; + + if (sourceEvent?.type === 'wheel') { + // Alt key plus wheel makes the browser go back in history. Prevent. + if (sourceEvent.altKey) { + sourceEvent.preventDefault(); } - const { x, y, k } = event.transform; - setZoomTransform((t) => - isEqualTransform(t, { x, y, k }) ? t : { x, y, k } - ); - }) - ); - }, [width, height, domainDays]); - - // useEffectPrevious( - // ([zb]) => { - // if (zb && zb.scaleExtent()[1] > 0) { - // const prevScaleMax = zb.scaleExtent()[1]; - // const currScaleMax = zoomBehavior.scaleExtent()[1]; - // const prevXMax = zb.translateExtent()[1][0]; - // const currXMax = zoomBehavior.translateExtent()[1][0]; - - // console.log('zoomBehavior', zoomBehavior); - - // console.log('prevScaleMax, currScaleMax', prevScaleMax, currScaleMax); - // console.log('prevXMax, currXMax', prevXMax, currXMax); - - // setZoomTransform((t) => { - // return { - // ...t, - // x: (currXMax / prevXMax) * t.x, - // k: (currScaleMax / prevScaleMax) * t.k - // }; - // }); - // } - // }, - // [zoomBehavior] - // ); + } + const { x, y, k } = event.transform; + setZoomTransform((t) => + isEqualTransform(t, { x, y, k }) ? t : { x, y, k } + ); + }); + }, [translateExtent, k0, k1]); useEffect(() => { if (!interactionRef.current) return; @@ -263,10 +246,7 @@ function Timeline() { // Constrain the transform according to the timeline bounds. const newTransform = constrainFn( updatedT, - [ - [0, 0], - [width, height] - ], + translateExtent, zoomBehavior.translateExtent() ); @@ -274,7 +254,7 @@ function Timeline() { // a sourceEvent. zoomBehavior.transform(element, newTransform); }); - }, [width, height, xScaled, zoomBehavior]); + }, [translateExtent, xScaled, zoomBehavior]); useEffect(() => { if (!interactionRef.current) return; @@ -305,9 +285,9 @@ function Timeline() { size='small' onClick={() => { setDatasets((list) => - list.length === srcDatasets.length - ? list.concat(extraDataset) - : list.slice(0, -1) + list.find((d) => d.title === 'Daily infinity!') + ? list.filter((d) => d.title !== 'Daily infinity!') + : [...list, extraDataset] ); }} > @@ -447,10 +427,11 @@ function DatasetList(props: any) { /** * Rescales the given scale according to the given factors. + * * @param scale Scale to rescale * @param x X factor * @param k Scale factor - * @returns new scale + * @returns New scale */ function rescaleX(scale, x, k) { const range = scale.range(); @@ -465,6 +446,19 @@ function rescaleX(scale, x, k) { ); } -function isEqualTransform(t1, t2) { +interface Transform { + x: number; + y: number; + k: number; +} + +/** + * Compares two transforms. + * + * @param t1 First transform + * @param t2 Second transform + * @returns Whether the transforms are equal. + */ +function isEqualTransform(t1: Transform, t2: Transform) { return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; } From aa277dcf1c5bc98523388b978c9398f8021be839 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 2 Aug 2023 11:53:10 +0100 Subject: [PATCH 007/208] Create exploration page with timeline --- app/scripts/components/exploration/atoms.ts | 37 ++ .../components/exploration/constants.ts | 34 ++ .../exploration/dataset-list-item.tsx | 254 ++++++++++ .../components/exploration/datasets-mock.tsx | 155 ++++++ .../components/exploration/date-axis.tsx | 168 +++++++ app/scripts/components/exploration/hooks.ts | 72 +++ app/scripts/components/exploration/index.tsx | 82 +++ .../components/exploration/timeline-head.tsx | 200 ++++++++ .../components/exploration/timeline.tsx | 472 ++++++++++++++++++ app/scripts/components/exploration/utils.ts | 68 +++ app/scripts/main.tsx | 4 + package.json | 1 + yarn.lock | 5 + 13 files changed, 1552 insertions(+) create mode 100644 app/scripts/components/exploration/atoms.ts create mode 100644 app/scripts/components/exploration/constants.ts create mode 100644 app/scripts/components/exploration/dataset-list-item.tsx create mode 100644 app/scripts/components/exploration/datasets-mock.tsx create mode 100644 app/scripts/components/exploration/date-axis.tsx create mode 100644 app/scripts/components/exploration/hooks.ts create mode 100644 app/scripts/components/exploration/index.tsx create mode 100644 app/scripts/components/exploration/timeline-head.tsx create mode 100644 app/scripts/components/exploration/timeline.tsx create mode 100644 app/scripts/components/exploration/utils.ts diff --git a/app/scripts/components/exploration/atoms.ts b/app/scripts/components/exploration/atoms.ts new file mode 100644 index 000000000..279e9b903 --- /dev/null +++ b/app/scripts/components/exploration/atoms.ts @@ -0,0 +1,37 @@ +import { atom } from 'jotai'; +import { + DateRange, + HEADER_COLUMN_WIDTH, + RIGHT_AXIS_SPACE, + TimelineDataset, + ZoomTransformPlain +} from './constants'; + +// Datasets to show on the timeline and their settings +export const timelineDatasetsAtom = atom([]); +// Main timeline date. This date defines the datasets shown on the map. +export const selectedDateAtom = atom(null); +// Date range for L&R playheads. +export const selectedIntervalAtom = atom(null); +// Zoom transform for the timeline. Values as object instead of d3.ZoomTransform +export const zoomTransformAtom = atom({ + x: 0, + y: 0, + k: 1 +}); +// Width of the whole timeline item. Set via a size observer and then used to +// compute the different element sizes. +export const timelineWidthAtom = atom(undefined); +// Derived atom with the different sizes of the timeline elements. +export const timelineSizesAtom = atom((get) => { + const totalWidth = get(timelineWidthAtom); + + return { + headerColumnWidth: HEADER_COLUMN_WIDTH, + rightAxisWidth: RIGHT_AXIS_SPACE, + contentWidth: Math.max( + 1, + (totalWidth ?? 0) - HEADER_COLUMN_WIDTH - RIGHT_AXIS_SPACE + ) + }; +}); diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts new file mode 100644 index 000000000..2991eba2a --- /dev/null +++ b/app/scripts/components/exploration/constants.ts @@ -0,0 +1,34 @@ +export const RIGHT_AXIS_SPACE = 80; +export const HEADER_COLUMN_WIDTH = 320; +export const DATASET_TRACK_BLOCK_HEIGHT = 16; + +export const emptyDateRange = { + start: null, + end: null +}; + +export enum TimeDensity { + YEAR = 'year', + MONTH = 'month', + DAY = 'day' +} + +export interface TimelineDataset { + status: 'idle' | 'loading' | 'succeeded' | 'errored'; + data: any; + error: any; + settings: { + // user defined settings like visibility, opacity + }; +} + +export interface DateRange { + start: Date; + end: Date; +} + +export interface ZoomTransformPlain { + x: number; + y: number; + k: number; +} diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx new file mode 100644 index 000000000..5bfccc3a7 --- /dev/null +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -0,0 +1,254 @@ +import React, { useMemo, useState } from 'react'; +import { Reorder, useDragControls } from 'framer-motion'; +import styled, { useTheme } from 'styled-components'; +import { + addDays, + subDays, + endOfDay, + endOfMonth, + endOfYear, + startOfDay, + startOfMonth, + startOfYear, + areIntervalsOverlapping +} from 'date-fns'; +import { ScaleTime } from 'd3'; +import { + CollecticonEye, + CollecticonEyeDisabled, + CollecticonGripVertical +} from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Toolbar, ToolbarIconButton } from '@devseed-ui/toolbar'; +import { Heading } from '@devseed-ui/typography'; + +import { + DATASET_TRACK_BLOCK_HEIGHT, + HEADER_COLUMN_WIDTH, + TimeDensity, + TimelineDataset +} from './constants'; + +import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; + +function getBlockBoundaries(date: Date, timeDensity: TimeDensity) { + switch (timeDensity) { + case TimeDensity.MONTH: + return [startOfMonth(date), endOfMonth(date)]; + case TimeDensity.YEAR: + return [startOfYear(date), endOfYear(date)]; + } + + return [startOfDay(date), endOfDay(date)]; +} + +const DatasetItem = styled.article` + width: 100%; + display: flex; + position: relative; + + ::before, + ::after { + position: absolute; + content: ''; + display: block; + width: 100%; + background: ${themeVal('color.base-200')}; + height: 1px; + } + + ::before { + top: 0; + } + + ::after { + bottom: -1px; + } +`; + +const DatasetHeader = styled.header` + width: ${HEADER_COLUMN_WIDTH}px; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + background: ${themeVal('color.surface')}; + padding: ${glsp(0.5)}; + display: flex; + align-items: center; + gap: 0.5rem; + + ${CollecticonGripVertical} { + cursor: grab; + color: ${themeVal('color.base-300')}; + + &:active { + cursor: grabbing; + } + } +`; + +const DatasetInfo = styled.div` + width: 100%; + display: flex; + flex-flow: column; + gap: 0.5rem; +`; + +const DatasetHeadline = styled.div` + display: flex; + justify-content: space-between; + gap: ${glsp()}; +`; + +const DatasetData = styled.div` + padding: ${glsp(0.25, 0)}; + display: flex; + align-items: center; +`; + +interface DatasetListItemProps { + dataset: TimelineDataset; + width: number; + xScaled: ScaleTime; +} + +export function DatasetListItem(props: DatasetListItemProps) { + const { dataset, width, xScaled } = props; + + const [isVisible, setVisible] = useState(true); + + const controls = useDragControls(); + + return ( + + + + controls.start(e)} /> + + + + {dataset.data.title} + + + setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + + )} + + + + + + + + + + + + ); +} + +interface DatasetTrackProps { + width: number; + xScaled: ScaleTime; + dataset: TimelineDataset; + isVisible: boolean; +} + +function DatasetTrack(props: DatasetTrackProps) { + const { width, xScaled, dataset, isVisible } = props; + + // Limit the items to render to increase performance. + const domainToRender = useMemo(() => { + const domain = xScaled.domain(); + const start = subDays(domain[0], 1); + const end = addDays(domain[1], 1); + + return dataset.data.domain.filter((d) => { + const [blockStart, blockEnd] = getBlockBoundaries( + d, + dataset.data.timeDensity + ); + + return areIntervalsOverlapping( + { + start: blockStart, + end: blockEnd + }, + { start, end } + ); + }); + }, [xScaled, dataset]); + + return ( + + {domainToRender.map((date) => ( + + ))} + + ); +} + +interface DatasetTrackBlockProps { + xScaled: ScaleTime; + date: Date; + dataset: TimelineDataset; + isVisible: boolean; +} + +function DatasetTrackBlock(props: DatasetTrackBlockProps) { + const { xScaled, date, dataset, isVisible } = props; + + const [start, end] = getBlockBoundaries(date, dataset.data.timeDensity); + const s = xScaled(start); + const e = xScaled(end); + + const fill = useFillColors(isVisible); + + return ( + + + + ); +} + +const useFillColors = (isVisible: boolean): string | undefined => { + const theme = useTheme(); + + if (!isVisible) { + return theme.color?.['base-200']; + } + + return theme.color?.['base-400']; +}; diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx new file mode 100644 index 000000000..91403925f --- /dev/null +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -0,0 +1,155 @@ +import React from 'react'; +import { eachDayOfInterval } from 'date-fns'; +import { useSetAtom } from 'jotai'; +import styled from 'styled-components'; +import { Button } from '@devseed-ui/button'; + +import { timelineDatasetsAtom } from './atoms'; +import { TimelineDataset } from './constants'; + +const extraDataset = { + title: 'Daily infinity!', + timeDensity: 'day', + domain: eachDayOfInterval({ + start: new Date('2000-01-01'), + end: new Date('2021-12-12') + }) +}; + +const datasets = [ + { + title: 'Monthly dataset', + timeDensity: 'month', + domain: [ + new Date('2020-01-01'), + new Date('2020-02-01'), + new Date('2020-03-01'), + new Date('2020-05-01'), + new Date('2020-06-01') + ] + }, + { + title: 'Daily dataset', + timeDensity: 'day', + domain: [ + new Date('2020-01-01'), + new Date('2020-01-02'), + new Date('2020-01-03'), + new Date('2020-01-04'), + new Date('2020-01-05'), + new Date('2020-01-07'), + new Date('2020-01-08'), + new Date('2020-01-09'), + new Date('2020-01-10'), + new Date('2020-01-11'), + new Date('2020-01-12'), + new Date('2020-01-13'), + new Date('2020-01-14'), + new Date('2020-01-15'), + new Date('2020-01-16'), + new Date('2020-01-19'), + new Date('2020-01-20'), + new Date('2020-01-21'), + new Date('2020-01-22'), + new Date('2020-01-23'), + new Date('2020-01-24'), + new Date('2020-01-25'), + new Date('2020-01-26'), + new Date('2020-01-27'), + new Date('2020-01-28'), + new Date('2020-01-29'), + new Date('2020-01-30'), + new Date('2020-01-31'), + new Date('2020-02-01'), + new Date('2020-02-02'), + new Date('2020-02-03'), + new Date('2020-02-04'), + new Date('2020-02-05'), + new Date('2020-02-06'), + new Date('2020-02-07'), + new Date('2020-02-08'), + new Date('2020-02-12'), + new Date('2020-02-13'), + new Date('2020-02-14'), + new Date('2020-02-15'), + new Date('2020-02-16'), + new Date('2020-02-17'), + new Date('2020-02-18'), + new Date('2020-02-19'), + new Date('2020-02-20'), + new Date('2020-02-22'), + new Date('2020-02-23'), + new Date('2020-02-24'), + new Date('2020-02-25'), + new Date('2020-02-26'), + new Date('2020-02-27'), + new Date('2020-02-28'), + new Date('2020-02-29'), + new Date('2020-03-01'), + new Date('2020-03-02'), + new Date('2020-03-03'), + new Date('2020-03-04'), + new Date('2020-03-05'), + new Date('2020-03-06'), + new Date('2020-03-07'), + new Date('2020-03-08') + ] + }, + { + title: 'Daily 2', + timeDensity: 'day', + domain: [ + new Date('2020-01-01'), + new Date('2020-02-01'), + new Date('2020-03-01'), + new Date('2020-05-01'), + new Date('2020-06-01') + ] + }, + { + title: 'Daily 3', + timeDensity: 'day', + domain: eachDayOfInterval({ + start: new Date('2020-01-01'), + end: new Date('2021-01-01') + }) + } +].map(makeDataset); + +function makeDataset(data): TimelineDataset { + return { + status: 'succeeded', + data, + error: null, + settings: {} + }; +} + +const MockPanel = styled.div` + padding: 1rem; +`; + +export function MockControls() { + const set = useSetAtom(timelineDatasetsAtom); + + return ( + + + + + ); +} diff --git a/app/scripts/components/exploration/date-axis.tsx b/app/scripts/components/exploration/date-axis.tsx new file mode 100644 index 000000000..cfb626415 --- /dev/null +++ b/app/scripts/components/exploration/date-axis.tsx @@ -0,0 +1,168 @@ +import React from 'react'; +import styled from 'styled-components'; +import { ScaleTime } from 'd3'; +import { + format, + isSameMonth, + isSameYear, + startOfMonth, + startOfYear +} from 'date-fns'; +import { themeVal } from '@devseed-ui/theme-provider'; + +import { RIGHT_AXIS_SPACE, TimeDensity } from './constants'; + +const GridLine = styled.line` + stroke: ${themeVal('color.base-200')}; +`; + +const DateAxisSVG = styled.svg` + text { + font-size: 0.75rem; + fill: ${themeVal('color.base')}; + + &.parent { + font-size: 0.625rem; + } + } +`; + +const GridSvg = styled.svg` + position: absolute; + right: ${RIGHT_AXIS_SPACE}px; + height: 100%; + pointer-events: none; +`; + +function getTimeDensity(domain) { + if (domain.every((d) => d.getTime() === startOfYear(d).getTime())) { + return TimeDensity.YEAR; + } else if (domain.every((d) => d.getTime() === startOfMonth(d).getTime())) { + return TimeDensity.MONTH; + } else { + return TimeDensity.DAY; + } +} + +function timeDensityFormat(date: Date, timeDensity: TimeDensity) { + switch (timeDensity) { + case TimeDensity.YEAR: + return format(date, 'yyyy'); + case TimeDensity.MONTH: + return format(date, 'MMM dd'); + case TimeDensity.DAY: + return format(date, 'iii dd'); + } +} + +/** + * Returns date ticks that are spaced out enough to be readable taking into + * account a minimum width for each tick of 60px. + * If the width of each tick is less than 60px, every other tick is returned. + * + * @param scale The scale to get the ticks from. + * @returns Date[] + */ +function getTicks(scale: ScaleTime) { + const [min, max] = scale.range(); + const width = max - min; + + return scale + .ticks() + .filter((v, i, a) => (width / a.length < 60 ? !(i % 2) : true)); +} + +interface DateAxisProps { + xScaled: ScaleTime; + width: number; +} + +export function DateAxis(props: DateAxisProps) { + const { xScaled, width } = props; + + const ticks = getTicks(xScaled); + const axisDensity = getTimeDensity(ticks); + + return ( + + {ticks.map((d) => { + const xPos = xScaled(d); + return ( + + + + {timeDensityFormat(d, axisDensity)} + + + + ); + })} + + ); +} + +interface ParentIndicatorProps { + timeDensity: TimeDensity; + date: Date; + domain: Date[]; + xScaled: ScaleTime; +} + +function ParentIndicator(props: ParentIndicatorProps) { + const { timeDensity, date, domain, xScaled } = props; + + if (timeDensity === TimeDensity.YEAR) return <>{false}; + + let dateFormat: string; + if (timeDensity === TimeDensity.MONTH) { + // Only render the first date for a given month. + // Get the first date that has the same month as the one being rendered and + // check if they're the same. + const firstDate = domain.find((d) => isSameYear(d, date)); + if (firstDate !== date) return <>{false}; + + dateFormat = 'yyyy'; + } else { + const firstDate = domain.find((d) => isSameMonth(d, date)); + if (firstDate !== date) return <>{false}; + + dateFormat = 'MMM yyyy'; + } + + return ( + + {format(date, dateFormat)} + + ); +} + +interface DateGridProps { + xScaled: ScaleTime; + width: number; +} + +export function DateGrid(props: DateGridProps) { + const { width, xScaled } = props; + + return ( + + {getTicks(xScaled).map((tick) => { + const xPos = xScaled(tick); + return ( + + ); + })} + + ); +} diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts new file mode 100644 index 000000000..d38d488dd --- /dev/null +++ b/app/scripts/components/exploration/hooks.ts @@ -0,0 +1,72 @@ +import { useMemo } from 'react'; +import { extent, scaleTime } from 'd3'; +import { useAtomValue } from 'jotai'; +import { differenceInCalendarDays } from 'date-fns'; + +import { + timelineDatasetsAtom, + timelineSizesAtom, + zoomTransformAtom +} from './atoms'; +import { rescaleX } from './utils'; + +/** + * Calculates the date domain of the datasets, if any are selected. + * @returns Dataset date domain or undefined. + */ +export function useTimelineDatasetsDomain() { + const datasets = useAtomValue(timelineDatasetsAtom); + + return useMemo(() => { + return datasets.length === 0 + ? undefined + : (extent(datasets.flatMap((d) => d.data.domain)) as [Date, Date]); + }, [datasets]); +} + +/** + * Calculate min and max scale factors, such has each day has a minimum of 2px + * and a maximum of 100px + * @returns Minimum and maximum scale factors as k0 and k1. + */ +export function useScaleFactors() { + const dataDomain = useTimelineDatasetsDomain(); + const { contentWidth } = useAtomValue(timelineSizesAtom); + + // Calculate min and max scale factors, such has each day has a minimum of 2px + // and a maximum of 100px. + return useMemo(() => { + if (contentWidth <= 0 || !dataDomain) return { k0: 0, k1: 1 }; + // Calculate how many days are in the domain. + const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); + return { + k0: Math.max(1, 2 / (contentWidth / domainDays)), + k1: 100 / (contentWidth / domainDays) + }; + }, [contentWidth, dataDomain]); +} + +/** + * Creates the scales for the timeline. + * The main scale takes into account the whole data domain. + * The scaled scale is the main scale rescaled according to the zoom transform. + * @param width + * @returns + */ +export function useScales() { + const dataDomain = useTimelineDatasetsDomain(); + const zoomTransform = useAtomValue(zoomTransformAtom); + const { contentWidth } = useAtomValue(timelineSizesAtom); + + const main = useMemo(() => { + if (!dataDomain) return undefined; + return scaleTime().domain(dataDomain).range([0, contentWidth]); + }, [dataDomain, contentWidth]); + + const scaled = useMemo(() => { + if (!main) return undefined; + return rescaleX(main, zoomTransform.x, zoomTransform.k); + }, [main, zoomTransform.x, zoomTransform.k]); + + return { main, scaled }; +} diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx new file mode 100644 index 000000000..bfcd55bad --- /dev/null +++ b/app/scripts/components/exploration/index.tsx @@ -0,0 +1,82 @@ +import React from 'react'; +import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; + +import { MockControls } from './datasets-mock'; +import Timeline from './timeline'; + +import { LayoutProps } from '$components/common/layout-root'; +import PageHero from '$components/common/page-hero'; +import { PageMainContent } from '$styles/page'; + +const Container = styled.div` + display: flex; + flex-flow: column; + flex-grow: 1; + + .panel-wrapper { + flex-grow: 1; + } + + .panel { + display: flex; + flex-direction: column; + } + + .panel-timeline { + box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; + } + + .resize-handle { + flex: 0; + position: relative; + outline: none; + display: flex; + align-items: center; + justify-content: center; + width: 5rem; + margin: 0 auto -1.25rem auto; + padding: 0.25rem 0; + z-index: 5000; + + ::before { + content: ''; + display: block; + width: 2rem; + background: ${themeVal('color.base-200')}; + height: 0.25rem; + border-radius: ${themeVal('shape.ellipsoid')}; + } + } +`; + +function Exploration() { + return ( + <> + + + + + + + +
    Top
    + +
    + + + + +
    +
    +
    + + ); +} + +export default Exploration; diff --git a/app/scripts/components/exploration/timeline-head.tsx b/app/scripts/components/exploration/timeline-head.tsx new file mode 100644 index 000000000..4ea9c5986 --- /dev/null +++ b/app/scripts/components/exploration/timeline-head.tsx @@ -0,0 +1,200 @@ +import React, { useEffect, useRef } from 'react'; +import styled, { useTheme } from 'styled-components'; +import { drag, ScaleTime, select } from 'd3'; +import { clamp, startOfDay } from 'date-fns'; +import { themeVal } from '@devseed-ui/theme-provider'; + +import { DateRange, RIGHT_AXIS_SPACE } from './constants'; + +const TimelineHeadSVG = styled.svg` + position: absolute; + right: ${RIGHT_AXIS_SPACE}px; + top: -1rem; + height: calc(100% + 1rem); + pointer-events: none; + z-index: 200; +`; + +const dropShadowFilter = + 'drop-shadow(0px 2px 2px rgba(44, 62, 80, 0.08)) drop-shadow(0px 0px 4px rgba(44, 62, 80, 0.08))'; + +interface TimelineHeadProps { + domain: [Date, Date]; + xScaled: ScaleTime; + selectedDay: Date; + width: number; + onDayChange: (date: Date) => void; + children: React.ReactNode; +} + +export function TimelineHead(props: TimelineHeadProps) { + const { domain, xScaled, selectedDay, width, onDayChange, children } = + props; + + const theme = useTheme(); + const rectRef = useRef(null); + + useEffect(() => { + if (!rectRef.current) return; + + const dragger = drag() + .on('start', function dragstarted() { + document.body.style.cursor = 'grabbing'; + select(this).attr('cursor', 'grabbing'); + }) + .on('drag', function dragged(event) { + if (event.x < 0 || event.x > width) { + return; + } + + const dx = event.x - event.subject.x; + const currPos = xScaled(selectedDay); + const newPos = currPos + dx; + + const dateFromPos = startOfDay(xScaled.invert(newPos)); + + const [start, end] = domain; + const interval = { start, end }; + + const newDate = clamp(dateFromPos, interval); + + if (selectedDay.getTime() !== newDate.getTime()) { + onDayChange(newDate); + } + }) + .on('end', function dragended() { + document.body.style.cursor = ''; + select(this).attr('cursor', 'grab'); + }); + + select(rectRef.current).call(dragger); + }, [width, domain, selectedDay, onDayChange, xScaled]); + + return ( + + + + {children} + + + ); +} + +export function TimelineHeadP(props: Omit) { + const theme = useTheme(); + + return ( + + + + P + + + ); +} + +export function TimelineHeadL(props: Omit) { + const theme = useTheme(); + + return ( + + + + L + + + ); +} + +export function TimelineHeadR(props: Omit) { + const theme = useTheme(); + return ( + + + + R + + + ); +} + +const TimelineRangeTrackSelf = styled.div` + position: absolute; + top: -1rem; + right: ${RIGHT_AXIS_SPACE}px; + overflow: hidden; + + .shaded { + position: relative; + background: ${themeVal('color.base-100a')}; + height: 1rem; + } +`; + +interface TimelineRangeTrackProps { + range: DateRange; + xScaled: ScaleTime; + width: number; +} + +export function TimelineRangeTrack(props: TimelineRangeTrackProps) { + const { range, xScaled, width } = props; + + const start = xScaled(range.start); + const end = xScaled(range.end); + + return ( + +
    + + ); +} diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx new file mode 100644 index 000000000..a09c33344 --- /dev/null +++ b/app/scripts/components/exploration/timeline.tsx @@ -0,0 +1,472 @@ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useAtomValue, useSetAtom, useAtom } from 'jotai'; +import styled from 'styled-components'; +import useDimensions from 'react-cool-dimensions'; +import { Reorder } from 'framer-motion'; +import { ZoomTransform, select, zoom } from 'd3'; +import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; +import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; +import { + CollecticonChevronDownSmall, + CollecticonPlusSmall +} from '@devseed-ui/collecticons'; +import { Button } from '@devseed-ui/button'; +import { Heading } from '@devseed-ui/typography'; +import { DatePicker } from '@devseed-ui/date-picker'; + +import { + selectedDateAtom, + selectedIntervalAtom, + timelineDatasetsAtom, + timelineSizesAtom, + timelineWidthAtom, + zoomTransformAtom +} from './atoms'; +import { DatasetListItem } from './dataset-list-item'; +import { + TimelineHeadL, + TimelineHeadP, + TimelineHeadR, + TimelineRangeTrack +} from './timeline-head'; +import { DateAxis, DateGrid } from './date-axis'; +import { emptyDateRange, RIGHT_AXIS_SPACE } from './constants'; +import { applyTransform, isEqualTransform, rescaleX } from './utils'; +import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; + +import { useEffectPrevious } from '$utils/use-effect-previous'; + +const TimelineWrapper = styled.div` + position: relative; + flex-grow: 1; + display: flex; + flex-flow: column; + height: 100%; + + svg { + display: block; + } +`; + +const NoData = styled.div` + display: flex; + flex-flow: column; + align-items: center; + max-width: 20rem; + margin: auto; + padding: 2rem; + gap: 1rem; +`; + +const InteractionRect = styled.div` + position: absolute; + left: 20rem; + top: 3.5rem; + bottom: 0; + right: ${RIGHT_AXIS_SPACE}px; + /* background-color: rgba(255, 0, 0, 0.08); */ + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + z-index: 100; +`; + +const TimelineHeader = styled.header` + display: flex; + flex-shrink: 0; + box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; +`; + +const TimelineDetails = styled.div` + width: 20rem; + flex-shrink: 0; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}, + 0 1px 0 0 ${themeVal('color.base-200')}; + padding: ${glsp(0.5, 0.5, 0.5, 2)}; + z-index: 1; +`; + +const Headline = styled.div` + display: flex; + justify-content: space-between; + align-items: center; +`; + +const TimelineControls = styled.div` + width: 100%; + display: flex; + flex-flow: column; + min-width: 0; + + .date-axis { + margin-top: auto; + } +`; + +const ControlsToolbar = styled.div` + display: flex; + justify-content: space-between; + padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; +`; + +const DatePickerButton = styled(Button)` + gap: ${glsp(0.5)}; + + .head-reference { + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + font-size: 0.875rem; + } +`; + +const TimelineContent = styled.div` + height: 100%; + min-height: 0; + display: flex; + width: 100%; + position: relative; +`; + +const TimelineContentInner = styled.div` + height: 100%; + min-height: 0; + display: flex; + overflow-y: scroll; + overflow-x: hidden; + width: 100%; + position: relative; +`; + +const DatasetListSelf = styled.ul` + ${listReset()} + width: 100%; +`; + +function Timeline() { + // Refs for non react based interactions. + // The interaction rect is used to capture the different d3 events for the + // zoom. + const interactionRef = useRef(null); + // Because the interaction rect traps the events, we need a ref to the + // container to propagate the needed events to it, like scroll. + const datasetsContainerRef = useRef(null); + + const datasets = useAtomValue(timelineDatasetsAtom); + + const dataDomain = useTimelineDatasetsDomain(); + + // Observe the width of the timeline wrapper and store it. + // We then use hooks to get the different needed values. + const setTimelineWidth = useSetAtom(timelineWidthAtom); + const { observe } = useDimensions({ + onResize: ({ width }) => { + setTimelineWidth(width); + } + }); + + const { contentWidth: width } = useAtomValue(timelineSizesAtom); + + const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); + const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom); + + const translateExtent = useMemo<[[number, number], [number, number]]>( + () => [ + [0, 0], + [width, 0] + ], + [width] + ); + + const [zoomTransform, setZoomTransform] = useAtom(zoomTransformAtom); + + // Calculate min and max scale factors, such has each day has a minimum of 2px + // and a maximum of 100px. + const { k0, k1 } = useScaleFactors(); + const { scaled: xScaled, main: xMain } = useScales(); + + // Create the zoom behavior needed for the timeline interactions. + const zoomBehavior = useMemo(() => { + return zoom() + .scaleExtent([k0, k1]) + .translateExtent(translateExtent) + .extent(translateExtent) + .filter((event) => { + if (event.type === 'wheel' && !event.altKey) { + // The zoom behavior traps the scroll event. Propagate to the data + // container to scroll it. + if (datasetsContainerRef.current) { + datasetsContainerRef.current.scrollBy(0, event.deltaY); + } + return false; + } + return true; + }) + .on('zoom', function (event) { + const { sourceEvent } = event; + + if (sourceEvent?.type === 'wheel') { + // Alt key plus wheel makes the browser go back in history. Prevent. + if (sourceEvent.altKey) { + sourceEvent.preventDefault(); + } + } + const { x, y, k } = event.transform; + setZoomTransform((t) => + isEqualTransform(t, { x, y, k }) ? t : { x, y, k } + ); + }); + }, [setZoomTransform, translateExtent, k0, k1]); + + useEffect(() => { + if (!interactionRef.current) return; + + select(interactionRef.current) + .call(zoomBehavior) + .on('dblclick.zoom', null) + .on('click', (event) => { + const d = xScaled?.invert(event.layerX); + d && setSelectedDay(startOfDay(d)); + }) + .on('wheel', function (event) { + // Wheel is triggered when an horizontal wheel is used or when shift + // wheel is used. The zoom event is only for vertical wheel so we have + // to mimic the pan behavior. + if (event.altKey) { + event.preventDefault(); + } + + const element = select(this); + // Get the current zoom transform. + const currentT = element.property('__zoom'); + // Applying the transform will cause the zoom event to be emitted without + // a sourceEvent. On the zoom event listener, the updated zoom transform + // is set on the state, so there's no need to do it here + applyTransform( + zoomBehavior, + element, + currentT.x - event.deltaX, + currentT.y, + currentT.k + ); + }); + }, [setSelectedDay, xScaled, zoomBehavior]); + + // When a new dataset is added we need to recompute the transform to ensure + // the timeline view remains the same. + useEffectPrevious( + (prevProps) => { + const [zb, zt, xSc] = prevProps as [ + typeof zoomBehavior | undefined, + ZoomTransform | undefined, + typeof xScaled | undefined + ]; + + // Only act when the zoom behavior changes. + // Everything else should be defined but let's prevent ts errors. + if ( + !interactionRef.current || + !zb || + !zt || + !xSc || + !xMain || + zb === zoomBehavior + ) + return; + + if (zb.scaleExtent()[1] > 0) { + const prevScaleMax = zb.scaleExtent()[1]; + const currScaleMax = zoomBehavior.scaleExtent()[1]; + // Calculate the new scale factor by using the ration between the old + // and new scale extents. + const k = (currScaleMax / prevScaleMax) * zt.k; + // Rescale the main scale to be able to calculate the new x position + const rescaled = rescaleX(xMain, 0, k); + // The date at the start of the timeline is the initial domain of the + // scale used to draw it - the scaled scale in this case. + const dateAtTimelineStart = xSc.domain()[0]; + + // Applying the transform will cause the zoom event to be emitted + // without a sourceEvent. On the zoom event listener, the updated zoom + // transform is set on the state, so there's no need to do it here. + applyTransform( + zoomBehavior, + select(interactionRef.current), + rescaled(dateAtTimelineStart) * -1, + 0, + k + ); + } + }, + [zoomBehavior, zoomTransform, xScaled, xMain] + ); + + // Some of these values depend on each other, but we check all of them so + // typescript doesn't complain. + if (datasets.length === 0 || !xScaled || !dataDomain) { + return ( + + +

    Select a dataset to start exploration

    + +
    +
    + ); + } + + return ( + + + + + + + Datasets + + + +

    X of Y

    +
    + + + { + setSelectedDay(d.start); + }} + renderTriggerElement={(props, label) => ( + + P + {label} + + + )} + /> + { + setSelectedInterval({ + start: d.start!, + end: d.end! + }); + }} + isClearable={false} + isRange + alignment='right' + renderTriggerElement={(props) => ( + + L + + {selectedInterval + ? format(selectedInterval.start, 'MMM do, yyyy') + : 'Date'} + + R + + {selectedInterval + ? format(selectedInterval.end, 'MMM do, yyyy') + : 'Date'} + + + + )} + /> + + + + +
    + + {selectedDay ? ( + + ) : ( + false + )} + {selectedInterval && ( + <> + { + setSelectedInterval((interval) => { + const prevDay = sub(interval!.end, { days: 1 }); + return { + end: interval!.end, + start: isAfter(d, prevDay) ? prevDay : d + }; + }); + }} + selectedDay={selectedInterval.start} + width={width} + /> + { + setSelectedInterval((interval) => { + const nextDay = add(interval!.start, { days: 1 }); + return { + start: interval!.start, + end: isBefore(d, nextDay) ? nextDay : d + }; + }); + }} + selectedDay={selectedInterval.end} + width={width} + /> + + + )} + + + + + + + +
    + ); +} + +export default Timeline; + +function DatasetList(props: any) { + const { datasets, ...rest } = props; + + const [orderedDatasets, setOrderDatasets] = useState(datasets); + + useEffect(() => { + setOrderDatasets(datasets); + }, [datasets]); + + return ( + + {orderedDatasets.map((dataset) => ( + + ))} + + ); +} diff --git a/app/scripts/components/exploration/utils.ts b/app/scripts/components/exploration/utils.ts new file mode 100644 index 000000000..b1035a64a --- /dev/null +++ b/app/scripts/components/exploration/utils.ts @@ -0,0 +1,68 @@ +import { ScaleTime, Selection, ZoomBehavior, ZoomTransform } from 'd3'; + +/** + * Rescales the given scale according to the given factors. + * + * @param scale Scale to rescale + * @param x X factor + * @param k Scale factor + * @returns New scale + */ +export function rescaleX(scale: ScaleTime, x: number, k: number) { + const range = scale.range(); + return scale.copy().domain( + range.map((v) => { + // New value after scaling + const value = (v - x) / k; + // Clamp value to the range + const valueClamped = Math.max(range[0], Math.min(value, range[1])); + return scale.invert(valueClamped); + }) + ); +} + +interface Transform { + x: number; + y: number; + k: number; +} + +/** + * Compares two transforms. + * + * @param t1 First transform + * @param t2 Second transform + * @returns Whether the transforms are equal. + */ +export function isEqualTransform(t1: Transform, t2: Transform) { + return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; +} + +/** + * Apply the new transform parameters (x, y, k) to the given element + * constraining the values according to the zoom behavior's constrain function. + * + * @param zoomBehavior The zoom behavior to use. + * @param element The element to apply the transform to. Must be a d3 selection. + * @param x The new x value. + * @param y The new y value. + * @param k The new k value. + */ +export function applyTransform( + zoomBehavior: ZoomBehavior, + element: Selection, + x: number, + y: number, + k: number +) { + const updatedT = new ZoomTransform(k, x, y); + const constrainFn = zoomBehavior.constrain(); + // Constrain the transform according to the timeline bounds. + const extent = zoomBehavior.translateExtent(); + const newTransform = constrainFn(updatedT, extent, extent); + + // Apply transform which will cause the zoom event to be emitted without + // a sourceEvent. On the zoom event listener, the updated zoom transform + // is set on the state, so there's no need to do it here. + zoomBehavior.transform(element, newTransform); +} \ No newline at end of file diff --git a/app/scripts/main.tsx b/app/scripts/main.tsx index 18b0997b7..743d8b619 100644 --- a/app/scripts/main.tsx +++ b/app/scripts/main.tsx @@ -34,6 +34,8 @@ const DatasetsOverview = lazy(() => import('$components/datasets/s-overview')); const Analysis = lazy(() => import('$components/analysis/define')); const AnalysisResults = lazy(() => import('$components/analysis/results')); +const Exploration = lazy(() => import('$components/exploration')); + const Sandbox = lazy(() => import('$components/sandbox')); const UserPagesComponent = lazy(() => import('$components/user-pages')); @@ -109,6 +111,8 @@ function Root() { /> } /> + } /> + {process.env.NODE_ENV !== 'production' && ( } /> )} diff --git a/package.json b/package.json index 8a7b5e002..0d138d5a6 100644 --- a/package.json +++ b/package.json @@ -139,6 +139,7 @@ "history": "^5.1.0", "intersection-observer": "^0.12.0", "jest-environment-jsdom": "^28.1.3", + "jotai": "^2.2.3", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mapbox-gl": "^2.15.0", diff --git a/yarn.lock b/yarn.lock index 6011432f5..24a49fefb 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8154,6 +8154,11 @@ jest@^28.1.3: import-local "^3.0.2" jest-cli "^28.1.3" +jotai@^2.2.3: + version "2.2.3" + resolved "http://verdaccio.ds.io:4873/jotai/-/jotai-2.2.3.tgz#4dd9f429e9cd23d81f08a6b1492931db05ccf79f" + integrity sha512-E3tTBb2CKwjAJKUJYVV6rKM8zxmpsPMedRclRnI2RgLzkvgDEH6mhtZPVesxIoixJ8p7949RWd8eo/8TqDDFDA== + "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "http://verdaccio.ds.io:4873/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From 7d8c093d673a07f129f0991e7d869d559de10b2c Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 2 Aug 2023 17:04:20 +0100 Subject: [PATCH 008/208] Fix timeline rescale glitch --- app/scripts/components/exploration/hooks.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index d38d488dd..4098e1787 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -66,7 +66,14 @@ export function useScales() { const scaled = useMemo(() => { if (!main) return undefined; return rescaleX(main, zoomTransform.x, zoomTransform.k); - }, [main, zoomTransform.x, zoomTransform.k]); + // We want to scale this scale only when the zoom transform changes. + // The zoom transform is what dictates the current timeline view so it is + // important that it drives the scale. Because the main changes before the + // zoom transform we can't include it in the deps, otherwise we'd have a + // weird midway render in the timeline when this scale had reacted to the + // main change but not to the zoom transform change. + }, [zoomTransform.x, zoomTransform.k]); - return { main, scaled }; + // In the first run the scaled and main scales are the same. + return { main, scaled: scaled ?? main }; } From f4b93dbec12ece84a51864ae82b2e26f36cfcf8d Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Thu, 3 Aug 2023 16:35:17 +0100 Subject: [PATCH 009/208] Handle dataset loading and error --- .../components/common/loading-skeleton.tsx | 2 +- .../components/exploration/constants.ts | 10 +- .../exploration/dataset-list-item-status.tsx | 79 +++++++++ .../exploration/dataset-list-item.tsx | 79 ++++++--- .../components/exploration/datasets-mock.tsx | 80 ++++++++-- app/scripts/components/exploration/hooks.ts | 37 ++++- .../exploration/timeline-controls.tsx | 112 +++++++++++++ .../components/exploration/timeline.tsx | 150 ++++-------------- package.json | 2 + yarn.lock | 10 ++ 10 files changed, 405 insertions(+), 156 deletions(-) create mode 100644 app/scripts/components/exploration/dataset-list-item-status.tsx create mode 100644 app/scripts/components/exploration/timeline-controls.tsx diff --git a/app/scripts/components/common/loading-skeleton.tsx b/app/scripts/components/common/loading-skeleton.tsx index fc3e4a46d..06ace02e1 100644 --- a/app/scripts/components/common/loading-skeleton.tsx +++ b/app/scripts/components/common/loading-skeleton.tsx @@ -15,7 +15,7 @@ const pulse = keyframes` } `; -const pulsingAnimation = css` +export const pulsingAnimation = css` animation: ${pulse} 0.8s ease 0s infinite alternate; `; diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 2991eba2a..3b624ba54 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -13,12 +13,20 @@ export enum TimeDensity { DAY = 'day' } +export enum TimelineDatasetStatus { + IDLE = 'idle', + LOADING = 'loading', + SUCCEEDED = 'succeeded', + ERRORED = 'errored' +} + export interface TimelineDataset { - status: 'idle' | 'loading' | 'succeeded' | 'errored'; + status: TimelineDatasetStatus; data: any; error: any; settings: { // user defined settings like visibility, opacity + isVisible?: boolean; }; } diff --git a/app/scripts/components/exploration/dataset-list-item-status.tsx b/app/scripts/components/exploration/dataset-list-item-status.tsx new file mode 100644 index 000000000..d63260a80 --- /dev/null +++ b/app/scripts/components/exploration/dataset-list-item-status.tsx @@ -0,0 +1,79 @@ +import React from 'react'; +import styled from 'styled-components'; +import { themeVal } from '@devseed-ui/theme-provider'; + +import { DATASET_TRACK_BLOCK_HEIGHT } from './constants'; + +import { pulsingAnimation } from '$components/common/loading-skeleton'; + +const loadingPattern = '.-.. --- .- -.. .. -. --.' + .split(' ') + .map((c) => c.split('')); + +const errorPattern = '. .-. .-. --- .-. . -..' + .split(' ') + .map((c) => c.split('')); + +const Track = styled.div` + display: flex; + gap: 1.5rem; + margin: auto; + padding: 0 1rem; +`; + +const Item = styled.div<{ code: string }>` + height: ${DATASET_TRACK_BLOCK_HEIGHT}px; + width: ${({ code }) => (code === '.' ? '1rem' : '2rem')}; + border-radius: 4px; +`; + +const TrackBlock = styled.div` + display: flex; + gap: 0.25rem; +`; + +const TrackLoading = styled(Track)` + ${pulsingAnimation} + + ${Item} { + background: ${themeVal('color.base-200')}; + } +`; + +const TrackError = styled(Track)` + ${Item} { + background: ${themeVal('color.danger-200')}; + } +`; + +export function DatasetTrackLoading() { + /* eslint-disable react/no-array-index-key */ + return ( + + {loadingPattern.map((letter, i) => ( + + {letter.map((s, i2) => ( + + ))} + + ))} + + ); + /* eslint-enable react/no-array-index-key */ +} + +export function DatasetTrackError() { + /* eslint-disable react/no-array-index-key */ + return ( + + {errorPattern.map((letter, i) => ( + + {letter.map((s, i2) => ( + + ))} + + ))} + + ); + /* eslint-enable react/no-array-index-key */ +} diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 5bfccc3a7..1ca4c5841 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -1,4 +1,5 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo } from 'react'; +import { useAtomValue } from 'jotai'; import { Reorder, useDragControls } from 'framer-motion'; import styled, { useTheme } from 'styled-components'; import { @@ -14,6 +15,7 @@ import { } from 'date-fns'; import { ScaleTime } from 'd3'; import { + CollecticonArrowSpinCw, CollecticonEye, CollecticonEyeDisabled, CollecticonGripVertical @@ -26,8 +28,14 @@ import { DATASET_TRACK_BLOCK_HEIGHT, HEADER_COLUMN_WIDTH, TimeDensity, - TimelineDataset + TimelineDataset, + TimelineDatasetStatus } from './constants'; +import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from './hooks'; +import { + DatasetTrackError, + DatasetTrackLoading +} from './dataset-list-item-status'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -103,21 +111,27 @@ const DatasetData = styled.div` padding: ${glsp(0.25, 0)}; display: flex; align-items: center; + flex-grow: 1; `; interface DatasetListItemProps { - dataset: TimelineDataset; + datasetId: string; width: number; xScaled: ScaleTime; } export function DatasetListItem(props: DatasetListItemProps) { - const { dataset, width, xScaled } = props; + const { datasetId, width, xScaled } = props; - const [isVisible, setVisible] = useState(true); + const datasetAtom = useTimelineDatasetAtom(datasetId); + const dataset = useAtomValue(datasetAtom); + + const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); const controls = useDragControls(); + const isError = dataset.status === TimelineDatasetStatus.ERRORED; + return ( @@ -125,23 +139,36 @@ export function DatasetListItem(props: DatasetListItemProps) { controls.start(e)} /> - + {dataset.data.title} - setVisible((v) => !v)}> - {isVisible ? ( - setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + + )} + + ) : ( + + - ) : ( - - )} - + + )} - + {dataset.status === TimelineDatasetStatus.LOADING && ( + + )} + {isError && } + {dataset.status === TimelineDatasetStatus.SUCCEEDED && ( + + )} diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index 91403925f..d7eebeb54 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -5,9 +5,10 @@ import styled from 'styled-components'; import { Button } from '@devseed-ui/button'; import { timelineDatasetsAtom } from './atoms'; -import { TimelineDataset } from './constants'; +import { TimelineDataset, TimelineDatasetStatus } from './constants'; const extraDataset = { + id: 'infinity', title: 'Daily infinity!', timeDensity: 'day', domain: eachDayOfInterval({ @@ -18,6 +19,7 @@ const extraDataset = { const datasets = [ { + id: 'monthly', title: 'Monthly dataset', timeDensity: 'month', domain: [ @@ -29,6 +31,7 @@ const datasets = [ ] }, { + id: 'daily', title: 'Daily dataset', timeDensity: 'day', domain: [ @@ -96,6 +99,7 @@ const datasets = [ ] }, { + id: 'daily2', title: 'Daily 2', timeDensity: 'day', domain: [ @@ -107,6 +111,7 @@ const datasets = [ ] }, { + id: 'daily3', title: 'Daily 3', timeDensity: 'day', domain: eachDayOfInterval({ @@ -114,19 +119,38 @@ const datasets = [ end: new Date('2021-01-01') }) } -].map(makeDataset); +].map((d) => makeDataset(d)); -function makeDataset(data): TimelineDataset { +function makeDataset( + data, + status = TimelineDatasetStatus.SUCCEEDED, + settings: Record = {} +): TimelineDataset { return { - status: 'succeeded', + status, data, error: null, - settings: {} + settings: { + ...settings, + isVisible: settings.isVisible === undefined ? true : settings.isVisible + } + }; +} + +function toggleDataset(dataset) { + return (d) => { + if (d.find((dd) => dd.data.id === dataset.data.id)) { + return d.filter((dd) => dd.data.id !== dataset.data.id); + } + return [...d, dataset]; }; } const MockPanel = styled.div` + display: flex; + flex-direction: row wrap; padding: 1rem; + gap: 1rem; `; export function MockControls() { @@ -134,22 +158,56 @@ export function MockControls() { return ( + + + ); } diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 4098e1787..510f8e2c6 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react'; import { extent, scaleTime } from 'd3'; -import { useAtomValue } from 'jotai'; +import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; +import { focusAtom } from 'jotai-optics'; import { differenceInCalendarDays } from 'date-fns'; import { @@ -9,6 +10,7 @@ import { zoomTransformAtom } from './atoms'; import { rescaleX } from './utils'; +import { TimelineDataset } from './constants'; /** * Calculates the date domain of the datasets, if any are selected. @@ -77,3 +79,36 @@ export function useScales() { // In the first run the scaled and main scales are the same. return { main, scaled: scaled ?? main }; } + +/** + * Creates a focus atom for a dataset with the given id. + * + * @param id The dataset id for which to create the atom. + * @returns Focus atom for the dataset with the given id. + */ +export function useTimelineDatasetAtom(id: string) { + const datasetAtom = useMemo(() => { + return focusAtom(timelineDatasetsAtom, (optic) => + optic.find((d) => d.data.id === id) + ); + }, [id]); + + return datasetAtom as PrimitiveAtom; +} + +/** + * Hook to get/set the visibility of a dataset. + * @param datasetAtom Single dataset atom. + * @returns State getter/setter for the dataset visibility. + */ +export function useTimelineDatasetVisibility( + datasetAtom: PrimitiveAtom +) { + const visibilityAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => + optic.prop('settings').prop('isVisible') + ); + }, [datasetAtom]); + + return useAtom(visibilityAtom); +} diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/timeline-controls.tsx new file mode 100644 index 000000000..bca10ca84 --- /dev/null +++ b/app/scripts/components/exploration/timeline-controls.tsx @@ -0,0 +1,112 @@ +import React from 'react'; +import styled from 'styled-components'; +import { format } from 'date-fns'; +import { ScaleTime } from 'd3'; + +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; +import { Button } from '@devseed-ui/button'; +import { DatePicker } from '@devseed-ui/date-picker'; + +import { DateAxis } from './date-axis'; +import { DateRange, emptyDateRange } from './constants'; + +const TimelineControlsSelf = styled.div` + width: 100%; + display: flex; + flex-flow: column; + min-width: 0; + + .date-axis { + margin-top: auto; + } +`; + +const ControlsToolbar = styled.div` + display: flex; + justify-content: space-between; + padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; +`; + +const DatePickerButton = styled(Button)` + gap: ${glsp(0.5)}; + + .head-reference { + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + font-size: 0.875rem; + } +`; + +interface TimelineControlsProps { + selectedDay: Date | null; + selectedInterval: DateRange | null; + xScaled: ScaleTime; + width: number; + onDayChange: (d: Date) => void; + onIntervalChange: (d: DateRange) => void; +} + +export function TimelineControls(props: TimelineControlsProps) { + const { + selectedDay, + selectedInterval, + xScaled, + width, + onDayChange, + onIntervalChange + } = props; + + return ( + + + { + onDayChange(d.start!); + }} + renderTriggerElement={(props, label) => ( + + P + {label} + + + )} + /> + { + onIntervalChange({ + start: d.start!, + end: d.end! + }); + }} + isClearable={false} + isRange + alignment='right' + renderTriggerElement={(props) => ( + + L + + {selectedInterval + ? format(selectedInterval.start, 'MMM do, yyyy') + : 'Date'} + + R + + {selectedInterval + ? format(selectedInterval.end, 'MMM do, yyyy') + : 'Date'} + + + + )} + /> + + + + + ); +} diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index a09c33344..3c8ae0e1a 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -1,18 +1,14 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import styled from 'styled-components'; import useDimensions from 'react-cool-dimensions'; import { Reorder } from 'framer-motion'; import { ZoomTransform, select, zoom } from 'd3'; -import { add, format, isAfter, isBefore, startOfDay, sub } from 'date-fns'; +import { add, isAfter, isBefore, startOfDay, sub } from 'date-fns'; import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { - CollecticonChevronDownSmall, - CollecticonPlusSmall -} from '@devseed-ui/collecticons'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { Heading } from '@devseed-ui/typography'; -import { DatePicker } from '@devseed-ui/date-picker'; import { selectedDateAtom, @@ -29,8 +25,9 @@ import { TimelineHeadR, TimelineRangeTrack } from './timeline-head'; -import { DateAxis, DateGrid } from './date-axis'; -import { emptyDateRange, RIGHT_AXIS_SPACE } from './constants'; +import { TimelineControls } from './timeline-controls'; +import { DateGrid } from './date-axis'; +import { RIGHT_AXIS_SPACE } from './constants'; import { applyTransform, isEqualTransform, rescaleX } from './utils'; import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; @@ -90,33 +87,6 @@ const Headline = styled.div` align-items: center; `; -const TimelineControls = styled.div` - width: 100%; - display: flex; - flex-flow: column; - min-width: 0; - - .date-axis { - margin-top: auto; - } -`; - -const ControlsToolbar = styled.div` - display: flex; - justify-content: space-between; - padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; -`; - -const DatePickerButton = styled(Button)` - gap: ${glsp(0.5)}; - - .head-reference { - font-weight: ${themeVal('type.base.regular')}; - color: ${themeVal('color.base-400')}; - font-size: 0.875rem; - } -`; - const TimelineContent = styled.div` height: 100%; min-height: 0; @@ -140,7 +110,7 @@ const DatasetListSelf = styled.ul` width: 100%; `; -function Timeline() { +export default function Timeline() { // Refs for non react based interactions. // The interaction rect is used to capture the different d3 events for the // zoom. @@ -149,7 +119,7 @@ function Timeline() { // container to propagate the needed events to it, like scroll. const datasetsContainerRef = useRef(null); - const datasets = useAtomValue(timelineDatasetsAtom); + const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); const dataDomain = useTimelineDatasetsDomain(); @@ -328,56 +298,14 @@ function Timeline() {

    X of Y

    - - - { - setSelectedDay(d.start); - }} - renderTriggerElement={(props, label) => ( - - P - {label} - - - )} - /> - { - setSelectedInterval({ - start: d.start!, - end: d.end! - }); - }} - isClearable={false} - isRange - alignment='right' - renderTriggerElement={(props) => ( - - L - - {selectedInterval - ? format(selectedInterval.start, 'MMM do, yyyy') - : 'Date'} - - R - - {selectedInterval - ? format(selectedInterval.end, 'MMM do, yyyy') - : 'Date'} - - - - )} - /> - - - - + {selectedDay ? ( @@ -434,39 +362,23 @@ function Timeline() { - + + {datasets.map((dataset) => ( + + ))} + ); } - -export default Timeline; - -function DatasetList(props: any) { - const { datasets, ...rest } = props; - - const [orderedDatasets, setOrderDatasets] = useState(datasets); - - useEffect(() => { - setOrderDatasets(datasets); - }, [datasets]); - - return ( - - {orderedDatasets.map((dataset) => ( - - ))} - - ); -} diff --git a/package.json b/package.json index 0d138d5a6..a6569899c 100644 --- a/package.json +++ b/package.json @@ -140,11 +140,13 @@ "intersection-observer": "^0.12.0", "jest-environment-jsdom": "^28.1.3", "jotai": "^2.2.3", + "jotai-optics": "^0.3.1", "js-yaml": "^4.1.0", "lodash": "^4.17.21", "mapbox-gl": "^2.15.0", "mapbox-gl-compare": "^0.4.0", "mapbox-gl-draw-rectangle-mode": "^1.0.4", + "optics-ts": "^2.4.1", "papaparse": "^5.3.2", "polished": "^4.1.3", "prop-types": "^15.7.2", diff --git a/yarn.lock b/yarn.lock index 24a49fefb..efd854c79 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8154,6 +8154,11 @@ jest@^28.1.3: import-local "^3.0.2" jest-cli "^28.1.3" +jotai-optics@^0.3.1: + version "0.3.1" + resolved "http://verdaccio.ds.io:4873/jotai-optics/-/jotai-optics-0.3.1.tgz#7ff38470551429460cc41d9cd1320193665354e0" + integrity sha512-KibUx9IneM2hGWGIYGs/v0KCxU985lg7W2c6dt5RodJCB2XPbmok8rkkLmdVk9+fKsn2shkPMi+AG8XzHgB3+w== + jotai@^2.2.3: version "2.2.3" resolved "http://verdaccio.ds.io:4873/jotai/-/jotai-2.2.3.tgz#4dd9f429e9cd23d81f08a6b1492931db05ccf79f" @@ -9827,6 +9832,11 @@ onetime@^5.1.2: dependencies: mimic-fn "^2.1.0" +optics-ts@^2.4.1: + version "2.4.1" + resolved "http://verdaccio.ds.io:4873/optics-ts/-/optics-ts-2.4.1.tgz#de94bda2b0ed7fde5b7631283031b9699459d40d" + integrity sha512-HaYzMHvC80r7U/LqAd4hQyopDezC60PO2qF5GuIwALut2cl5rK1VWHsqTp0oqoJJWjiv6uXKqsO+Q2OO0C3MmQ== + optionator@^0.8.1: version "0.8.3" resolved "http://verdaccio.ds.io:4873/optionator/-/optionator-0.8.3.tgz#84fa1d036fe9d3c7e21d99884b601167ec8fb495" From bbcc3491aa5c4b5144ec572188c163afad29b4d6 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 4 Aug 2023 10:41:02 +0100 Subject: [PATCH 010/208] Improve extent calculation --- app/scripts/components/exploration/hooks.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 510f8e2c6..4f74f2d59 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -20,9 +20,13 @@ export function useTimelineDatasetsDomain() { const datasets = useAtomValue(timelineDatasetsAtom); return useMemo(() => { - return datasets.length === 0 - ? undefined - : (extent(datasets.flatMap((d) => d.data.domain)) as [Date, Date]); + if (!datasets.length) return undefined; + + // To speed up the calculation of the extent, we assume the dataset's domain + // is ordered and only look at first and last dates. + return extent( + datasets.flatMap((d) => [d.data.domain[0], d.data.domain.last]) + ) as [Date, Date]; }, [datasets]); } From 64d0c6112d91bbc0903192a8940f747463609563 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 4 Aug 2023 14:27:21 +0100 Subject: [PATCH 011/208] Fix scale calculation and dragging select --- .../exploration/dataset-list-item.tsx | 20 ++- .../components/exploration/dataset-list.tsx | 47 +++++ app/scripts/components/exploration/hooks.ts | 11 +- .../components/exploration/timeline.tsx | 164 +++++++++--------- app/scripts/utils/use-effect-previous.ts | 47 +++-- 5 files changed, 185 insertions(+), 104 deletions(-) create mode 100644 app/scripts/components/exploration/dataset-list.tsx diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 1ca4c5841..9f9ad3a05 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -117,11 +117,13 @@ const DatasetData = styled.div` interface DatasetListItemProps { datasetId: string; width: number; - xScaled: ScaleTime; + xScaled?: ScaleTime; + onDragStart?: () => void; + onDragEnd?: () => void; } export function DatasetListItem(props: DatasetListItemProps) { - const { datasetId, width, xScaled } = props; + const { datasetId, width, xScaled, onDragStart, onDragEnd } = props; const datasetAtom = useTimelineDatasetAtom(datasetId); const dataset = useAtomValue(datasetAtom); @@ -133,7 +135,17 @@ export function DatasetListItem(props: DatasetListItemProps) { const isError = dataset.status === TimelineDatasetStatus.ERRORED; return ( - + { + onDragStart?.(); + }} + onDragEnd={() => { + onDragEnd?.(); + }} + > controls.start(e)} /> @@ -188,7 +200,7 @@ export function DatasetListItem(props: DatasetListItemProps) { {dataset.status === TimelineDatasetStatus.SUCCEEDED && ( diff --git a/app/scripts/components/exploration/dataset-list.tsx b/app/scripts/components/exploration/dataset-list.tsx new file mode 100644 index 000000000..5f96a4f30 --- /dev/null +++ b/app/scripts/components/exploration/dataset-list.tsx @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { useAtom } from 'jotai'; +import { Reorder } from 'framer-motion'; +import { ScaleTime } from 'd3'; +import styled from 'styled-components'; +import { listReset } from '@devseed-ui/theme-provider'; + +import { timelineDatasetsAtom } from './atoms'; +import { DatasetListItem } from './dataset-list-item'; + +const DatasetListSelf = styled.ul` + ${listReset()} + width: 100%; +`; + +interface DatasetListProps { + width: number; + xScaled?: ScaleTime; +} + +export function DatasetList(props: DatasetListProps) { + const { width, xScaled } = props; + const [isDragging, setIsDragging] = useState(false); + + const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); + + return ( + + {datasets.map((dataset) => ( + setIsDragging(true)} + onDragEnd={() => setIsDragging(false)} + /> + ))} + + ); +} diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 4f74f2d59..9c310c334 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -10,7 +10,7 @@ import { zoomTransformAtom } from './atoms'; import { rescaleX } from './utils'; -import { TimelineDataset } from './constants'; +import { TimelineDataset, TimelineDatasetStatus } from './constants'; /** * Calculates the date domain of the datasets, if any are selected. @@ -20,12 +20,17 @@ export function useTimelineDatasetsDomain() { const datasets = useAtomValue(timelineDatasetsAtom); return useMemo(() => { - if (!datasets.length) return undefined; + const successDatasets = datasets.filter( + (d) => d.status === TimelineDatasetStatus.SUCCEEDED + ); + if (!successDatasets.length) return undefined; // To speed up the calculation of the extent, we assume the dataset's domain // is ordered and only look at first and last dates. return extent( - datasets.flatMap((d) => [d.data.domain[0], d.data.domain.last]) + successDatasets.flatMap((d) => + d.data.domain ? [d.data.domain[0], d.data.domain.last] : [] + ) ) as [Date, Date]; }, [datasets]); } diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index 3c8ae0e1a..285bcef3b 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -2,10 +2,9 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import styled from 'styled-components'; import useDimensions from 'react-cool-dimensions'; -import { Reorder } from 'framer-motion'; -import { ZoomTransform, select, zoom } from 'd3'; +import { select, zoom } from 'd3'; import { add, isAfter, isBefore, startOfDay, sub } from 'date-fns'; -import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { Heading } from '@devseed-ui/typography'; @@ -18,7 +17,7 @@ import { timelineWidthAtom, zoomTransformAtom } from './atoms'; -import { DatasetListItem } from './dataset-list-item'; +import { DatasetList } from './dataset-list'; import { TimelineHeadL, TimelineHeadP, @@ -27,11 +26,15 @@ import { } from './timeline-head'; import { TimelineControls } from './timeline-controls'; import { DateGrid } from './date-axis'; -import { RIGHT_AXIS_SPACE } from './constants'; +import { + RIGHT_AXIS_SPACE, + TimelineDatasetStatus, + ZoomTransformPlain +} from './constants'; import { applyTransform, isEqualTransform, rescaleX } from './utils'; import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; -import { useEffectPrevious } from '$utils/use-effect-previous'; +import { useLayoutEffectPrevious } from '$utils/use-effect-previous'; const TimelineWrapper = styled.div` position: relative; @@ -62,7 +65,8 @@ const InteractionRect = styled.div` bottom: 0; right: ${RIGHT_AXIS_SPACE}px; /* background-color: rgba(255, 0, 0, 0.08); */ - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}, + inset 1px 0 0 0 ${themeVal('color.base-200')}; z-index: 100; `; @@ -105,11 +109,6 @@ const TimelineContentInner = styled.div` position: relative; `; -const DatasetListSelf = styled.ul` - ${listReset()} - width: 100%; -`; - export default function Timeline() { // Refs for non react based interactions. // The interaction rect is used to capture the different d3 events for the @@ -119,7 +118,7 @@ export default function Timeline() { // container to propagate the needed events to it, like scroll. const datasetsContainerRef = useRef(null); - const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); + const datasets = useAtomValue(timelineDatasetsAtom); const dataDomain = useTimelineDatasetsDomain(); @@ -220,57 +219,71 @@ export default function Timeline() { }, [setSelectedDay, xScaled, zoomBehavior]); // When a new dataset is added we need to recompute the transform to ensure - // the timeline view remains the same. - useEffectPrevious( - (prevProps) => { - const [zb, zt, xSc] = prevProps as [ - typeof zoomBehavior | undefined, - ZoomTransform | undefined, - typeof xScaled | undefined - ]; - - // Only act when the zoom behavior changes. - // Everything else should be defined but let's prevent ts errors. + // the timeline view remains the same. Datasets being added cause the scale + // factors to change. + // Using useLayoutEffect to ensure the transform is calculate before new + // renders. + useLayoutEffectPrevious< + [number, ZoomTransformPlain, typeof xScaled, typeof xMain] + >( + ([_k1, _zoomTransform, _xScaled]) => { if ( !interactionRef.current || - !zb || - !zt || - !xSc || + !_zoomTransform || + !_k1 || + !_xScaled || !xMain || - zb === zoomBehavior + _k1 === k1 ) return; - if (zb.scaleExtent()[1] > 0) { - const prevScaleMax = zb.scaleExtent()[1]; - const currScaleMax = zoomBehavior.scaleExtent()[1]; - // Calculate the new scale factor by using the ration between the old - // and new scale extents. - const k = (currScaleMax / prevScaleMax) * zt.k; - // Rescale the main scale to be able to calculate the new x position - const rescaled = rescaleX(xMain, 0, k); - // The date at the start of the timeline is the initial domain of the - // scale used to draw it - the scaled scale in this case. - const dateAtTimelineStart = xSc.domain()[0]; - - // Applying the transform will cause the zoom event to be emitted - // without a sourceEvent. On the zoom event listener, the updated zoom - // transform is set on the state, so there's no need to do it here. - applyTransform( - zoomBehavior, - select(interactionRef.current), - rescaled(dateAtTimelineStart) * -1, - 0, - k - ); - } + // Calculate the new scale factor by using the ration between the old + // and new scale extents. + const k = (k1 / _k1) * _zoomTransform.k; + // Rescale the main scale to be able to calculate the new x position + const rescaled = rescaleX(xMain, 0, k); + // The date at the start of the timeline is the initial domain of the + // scale used to draw it - the scaled scale in this case. + const dateAtTimelineStart = _xScaled.domain()[0]; + + // Applying the transform will cause the zoom event to be emitted + // without a sourceEvent. On the zoom event listener, the updated zoom + // transform is set on the state, so there's no need to do it here. + applyTransform( + zoomBehavior, + select(interactionRef.current), + rescaled(dateAtTimelineStart) * -1, + 0, + k + ); }, - [zoomBehavior, zoomTransform, xScaled, xMain] + [k1, zoomTransform, xScaled, xMain] ); + // When a loaded dataset is added from an empty state, compute the correct + // transform taking into account the min scale factor (k0). + const successDatasetsCount = datasets.filter( + (d) => d.status === TimelineDatasetStatus.SUCCEEDED + ).length; + useLayoutEffectPrevious<[number, number, typeof zoomBehavior]>( + ([_successDatasetsCount]) => { + if ( + !interactionRef.current || + _successDatasetsCount !== 0 || + successDatasetsCount === 0 + ) + return; + + applyTransform(zoomBehavior, select(interactionRef.current), 0, 0, k0); + }, + [successDatasetsCount, k0, zoomBehavior] + ); + + const shouldRenderTimeline = xScaled && dataDomain; + // Some of these values depend on each other, but we check all of them so // typescript doesn't complain. - if (datasets.length === 0 || !xScaled || !dataDomain) { + if (datasets.length === 0) { return ( @@ -285,7 +298,10 @@ export default function Timeline() { return ( - + @@ -298,17 +314,19 @@ export default function Timeline() {

    X of Y

    - + {shouldRenderTimeline && ( + + )}
    - {selectedDay ? ( + {shouldRenderTimeline && selectedDay ? ( )} - + {shouldRenderTimeline && } - - {datasets.map((dataset) => ( - - ))} - +
    diff --git a/app/scripts/utils/use-effect-previous.ts b/app/scripts/utils/use-effect-previous.ts index 14b8b3bc4..1fd4226e0 100644 --- a/app/scripts/utils/use-effect-previous.ts +++ b/app/scripts/utils/use-effect-previous.ts @@ -1,10 +1,30 @@ -import { DependencyList, useEffect, useRef } from 'react'; +import { DependencyList, useEffect, useLayoutEffect, useRef } from 'react'; type EffectPreviousCb = ( previous: T, mounted: boolean ) => void | (() => void); +function makePreviousHook(effectHook) { + return ( + cb: EffectPreviousCb, + deps: T + ) => { + const prev = useRef([]); + const mounted = useRef(false); + const unchangingCb = useRef>(cb); + unchangingCb.current = cb; + + effectHook(() => { + const r = unchangingCb.current(prev.current as T, mounted.current); + prev.current = deps; + if (!mounted.current) mounted.current = true; + return r; + /* eslint-disable-next-line react-hooks/exhaustive-deps */ + }, deps); + }; +} + /** * Same behavior as React's useEffect but called with the values for the * previous dependencies and with a flag tracking whether or not the component @@ -12,20 +32,13 @@ type EffectPreviousCb = ( * @param {func} cb Hook callback * @param {array} deps Hook dependencies. */ -export function useEffectPrevious( - cb: EffectPreviousCb, - deps: T -) { - const prev = useRef([]); - const mounted = useRef(false); - const unchangingCb = useRef>(cb); - unchangingCb.current = cb; +export const useEffectPrevious = makePreviousHook(useEffect); - useEffect(() => { - const r = unchangingCb.current(prev.current as T, mounted.current); - prev.current = deps; - if (!mounted.current) mounted.current = true; - return r; - /* eslint-disable-next-line react-hooks/exhaustive-deps */ - }, deps); -} +/** + * Same behavior as React's useLayoutEffect but called with the values for the + * previous dependencies and with a flag tracking whether or not the component + * is mounted + * @param {func} cb Hook callback + * @param {array} deps Hook dependencies. + */ +export const useLayoutEffectPrevious = makePreviousHook(useLayoutEffect); From 9c465f279439f6344cc857622ea046dd3121061f Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 4 Aug 2023 17:42:57 +0100 Subject: [PATCH 012/208] Add empty state when there's no loaded datasets --- app/scripts/components/exploration/hooks.ts | 11 ++----- .../exploration/timeline-controls.tsx | 22 ++++++++----- .../components/exploration/timeline.tsx | 31 ++++++++----------- 3 files changed, 30 insertions(+), 34 deletions(-) diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 9c310c334..8873f04e9 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -77,16 +77,9 @@ export function useScales() { const scaled = useMemo(() => { if (!main) return undefined; return rescaleX(main, zoomTransform.x, zoomTransform.k); - // We want to scale this scale only when the zoom transform changes. - // The zoom transform is what dictates the current timeline view so it is - // important that it drives the scale. Because the main changes before the - // zoom transform we can't include it in the deps, otherwise we'd have a - // weird midway render in the timeline when this scale had reacted to the - // main change but not to the zoom transform change. - }, [zoomTransform.x, zoomTransform.k]); + }, [main, zoomTransform.x, zoomTransform.k]); - // In the first run the scaled and main scales are the same. - return { main, scaled: scaled ?? main }; + return { main, scaled }; } /** diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/timeline-controls.tsx index bca10ca84..51ea1078d 100644 --- a/app/scripts/components/exploration/timeline-controls.tsx +++ b/app/scripts/components/exploration/timeline-controls.tsx @@ -1,7 +1,7 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import styled from 'styled-components'; -import { format } from 'date-fns'; -import { ScaleTime } from 'd3'; +import { endOfYear, format, startOfYear } from 'date-fns'; +import { scaleTime, ScaleTime } from 'd3'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; @@ -41,7 +41,7 @@ const DatePickerButton = styled(Button)` interface TimelineControlsProps { selectedDay: Date | null; selectedInterval: DateRange | null; - xScaled: ScaleTime; + xScaled?: ScaleTime; width: number; onDayChange: (d: Date) => void; onIntervalChange: (d: DateRange) => void; @@ -57,6 +57,14 @@ export function TimelineControls(props: TimelineControlsProps) { onIntervalChange } = props; + // Scale to use when there are no datasets with data (loading or error) + const initialScale = useMemo(() => { + const now = new Date(); + return scaleTime() + .domain([startOfYear(now), endOfYear(now)]) + .range([0, width]); + }, [width]); + return ( @@ -67,7 +75,7 @@ export function TimelineControls(props: TimelineControlsProps) { onDayChange(d.start!); }} renderTriggerElement={(props, label) => ( - + P {label} @@ -87,7 +95,7 @@ export function TimelineControls(props: TimelineControlsProps) { isRange alignment='right' renderTriggerElement={(props) => ( - + L {selectedInterval @@ -106,7 +114,7 @@ export function TimelineControls(props: TimelineControlsProps) { /> - + ); } diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index 285bcef3b..2cb61048b 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -224,7 +224,7 @@ export default function Timeline() { // Using useLayoutEffect to ensure the transform is calculate before new // renders. useLayoutEffectPrevious< - [number, ZoomTransformPlain, typeof xScaled, typeof xMain] + [number, ZoomTransformPlain, typeof xScaled, typeof xMain, number] >( ([_k1, _zoomTransform, _xScaled]) => { if ( @@ -238,8 +238,8 @@ export default function Timeline() { return; // Calculate the new scale factor by using the ration between the old - // and new scale extents. - const k = (k1 / _k1) * _zoomTransform.k; + // and new scale extents. Can never be less than minimum scale factor (k0) + const k = Math.max(k0, (k1 / _k1) * _zoomTransform.k); // Rescale the main scale to be able to calculate the new x position const rescaled = rescaleX(xMain, 0, k); // The date at the start of the timeline is the initial domain of the @@ -257,7 +257,7 @@ export default function Timeline() { k ); }, - [k1, zoomTransform, xScaled, xMain] + [k1, zoomTransform, xScaled, xMain, k0] ); // When a loaded dataset is added from an empty state, compute the correct @@ -288,9 +288,6 @@ export default function Timeline() {

    Select a dataset to start exploration

    -
    ); @@ -312,18 +309,16 @@ export default function Timeline() { -

    X of Y

    +

    {datasets.length} of Y

    - {shouldRenderTimeline && ( - - )} + {shouldRenderTimeline && selectedDay ? ( From b404050a9b7198139655cbfc404488837ae58639 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 7 Aug 2023 17:13:19 +0100 Subject: [PATCH 013/208] Set initial dates upon dataset selection --- .../components/exploration/constants.ts | 3 + .../components/exploration/datasets-mock.tsx | 47 ++++++++++- app/scripts/components/exploration/hooks.ts | 31 +++++--- .../components/exploration/timeline-head.tsx | 52 ++++++------ .../components/exploration/timeline.tsx | 79 ++++++++++++++----- app/scripts/utils/use-effect-previous.ts | 23 ++++++ 6 files changed, 183 insertions(+), 52 deletions(-) diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 3b624ba54..563b17841 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -2,6 +2,9 @@ export const RIGHT_AXIS_SPACE = 80; export const HEADER_COLUMN_WIDTH = 320; export const DATASET_TRACK_BLOCK_HEIGHT = 16; +export const DAY_SIZE_MIN = 2; +export const DAY_SIZE_MAX = 100; + export const emptyDateRange = { start: null, end: null diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index d7eebeb54..62fac2ac3 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -17,6 +17,27 @@ const extraDataset = { }) }; +const dataset2Months = { + id: 'two-dates', + title: 'Two Dates', + timeDensity: 'month', + domain: [new Date('2020-01-01'), new Date('2020-02-01')] +}; + +const dataset2Days = { + id: 'two-days', + title: 'Two Days', + timeDensity: 'day', + domain: [new Date('2020-01-05'), new Date('2020-01-06')] +}; + +const datasetSingle = { + id: 'single-dates', + title: 'Single Date', + timeDensity: 'day', + domain: [new Date('2020-01-01')] +}; + const datasets = [ { id: 'monthly', @@ -148,7 +169,7 @@ function toggleDataset(dataset) { const MockPanel = styled.div` display: flex; - flex-direction: row wrap; + flex-flow: row wrap; padding: 1rem; gap: 1rem; `; @@ -208,6 +229,30 @@ export function MockControls() { > Toggle Error dataset + + + ); } diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 8873f04e9..a681c0018 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react'; import { extent, scaleTime } from 'd3'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; import { focusAtom } from 'jotai-optics'; -import { differenceInCalendarDays } from 'date-fns'; +import { add, differenceInCalendarDays, max } from 'date-fns'; import { timelineDatasetsAtom, @@ -10,7 +10,12 @@ import { zoomTransformAtom } from './atoms'; import { rescaleX } from './utils'; -import { TimelineDataset, TimelineDatasetStatus } from './constants'; +import { + DAY_SIZE_MAX, + DAY_SIZE_MIN, + TimelineDataset, + TimelineDatasetStatus +} from './constants'; /** * Calculates the date domain of the datasets, if any are selected. @@ -18,6 +23,9 @@ import { TimelineDataset, TimelineDatasetStatus } from './constants'; */ export function useTimelineDatasetsDomain() { const datasets = useAtomValue(timelineDatasetsAtom); + const { contentWidth } = useAtomValue(timelineSizesAtom); + + const minDays = Math.ceil(contentWidth / DAY_SIZE_MAX); return useMemo(() => { const successDatasets = datasets.filter( @@ -27,32 +35,35 @@ export function useTimelineDatasetsDomain() { // To speed up the calculation of the extent, we assume the dataset's domain // is ordered and only look at first and last dates. - return extent( + const [start, end] = extent( successDatasets.flatMap((d) => d.data.domain ? [d.data.domain[0], d.data.domain.last] : [] ) ) as [Date, Date]; - }, [datasets]); + + return [start, max([end, add(start, { days: minDays })])] as [Date, Date]; + }, [datasets, minDays]); } /** - * Calculate min and max scale factors, such has each day has a minimum of 2px - * and a maximum of 100px + * Calculate min and max scale factors, such has each day has a minimum of + * {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px * @returns Minimum and maximum scale factors as k0 and k1. */ export function useScaleFactors() { const dataDomain = useTimelineDatasetsDomain(); const { contentWidth } = useAtomValue(timelineSizesAtom); - // Calculate min and max scale factors, such has each day has a minimum of 2px - // and a maximum of 100px. + // Calculate min and max scale factors, such has each day has a minimum of + // {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px. return useMemo(() => { if (contentWidth <= 0 || !dataDomain) return { k0: 0, k1: 1 }; // Calculate how many days are in the domain. const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); + return { - k0: Math.max(1, 2 / (contentWidth / domainDays)), - k1: 100 / (contentWidth / domainDays) + k0: Math.max(1, DAY_SIZE_MIN / (contentWidth / domainDays)), + k1: DAY_SIZE_MAX / (contentWidth / domainDays) }; }, [contentWidth, dataDomain]); } diff --git a/app/scripts/components/exploration/timeline-head.tsx b/app/scripts/components/exploration/timeline-head.tsx index 4ea9c5986..199ea7e38 100644 --- a/app/scripts/components/exploration/timeline-head.tsx +++ b/app/scripts/components/exploration/timeline-head.tsx @@ -6,9 +6,13 @@ import { themeVal } from '@devseed-ui/theme-provider'; import { DateRange, RIGHT_AXIS_SPACE } from './constants'; +// Needs padding so that the timeline head is fully visible. +// This value gets added to the width. +const SVG_PADDING = 16; + const TimelineHeadSVG = styled.svg` position: absolute; - right: ${RIGHT_AXIS_SPACE}px; + right: ${RIGHT_AXIS_SPACE - SVG_PADDING}px; top: -1rem; height: calc(100% + 1rem); pointer-events: none; @@ -28,8 +32,7 @@ interface TimelineHeadProps { } export function TimelineHead(props: TimelineHeadProps) { - const { domain, xScaled, selectedDay, width, onDayChange, children } = - props; + const { domain, xScaled, selectedDay, width, onDayChange, children } = props; const theme = useTheme(); const rectRef = useRef(null); @@ -39,7 +42,7 @@ export function TimelineHead(props: TimelineHeadProps) { const dragger = drag() .on('start', function dragstarted() { - document.body.style.cursor = 'grabbing'; + // document.body.style.cursor = 'grabbing'; select(this).attr('cursor', 'grabbing'); }) .on('drag', function dragged(event) { @@ -63,24 +66,30 @@ export function TimelineHead(props: TimelineHeadProps) { } }) .on('end', function dragended() { - document.body.style.cursor = ''; + // document.body.style.cursor = ''; select(this).attr('cursor', 'grab'); }); select(rectRef.current).call(dragger); }, [width, domain, selectedDay, onDayChange, xScaled]); + const xPos = xScaled(selectedDay); + + if (xPos < 0 || xPos > width) return null; + return ( - - - - {children} + + + + + {children} + ); @@ -98,8 +107,7 @@ export function TimelineHeadP(props: Omit) { stroke={theme.color?.['base-200']} style={{ filter: dropShadowFilter, - pointerEvents: 'all', - cursor: 'grab' + pointerEvents: 'all' }} /> @@ -121,8 +129,7 @@ export function TimelineHeadL(props: Omit) { stroke={theme.color?.['base-200']} style={{ filter: dropShadowFilter, - pointerEvents: 'all', - cursor: 'grab' + pointerEvents: 'all' }} /> @@ -143,8 +150,7 @@ export function TimelineHeadR(props: Omit) { stroke={theme.color?.['base-200']} style={{ filter: dropShadowFilter, - pointerEvents: 'all', - cursor: 'grab' + pointerEvents: 'all' }} /> ) { const TimelineRangeTrackSelf = styled.div` position: absolute; - top: -1rem; + top: -0.5rem; right: ${RIGHT_AXIS_SPACE}px; overflow: hidden; .shaded { position: relative; background: ${themeVal('color.base-100a')}; - height: 1rem; + height: 0.5rem; } `; diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index 2cb61048b..f01662495 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -1,9 +1,16 @@ -import React, { useEffect, useMemo, useRef } from 'react'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; import { useAtomValue, useSetAtom, useAtom } from 'jotai'; import styled from 'styled-components'; import useDimensions from 'react-cool-dimensions'; import { select, zoom } from 'd3'; -import { add, isAfter, isBefore, startOfDay, sub } from 'date-fns'; +import { + add, + isAfter, + isBefore, + isWithinInterval, + startOfDay, + sub +} from 'date-fns'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; @@ -34,7 +41,10 @@ import { import { applyTransform, isEqualTransform, rescaleX } from './utils'; import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; -import { useLayoutEffectPrevious } from '$utils/use-effect-previous'; +import { + useLayoutEffectPrevious, + usePreviousValue +} from '$utils/use-effect-previous'; const TimelineWrapper = styled.div` position: relative; @@ -260,24 +270,57 @@ export default function Timeline() { [k1, zoomTransform, xScaled, xMain, k0] ); + const successDatasets = datasets.filter( + (d) => d.status === TimelineDatasetStatus.SUCCEEDED + ); + // When a loaded dataset is added from an empty state, compute the correct // transform taking into account the min scale factor (k0). - const successDatasetsCount = datasets.filter( - (d) => d.status === TimelineDatasetStatus.SUCCEEDED - ).length; - useLayoutEffectPrevious<[number, number, typeof zoomBehavior]>( - ([_successDatasetsCount]) => { - if ( - !interactionRef.current || - _successDatasetsCount !== 0 || - successDatasetsCount === 0 - ) - return; + const successDatasetsCount = successDatasets.length; + const prevDatasetsCount = usePreviousValue(successDatasets.length); + useLayoutEffect(() => { + if ( + !interactionRef.current || + prevDatasetsCount !== 0 || + successDatasetsCount === 0 + ) + return; + + applyTransform(zoomBehavior, select(interactionRef.current), 0, 0, k0); + }, [prevDatasetsCount, successDatasetsCount, k0, zoomBehavior]); + + // Set correct dates when the date domain changes. + const prevDataDomain = usePreviousValue(dataDomain); + useEffect(() => { + if (prevDataDomain === dataDomain) return; - applyTransform(zoomBehavior, select(interactionRef.current), 0, 0, k0); - }, - [successDatasetsCount, k0, zoomBehavior] - ); + // If all datasets are removed, reset the selected day/interval. + if (!dataDomain) { + setSelectedDay(null); + setSelectedInterval(null); + return; + } + + const [start, end] = dataDomain; + // If the selected day is not within the new domain, set it to the start of + // the domain. + if (!selectedDay || !isWithinInterval(selectedDay, { start, end })) { + setSelectedDay(start); + // Set the interval to first day plus 2 months if able. + const endDate = add(start, { months: 2 }); + setSelectedInterval({ + start, + end: isBefore(endDate, end) ? endDate : end + }); + } + }, [ + prevDataDomain, + dataDomain, + setSelectedDay, + setSelectedInterval, + selectedDay, + selectedInterval + ]); const shouldRenderTimeline = xScaled && dataDomain; diff --git a/app/scripts/utils/use-effect-previous.ts b/app/scripts/utils/use-effect-previous.ts index 1fd4226e0..89c6d0a07 100644 --- a/app/scripts/utils/use-effect-previous.ts +++ b/app/scripts/utils/use-effect-previous.ts @@ -42,3 +42,26 @@ export const useEffectPrevious = makePreviousHook(useEffect); * @param {array} deps Hook dependencies. */ export const useLayoutEffectPrevious = makePreviousHook(useLayoutEffect); + +/** + * Hook to store the previous value of a variable. + * + * @param value The value to store + * @param isEqualFunc Fuction to compare the previous value with the current + * value. + * @returns The previous value + */ +export const usePreviousValue = ( + value: T, + isEqualFunc = (a: T | undefined, b: T) => a === b +): T | undefined => { + const ref = useRef(undefined); + + const current = ref.current; + + if (!isEqualFunc(current, value)) { + ref.current = value; + } + + return current; +}; From 8ff19b3320ffe2a21b5f42f4e27f1d9cf669bd23 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 11 Aug 2023 11:01:12 +0100 Subject: [PATCH 014/208] Add analysis chart --- app/scripts/components/exploration/atoms.ts | 5 + .../components/exploration/dataset-chart.tsx | 197 ++++++++++++++ .../exploration/dataset-list-item.tsx | 134 +++++----- .../components/exploration/datasets-mock.tsx | 245 +++++++++++++++++- 4 files changed, 516 insertions(+), 65 deletions(-) create mode 100644 app/scripts/components/exploration/dataset-chart.tsx diff --git a/app/scripts/components/exploration/atoms.ts b/app/scripts/components/exploration/atoms.ts index 279e9b903..1f1e317ab 100644 --- a/app/scripts/components/exploration/atoms.ts +++ b/app/scripts/components/exploration/atoms.ts @@ -35,3 +35,8 @@ export const timelineSizesAtom = atom((get) => { ) }; }); +// Whether or not the dataset rows are expanded. +export const isExpandedAtom = atom(false); + +// 🛑 Whether or not an analysis is being performed. Temporary!!! +export const isAnalysisAtom = atom(false); diff --git a/app/scripts/components/exploration/dataset-chart.tsx b/app/scripts/components/exploration/dataset-chart.tsx new file mode 100644 index 000000000..14ae2489c --- /dev/null +++ b/app/scripts/components/exploration/dataset-chart.tsx @@ -0,0 +1,197 @@ +import React, { useMemo } from 'react'; +import { useTheme } from 'styled-components'; +import { extent, scaleLinear, ScaleTime, line, ScaleLinear } from 'd3'; +import { useAtomValue } from 'jotai'; +import { AnimatePresence, motion } from 'framer-motion'; + +import { isExpandedAtom } from './atoms'; +import { RIGHT_AXIS_SPACE } from './constants'; +import { getNumForChart } from '$components/common/chart/utils'; + +const CHART_MARGIN = 8; + +interface DatasetChartProps { + width: number; + xScaled: ScaleTime; + isVisible: boolean; + data: any; +} + +export function DatasetChart(props: DatasetChartProps) { + const { xScaled, width, isVisible, data } = props; + + const isExpanded = useAtomValue(isExpandedAtom); + + const height = isExpanded ? 180 : 70; + + const yExtent = extent( + data.data.timeseries.flatMap((d) => [d.min, d.max, d.mean]) + ); + + const y = useMemo(() => { + return ( + scaleLinear() + // Add 5% buffer + .domain(yExtent.map((v) => v * 1.05)) + .range([height - CHART_MARGIN * 2, 0]) + ); + }, [yExtent, height]); + + return ( +
    + + + + + + + + + + + + + + + + + +
    + ); +} + +interface DateLineProps { + x: ScaleTime; + y: ScaleLinear; + prop: string; + data: any[]; + color: string; + isVisible: boolean; + isExpanded: boolean; +} + +function DataLine(props: DateLineProps) { + const { x, y, prop, data, color, isVisible, isExpanded } = props; + + const path = useMemo( + () => + line>() + .defined((d) => d[prop] !== null) + .x((d) => x(new Date(d.date ?? ''))) + .y((d) => y(d[prop] as number))(data), + [x, y, prop, data] + ); + + const maxOpacity = isVisible ? 1 : 0.25; + + if (!path) return null; + + return ( + + + {data.map((d) => + d[prop] !== null ? ( + + ) : false + )} + + ); +} + +interface AxisGridProps { + y: ScaleLinear; + width: number; + isVisible: boolean; + isExpanded: boolean; +} + +function AxisGrid(props: AxisGridProps) { + const { y, width, isVisible, isExpanded } = props; + + const theme = useTheme(); + + const ticks = y.ticks(5); + + return ( + + {isExpanded && ( + + {ticks.map((tick) => ( + + + + {getNumForChart(tick)} + + + ))} + + )} + + ); +} diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 9f9ad3a05..bbfb751cd 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -36,6 +36,8 @@ import { DatasetTrackError, DatasetTrackLoading } from './dataset-list-item-status'; +import { DatasetChart } from './dataset-chart'; +import { isAnalysisAtom } from './atoms'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -77,7 +79,11 @@ const DatasetItem = styled.article` const DatasetHeader = styled.header` width: ${HEADER_COLUMN_WIDTH}px; flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; + background: ${themeVal('color.base-100')}; +`; + +const DatasetHeaderInner = styled.div` + box-shadow: 1px 1px 0 0 ${themeVal('color.base-200')}; background: ${themeVal('color.surface')}; padding: ${glsp(0.5)}; display: flex; @@ -128,6 +134,8 @@ export function DatasetListItem(props: DatasetListItemProps) { const datasetAtom = useTimelineDatasetAtom(datasetId); const dataset = useAtomValue(datasetAtom); + const isAnalysis = useAtomValue(isAnalysisAtom); + const [isVisible, setVisible] = useTimelineDatasetVisibility(datasetAtom); const controls = useDragControls(); @@ -148,63 +156,73 @@ export function DatasetListItem(props: DatasetListItemProps) { > - controls.start(e)} /> - - - - {dataset.data.title} - - - {!isError ? ( - setVisible((v) => !v)}> - {isVisible ? ( - + controls.start(e)} /> + + + + {dataset.data.title} + + + {!isError ? ( + setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + + )} + + ) : ( + + - ) : ( - - )} - - ) : ( - - - - )} - - - - + + )} + + + + + {dataset.status === TimelineDatasetStatus.LOADING && ( )} {isError && } - {dataset.status === TimelineDatasetStatus.SUCCEEDED && ( - - )} + {dataset.status === TimelineDatasetStatus.SUCCEEDED && + (isAnalysis ? ( + + ) : ( + + ))}
    @@ -268,11 +286,15 @@ interface DatasetTrackBlockProps { function DatasetTrackBlock(props: DatasetTrackBlockProps) { const { xScaled, date, dataset, isVisible } = props; + const theme = useTheme(); + const [start, end] = getBlockBoundaries(date, dataset.data.timeDensity); const s = xScaled(start); const e = xScaled(end); - const fill = useFillColors(isVisible); + const fill = isVisible + ? theme.color?.['base-400'] + : theme.color?.['base-200']; return ( @@ -287,13 +309,3 @@ function DatasetTrackBlock(props: DatasetTrackBlockProps) { ); } - -const useFillColors = (isVisible: boolean): string | undefined => { - const theme = useTheme(); - - if (!isVisible) { - return theme.color?.['base-200']; - } - - return theme.color?.['base-400']; -}; diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index 62fac2ac3..f2204b0a6 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -1,12 +1,211 @@ import React from 'react'; -import { eachDayOfInterval } from 'date-fns'; +import { eachDayOfInterval, eachMonthOfInterval } from 'date-fns'; import { useSetAtom } from 'jotai'; import styled from 'styled-components'; import { Button } from '@devseed-ui/button'; -import { timelineDatasetsAtom } from './atoms'; +import { isAnalysisAtom, isExpandedAtom, timelineDatasetsAtom } from './atoms'; import { TimelineDataset, TimelineDatasetStatus } from './constants'; +const chartData = { + status: 'succeeded', + meta: { + total: 9, + loaded: 9 + }, + data: { + isPeriodic: true, + timeDensity: 'month', + timeseries: [ + { + date: '2020-10-01T00:00:00', + min: -2086298214989824, + max: 12890265228410880, + mean: 798724301873588, + count: 82701, + sum: 66055298489247600000, + std: 714889121800240.6 + }, + { + date: '2020-09-01T00:00:00', + min: -243302179799040, + max: 6783829977071616, + mean: 826268261725556.4, + count: 83024, + sum: 68600096161502590000, + std: 525758341466615.44 + }, + { + date: '2020-08-01T00:00:00', + min: -141698134966272, + max: 3727485078339584, + mean: 725800731063289.5, + count: 83024, + sum: 60258879895798550000, + std: 353335499843328.06 + }, + { + date: '2020-07-01T00:00:00', + min: -147666428231680, + max: 3569522892079104, + mean: 786568136687148.8, + count: 83024, + sum: 65304032980313830000, + std: 366549997729682.4 + }, + { + date: '2020-06-01T00:00:00', + min: -255044100292608, + max: 3507326933794816, + mean: 771834013412698, + count: 83024, + sum: 64080747129575830000, + std: 379108800939232 + }, + { + date: '2020-05-01T00:00:00', + min: -274287718039552, + max: 5203029145944064, + mean: 800096907890949, + count: 83024, + sum: 66427245680738170000, + std: 396133006092423.75 + }, + { + date: '2020-04-01T00:00:00', + min: -1733678178762752, + max: 4642589600907264, + mean: 698716974847719.5, + count: 82956, + sum: 57962765365467415000, + std: 450048731150610.9 + }, + { + date: '2020-03-01T00:00:00', + min: -800272532111360, + max: 8905950232576000, + mean: 705833097884692.6, + count: 82988, + sum: 58575677127254870000, + std: 528843673285467.1 + }, + { + date: '2020-02-01T00:00:00', + min: -426854754287616, + max: 12515318878437376, + mean: 781040757444822.8, + count: 82978, + sum: 64809199971256500000, + std: 832389364703228.4 + } + ] + } +}; + +const chartData2 = { + status: 'succeeded', + meta: { + total: 15, + loaded: 15 + }, + data: { + isPeriodic: true, + timeDensity: 'day', + timeseries: [ + { + date: '2020-04-01T00:00:00', + min: -6, + max: 17, + mean: 8 + }, + { + date: '2020-04-02T00:00:00', + min: -9, + max: 15, + mean: 5 + }, + { + date: '2020-04-03T00:00:00', + min: -3, + max: 15, + mean: 10 + }, + { + date: '2020-04-04T00:00:00', + min: 2, + max: 18, + mean: 6 + }, + { + date: '2020-04-05T00:00:00', + min: 3, + max: 19, + mean: 15 + }, + { + date: '2020-04-06T00:00:00', + min: null, + max: null, + mean: null + }, + { + date: '2020-04-07T00:00:00', + min: -8, + max: 17, + mean: 8 + }, + { + date: '2020-04-08T00:00:00', + min: -8, + max: 19, + mean: 9 + }, + { + date: '2020-04-09T00:00:00', + min: 2, + max: 15, + mean: 8 + }, + { + date: '2020-04-10T00:00:00', + min: null, + max: null, + mean: null + }, + { + date: '2020-04-11T00:00:00', + min: 3, + max: 20, + mean: 8 + }, + { + date: '2020-04-12T00:00:00', + min: 0, + max: 20, + mean: 10 + }, + { + date: '2020-04-13T00:00:00', + min: -10, + max: 16, + mean: 11 + }, + { + date: '2020-04-14T00:00:00', + min: -9, + max: 16, + mean: 11 + }, + { + date: '2020-04-15T00:00:00', + min: 3, + max: 17, + mean: 9 + } + ] + } +}; + const extraDataset = { id: 'infinity', title: 'Daily infinity!', @@ -17,6 +216,17 @@ const extraDataset = { }) }; +const dataset2020 = { + id: '2020', + title: '2020', + timeDensity: 'month', + domain: eachMonthOfInterval({ + start: new Date('2020-01-01'), + end: new Date('2020-12-01') + }), + analysis: chartData +}; + const dataset2Months = { id: 'two-dates', title: 'Two Dates', @@ -35,7 +245,8 @@ const datasetSingle = { id: 'single-dates', title: 'Single Date', timeDensity: 'day', - domain: [new Date('2020-01-01')] + domain: [new Date('2020-01-01')], + analysis: chartData2 }; const datasets = [ @@ -176,6 +387,8 @@ const MockPanel = styled.div` export function MockControls() { const set = useSetAtom(timelineDatasetsAtom); + const setIsExpanded = useSetAtom(isExpandedAtom); + const setIsAnalysis = useSetAtom(isAnalysisAtom); return ( @@ -251,7 +464,31 @@ export function MockControls() { }} variation='base-outline' > - toggle Single dataset + toggle Single date + + + + ); From 420f3d148ce03286ebec3264256279ce1e479931 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 11 Aug 2023 12:32:13 +0100 Subject: [PATCH 015/208] Add metrics and expand controls --- .../exploration/analysis-metrics-dropdown.tsx | 139 +++++++++++++++ app/scripts/components/exploration/atoms.ts | 4 + .../components/exploration/dataset-chart.tsx | 73 ++++---- .../exploration/dataset-list-item.tsx | 5 +- .../exploration/dataset-track-message.tsx | 23 +++ .../exploration/timeline-controls.tsx | 163 +++++++++++------- .../components/exploration/timeline.tsx | 9 +- 7 files changed, 314 insertions(+), 102 deletions(-) create mode 100644 app/scripts/components/exploration/analysis-metrics-dropdown.tsx create mode 100644 app/scripts/components/exploration/dataset-track-message.tsx diff --git a/app/scripts/components/exploration/analysis-metrics-dropdown.tsx b/app/scripts/components/exploration/analysis-metrics-dropdown.tsx new file mode 100644 index 000000000..3a142a58b --- /dev/null +++ b/app/scripts/components/exploration/analysis-metrics-dropdown.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import styled from 'styled-components'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Dropdown, DropTitle } from '@devseed-ui/dropdown'; +import { Button } from '@devseed-ui/button'; +import { CollecticonChartLine } from '@devseed-ui/collecticons'; +import { FormSwitch } from '@devseed-ui/form'; + +export interface DataMetric { + id: string; + label: string; + chartLabel: string; + themeColor: + | 'infographicA' + | 'infographicB' + | 'infographicC' + | 'infographicD' + | 'infographicE'; +} + +export const dataMetrics: DataMetric[] = [ + { + id: 'min', + label: 'Min', + chartLabel: 'Min', + themeColor: 'infographicA' + }, + { + id: 'mean', + label: 'Average', + chartLabel: 'Avg', + themeColor: 'infographicB' + }, + { + id: 'max', + label: 'Max', + chartLabel: 'Max', + themeColor: 'infographicC' + }, + { + id: 'std', + label: 'St Deviation', + chartLabel: 'STD', + themeColor: 'infographicD' + }, + { + id: 'median', + label: 'Median', + chartLabel: 'Median', + themeColor: 'infographicE' + } +]; + +const MetricList = styled.ul` + display: flex; + flex-flow: column; + list-style: none; + margin: 0 -${glsp()}; + padding: 0; + gap: ${glsp(0.5)}; + + > li { + padding: ${glsp(0, 1)}; + } +`; + +const MetricSwitch = styled(FormSwitch)<{ metricThemeColor: string }>` + display: grid; + grid-template-columns: min-content 1fr auto; + + &::before { + content: ''; + width: 0.5rem; + height: 0.5rem; + background: ${({ metricThemeColor }) => + themeVal(`color.${metricThemeColor}` as any)}; + border-radius: ${themeVal('shape.ellipsoid')}; + align-self: center; + } +`; + +interface AnalysisMetricsDropdownProps { + activeMetrics: DataMetric[]; + onMetricsChange: (metrics: DataMetric[]) => void; + isDisabled: boolean; +} + +export default function AnalysisMetricsDropdown( + props: AnalysisMetricsDropdownProps +) { + const { activeMetrics, onMetricsChange, isDisabled } = props; + + const handleMetricChange = (metric: DataMetric, shouldAdd: boolean) => { + onMetricsChange( + shouldAdd + ? activeMetrics.concat(metric) + : activeMetrics.filter((m) => m.id !== metric.id) + ); + }; + + return ( + ( + + )} + > + View options + + {dataMetrics.map((metric) => { + const checked = !!activeMetrics.find((m) => m.id === metric.id); + return ( +
  • + handleMetricChange(metric, !checked)} + > + {metric.label} + +
  • + ); + })} +
    +
    + ); +} diff --git a/app/scripts/components/exploration/atoms.ts b/app/scripts/components/exploration/atoms.ts index 1f1e317ab..dae45944b 100644 --- a/app/scripts/components/exploration/atoms.ts +++ b/app/scripts/components/exploration/atoms.ts @@ -1,4 +1,5 @@ import { atom } from 'jotai'; +import { DataMetric, dataMetrics } from './analysis-metrics-dropdown'; import { DateRange, HEADER_COLUMN_WIDTH, @@ -38,5 +39,8 @@ export const timelineSizesAtom = atom((get) => { // Whether or not the dataset rows are expanded. export const isExpandedAtom = atom(false); +// What analysis metrics are enabled +export const activeAnalysisMetricsAtom = atom(dataMetrics); + // 🛑 Whether or not an analysis is being performed. Temporary!!! export const isAnalysisAtom = atom(false); diff --git a/app/scripts/components/exploration/dataset-chart.tsx b/app/scripts/components/exploration/dataset-chart.tsx index 14ae2489c..2c29ec230 100644 --- a/app/scripts/components/exploration/dataset-chart.tsx +++ b/app/scripts/components/exploration/dataset-chart.tsx @@ -6,6 +6,9 @@ import { AnimatePresence, motion } from 'framer-motion'; import { isExpandedAtom } from './atoms'; import { RIGHT_AXIS_SPACE } from './constants'; +import { DataMetric } from './analysis-metrics-dropdown'; +import { DatasetTrackMessage } from './dataset-track-message'; + import { getNumForChart } from '$components/common/chart/utils'; const CHART_MARGIN = 8; @@ -15,30 +18,46 @@ interface DatasetChartProps { xScaled: ScaleTime; isVisible: boolean; data: any; + activeMetrics: DataMetric[]; } export function DatasetChart(props: DatasetChartProps) { - const { xScaled, width, isVisible, data } = props; + const { xScaled, width, isVisible, data, activeMetrics } = props; + + const timeseries = data.data.timeseries; + + const theme = useTheme(); const isExpanded = useAtomValue(isExpandedAtom); const height = isExpanded ? 180 : 70; - const yExtent = extent( - data.data.timeseries.flatMap((d) => [d.min, d.max, d.mean]) + const yExtent = useMemo( + () => + extent( + // Extent of all active metrics. + timeseries.flatMap((d) => extent(activeMetrics.map((m) => d[m.id]))) + ) as [undefined, undefined] | [number, number], + [timeseries, activeMetrics] ); const y = useMemo(() => { + const [min = 0, max = 0] = yExtent; return ( scaleLinear() // Add 5% buffer - .domain(yExtent.map((v) => v * 1.05)) + .domain([min * 0.95, max * 1.05]) .range([height - CHART_MARGIN * 2, 0]) ); }, [yExtent, height]); return (
    + {!activeMetrics.length && ( + + There are no active metrics to visualize. + + )} @@ -55,33 +74,21 @@ export function DatasetChart(props: DatasetChartProps) { - - - + {activeMetrics.map( + (metric) => + timeseries.some((d) => !isNaN(d[metric.id])) && ( + + ) + )} @@ -138,7 +145,9 @@ function DataLine(props: DateLineProps) { fill='white' stroke={color} /> - ) : false + ) : ( + false + ) )} ); diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index bbfb751cd..cd721fa54 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -37,7 +37,7 @@ import { DatasetTrackLoading } from './dataset-list-item-status'; import { DatasetChart } from './dataset-chart'; -import { isAnalysisAtom } from './atoms'; +import { activeAnalysisMetricsAtom, isAnalysisAtom } from './atoms'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -114,6 +114,7 @@ const DatasetHeadline = styled.div` `; const DatasetData = styled.div` + position: relative; padding: ${glsp(0.25, 0)}; display: flex; align-items: center; @@ -133,6 +134,7 @@ export function DatasetListItem(props: DatasetListItemProps) { const datasetAtom = useTimelineDatasetAtom(datasetId); const dataset = useAtomValue(datasetAtom); + const activeMetrics = useAtomValue(activeAnalysisMetricsAtom); const isAnalysis = useAtomValue(isAnalysisAtom); @@ -214,6 +216,7 @@ export function DatasetListItem(props: DatasetListItemProps) { width={width} isVisible={!!isVisible} data={dataset.data.analysis} + activeMetrics={activeMetrics} /> ) : ( {children}; +} diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/timeline-controls.tsx index 51ea1078d..70c626650 100644 --- a/app/scripts/components/exploration/timeline-controls.tsx +++ b/app/scripts/components/exploration/timeline-controls.tsx @@ -1,15 +1,34 @@ import React, { useMemo } from 'react'; import styled from 'styled-components'; +import { useAtom, useAtomValue } from 'jotai'; import { endOfYear, format, startOfYear } from 'date-fns'; import { scaleTime, ScaleTime } from 'd3'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { CollecticonChevronDownSmall } from '@devseed-ui/collecticons'; +import { + CollecticonChevronDownSmall, + CollecticonResizeIn, + CollecticonResizeOut +} from '@devseed-ui/collecticons'; import { Button } from '@devseed-ui/button'; import { DatePicker } from '@devseed-ui/date-picker'; +import { + Toolbar, + ToolbarGroup, + ToolbarIconButton, + VerticalDivider +} from '@devseed-ui/toolbar'; import { DateAxis } from './date-axis'; -import { DateRange, emptyDateRange } from './constants'; +import { emptyDateRange } from './constants'; +import AnalysisMetricsDropdown from './analysis-metrics-dropdown'; +import { + activeAnalysisMetricsAtom, + isAnalysisAtom, + isExpandedAtom, + selectedDateAtom, + selectedIntervalAtom +} from './atoms'; const TimelineControlsSelf = styled.div` width: 100%; @@ -23,9 +42,11 @@ const TimelineControlsSelf = styled.div` `; const ControlsToolbar = styled.div` - display: flex; - justify-content: space-between; - padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; + padding: ${glsp(1.5, 1, 0.5, 1)}; + + ${ToolbarGroup}:last-child { + margin-left: auto; + } `; const DatePickerButton = styled(Button)` @@ -39,23 +60,18 @@ const DatePickerButton = styled(Button)` `; interface TimelineControlsProps { - selectedDay: Date | null; - selectedInterval: DateRange | null; xScaled?: ScaleTime; width: number; - onDayChange: (d: Date) => void; - onIntervalChange: (d: DateRange) => void; } export function TimelineControls(props: TimelineControlsProps) { - const { - selectedDay, - selectedInterval, - xScaled, - width, - onDayChange, - onIntervalChange - } = props; + const { xScaled, width } = props; + + const [selectedDay, setSelectedDay] = useAtom(selectedDateAtom); + const [selectedInterval, setSelectedInterval] = useAtom(selectedIntervalAtom); + const [activeMetrics, setActiveMetrics] = useAtom(activeAnalysisMetricsAtom); + const isAnalysis = useAtomValue(isAnalysisAtom); + const [isExpanded, setExpanded] = useAtom(isExpandedAtom); // Scale to use when there are no datasets with data (loading or error) const initialScale = useMemo(() => { @@ -68,50 +84,75 @@ export function TimelineControls(props: TimelineControlsProps) { return ( - { - onDayChange(d.start!); - }} - renderTriggerElement={(props, label) => ( - - P - {label} - - - )} - /> - { - onIntervalChange({ - start: d.start!, - end: d.end! - }); - }} - isClearable={false} - isRange - alignment='right' - renderTriggerElement={(props) => ( - - L - - {selectedInterval - ? format(selectedInterval.start, 'MMM do, yyyy') - : 'Date'} - - R - - {selectedInterval - ? format(selectedInterval.end, 'MMM do, yyyy') - : 'Date'} - - - - )} - /> + + { + setSelectedDay(d.start!); + }} + renderTriggerElement={(props, label) => ( + + P + {label} + + + )} + /> + + { + setSelectedInterval({ + start: d.start!, + end: d.end! + }); + }} + isClearable={false} + isRange + alignment='right' + renderTriggerElement={(props) => ( + + L + + {selectedInterval + ? format(selectedInterval.start, 'MMM do, yyyy') + : 'Date'} + + R + + {selectedInterval + ? format(selectedInterval.end, 'MMM do, yyyy') + : 'Date'} + + + + )} + /> + + + { + setExpanded((v) => !v); + }} + > + {isExpanded ? ( + + ) : ( + + )} + + + + + diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index f01662495..e485222d2 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -354,14 +354,7 @@ export default function Timeline() {

    {datasets.length} of Y

    - + {shouldRenderTimeline && selectedDay ? ( From 8335cdfe0ec9d0d6e25f79449b28551667de3952 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Fri, 11 Aug 2023 16:17:37 +0100 Subject: [PATCH 016/208] Add dataset options menu Includes opacity and dataset remove button --- .../components/exploration/constants.ts | 1 + .../exploration/dataset-list-item.tsx | 2 + .../exploration/dataset-options.tsx | 100 ++++++++++++++++++ app/scripts/components/exploration/hooks.ts | 54 +++++++++- app/scripts/styles/drop-menu-item-button.tsx | 49 ++++++--- app/scripts/styles/range-slider.tsx | 73 +++++++++++++ package.json | 1 + yarn.lock | 13 +++ 8 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 app/scripts/components/exploration/dataset-options.tsx create mode 100644 app/scripts/styles/range-slider.tsx diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 563b17841..19ef16650 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -30,6 +30,7 @@ export interface TimelineDataset { settings: { // user defined settings like visibility, opacity isVisible?: boolean; + opacity?: number; }; } diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index cd721fa54..1cb3059da 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -38,6 +38,7 @@ import { } from './dataset-list-item-status'; import { DatasetChart } from './dataset-chart'; import { activeAnalysisMetricsAtom, isAnalysisAtom } from './atoms'; +import DatasetOptions from './dataset-options'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -170,6 +171,7 @@ export function DatasetListItem(props: DatasetListItemProps) { {dataset.data.title} + {!isError ? ( setVisible((v) => !v)}> {isVisible ? ( diff --git a/app/scripts/components/exploration/dataset-options.tsx b/app/scripts/components/exploration/dataset-options.tsx new file mode 100644 index 000000000..70033bbeb --- /dev/null +++ b/app/scripts/components/exploration/dataset-options.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { PrimitiveAtom, useAtomValue, useSetAtom } from 'jotai'; +import 'react-range-slider-input/dist/style.css'; +import styled from 'styled-components'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Dropdown, DropMenu, DropTitle } from '@devseed-ui/dropdown'; +import { Button } from '@devseed-ui/button'; +import { CollecticonCog, CollecticonTrashBin } from '@devseed-ui/collecticons'; +import { Overline } from '@devseed-ui/typography'; + +import { useTimelineDatasetSettings } from './hooks'; +import { TimelineDataset } from './constants'; +import { timelineDatasetsAtom } from './atoms'; + +import DropMenuItemButton from '$styles/drop-menu-item-button'; +import { SliderInput, SliderInputProps } from '$styles/range-slider'; + +interface DatasetOptionsProps { + datasetAtom: PrimitiveAtom; +} + +export default function DatasetOptions(props: DatasetOptionsProps) { + const { datasetAtom } = props; + + const setDatasets = useSetAtom(timelineDatasetsAtom); + const dataset = useAtomValue(datasetAtom); + const [getSettings, setSetting] = useTimelineDatasetSettings(datasetAtom); + + const opacity = (getSettings('opacity') ?? 100) as number; + + return ( + ( + + )} + > + View options + +
  • + setSetting('opacity', v)} + /> +
  • +
    + +
  • + { + setDatasets((datasets) => + datasets.filter((d) => d.data.id !== dataset.data.id) + ); + }} + > + Remove dataset + +
  • +
    +
    + ); +} + +const OpacityControlWrapper = styled.div` + padding: ${glsp(0.5, 1)}; + display: flex; + flex-flow: column; + gap: ${glsp(0.25)}; +`; + +const OpacityControlElements = styled.div` + display: flex; + gap: ${glsp(0.5)}; + align-items: center; +`; + +const OpacityValue = styled.span` + font-size: 0.75rem; + font-weight: ${themeVal('type.base.regular')}; + color: ${themeVal('color.base-400')}; + width: 2rem; + text-align: right; +`; + +function OpacityControl(props: SliderInputProps) { + const { value, onInput } = props; + + return ( + + Opacity + + + {value} + + + ); +} diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index a681c0018..1086afbe5 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -1,4 +1,4 @@ -import { useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { extent, scaleTime } from 'd3'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; import { focusAtom } from 'jotai-optics'; @@ -109,6 +109,58 @@ export function useTimelineDatasetAtom(id: string) { return datasetAtom as PrimitiveAtom; } +type TimelineDatasetSettingsReturn = [ + ( + prop: keyof TimelineDataset['settings'] + ) => TimelineDataset['settings'][keyof TimelineDataset['settings']], + ( + prop: keyof TimelineDataset['settings'], + value: + | TimelineDataset['settings'][keyof TimelineDataset['settings']] + | (( + prev: TimelineDataset['settings'][keyof TimelineDataset['settings']] + ) => TimelineDataset['settings'][keyof TimelineDataset['settings']]) + ) => void +]; + +/** + * Hook to get/set the settings of a dataset. + * + * @param datasetAtom Single dataset atom. + * @returns State getter/setter for the dataset settings. + * + * @example + * const [get, set] = useTimelineDatasetSettings(datasetAtom); + * const isVisible = get('isVisible'); + * set('isVisible', !isVisible); + * set('isVisible', (prev) => !prev); + * + */ +export function useTimelineDatasetSettings( + datasetAtom: PrimitiveAtom +): TimelineDatasetSettingsReturn { + const settingsAtom = useMemo(() => { + return focusAtom(datasetAtom, (optic) => optic.prop('settings')); + }, [datasetAtom]); + + const [value, set] = useAtom(settingsAtom); + + const setter = useCallback( + (prop, value) => { + set((prev) => { + const currValue = prev[prop]; + const newValue = typeof value === 'function' ? value(currValue) : value; + return { ...prev, [prop]: newValue }; + }); + }, + [set] + ); + + const getter = useCallback((prop) => value[prop], [value]); + + return [getter, setter]; +} + /** * Hook to get/set the visibility of a dataset. * @param datasetAtom Single dataset atom. diff --git a/app/scripts/styles/drop-menu-item-button.tsx b/app/scripts/styles/drop-menu-item-button.tsx index 0dfe18166..99e88a160 100644 --- a/app/scripts/styles/drop-menu-item-button.tsx +++ b/app/scripts/styles/drop-menu-item-button.tsx @@ -1,27 +1,52 @@ import styled, { css } from 'styled-components'; -import { DropMenuItem } from '@devseed-ui/dropdown'; -import { rgba, themeVal } from '@devseed-ui/theme-provider'; +import { DropMenuItem, DropMenuItemProps } from '@devseed-ui/dropdown'; +import { themeVal } from '@devseed-ui/theme-provider'; -const rgbaFixed = rgba as any; +interface DropMenuItemButtonProps extends DropMenuItemProps { + variation?: + | 'base' + | 'primary' + | 'secondary' + | 'danger' + | 'success' + | 'warning' + | 'info'; +} const DropMenuItemButton = styled(DropMenuItem).attrs({ as: 'button', 'data-dropdown': 'click.close' -})` +})` background: none; border: none; width: 100%; cursor: pointer; text-align: left; - ${({ active }) => - active && - css` - &, - &:visited { - background-color: ${rgbaFixed(themeVal('color.link'), 0.08)}; - } - `} + ${(props) => renderVariation(props)} `; export default DropMenuItemButton; + +function renderVariation(props: DropMenuItemButtonProps) { + const { variation = 'base', active } = props; + + return css` + color: ${themeVal(`color.${variation}`)}; + + &:hover { + color: ${themeVal(`color.${variation}`)}; + background-color: ${themeVal(`color.${variation}-50a`)}; + } + + /* Print & when prop is passed */ + ${active && '&,'} + &.active { + background-color: ${themeVal(`color.${variation}-100a`)}; + } + + &:focus-visible { + outline-color: ${themeVal(`color.${variation}-200a`)}; + } + `; +} diff --git a/app/scripts/styles/range-slider.tsx b/app/scripts/styles/range-slider.tsx new file mode 100644 index 000000000..5f5740760 --- /dev/null +++ b/app/scripts/styles/range-slider.tsx @@ -0,0 +1,73 @@ +import React from 'react'; +import styled from 'styled-components'; +import RangeSlider from 'react-range-slider-input'; +import 'react-range-slider-input/dist/style.css'; + +import { themeVal } from '@devseed-ui/theme-provider'; + +export interface RangeSliderInputProps { + id?: string; + className?: string; + min?: number; + max?: number; + step?: number; + defaultValue?: [number, number]; + value: [number, number]; + onInput: (v: [number, number]) => void; + // onThumbDragStart; + // onThumbDragEnd; + // onRangeDragStart; + // onRangeDragEnd; + disabled?: boolean; + rangeSlideDisabled?: boolean; + thumbsDisabled?: [boolean, boolean]; + orientation?: 'horizontal' | 'vertical'; +} + +export const RangeSliderInput = styled(RangeSlider)` + && { + background: ${themeVal('color.base-200')}; + border-radius: ${themeVal('shape.rounded')}; + + .range-slider__range { + border-radius: ${themeVal('shape.rounded')}; + background: ${themeVal('color.primary')}; + } + + .range-slider__thumb { + transition: background 160ms ease-in-out; + background: ${themeVal('color.surface')}; + box-shadow: ${themeVal('boxShadow.elevationD')}; + width: 1.25rem; + height: 1.25rem; + + &:hover { + background: ${themeVal('color.base-50')}; + } + } + + .range-slider__thumb[data-lower] { + width: 0; + } + } +`; + +export interface SliderInputProps + extends Omit { + value: number; + onInput: (v: number) => void; +} + +export function SliderInput(props: SliderInputProps) { + const { value, onInput, ...rest } = props; + + return ( + onInput(v[1])} + thumbsDisabled={[true, false]} + rangeSlideDisabled + /> + ); +} diff --git a/package.json b/package.json index a6569899c..bebbe2805 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "react-indiana-drag-scroll": "^2.2.0", "react-lazyload": "^3.2.0", "react-nl2br": "^1.0.2", + "react-range-slider-input": "^3.0.7", "react-resizable-panels": "^0.0.45", "react-router": "^6.0.0", "react-router-dom": "^6.0.0", diff --git a/yarn.lock b/yarn.lock index efd854c79..428295249 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4905,6 +4905,11 @@ core-js-compat@^3.21.0, core-js-compat@^3.22.1: browserslist "^4.21.1" semver "7.0.0" +core-js@^3.22.4: + version "3.32.0" + resolved "http://verdaccio.ds.io:4873/core-js/-/core-js-3.32.0.tgz#7643d353d899747ab1f8b03d2803b0312a0fb3b6" + integrity sha512-rd4rYZNlF3WuoYuRIDEmbR/ga9CeuWX9U05umAvgrrZoHY4Z++cp/xwPQMvUpBB4Ag6J8KfD80G0zwCyaSxDww== + core-util-is@~1.0.0: version "1.0.3" resolved "http://verdaccio.ds.io:4873/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" @@ -10661,6 +10666,14 @@ react-nl2br@^1.0.2: resolved "http://verdaccio.ds.io:4873/react-nl2br/-/react-nl2br-1.0.4.tgz#20079e2660b9e9a5293b115466e3749abeed6d87" integrity sha512-KQ+uwmjYk3B04xDl0hus2OeGoqkR8qkoyDBtsIqVacOUMNeaP0W+r/+anded2ehii/FlgFqEuu0T72CJLWFp4A== +react-range-slider-input@^3.0.7: + version "3.0.7" + resolved "http://verdaccio.ds.io:4873/react-range-slider-input/-/react-range-slider-input-3.0.7.tgz#88ceb118b33d7eb0550cec1f77fc3e60e0f880f9" + integrity sha512-yAJDDMUNkILOcZSCLCVbwgnoAM3v0AfdDysTCMXDwY/+ZRNRlv98TyHbVCwPFEd7qiI8Ca/stKb0GAy//NybYw== + dependencies: + clsx "^1.1.1" + core-js "^3.22.4" + react-refresh@^0.9.0: version "0.9.0" resolved "http://verdaccio.ds.io:4873/react-refresh/-/react-refresh-0.9.0.tgz#71863337adc3e5c2f8a6bfddd12ae3bfe32aafbf" From 7fb12d75788362cd3a79e1a0259fd4f8d3330593 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 21 Aug 2023 17:51:35 +0100 Subject: [PATCH 017/208] Add floating-ui library --- package.json | 1 + yarn.lock | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+) diff --git a/package.json b/package.json index bebbe2805..0992061fa 100644 --- a/package.json +++ b/package.json @@ -109,6 +109,7 @@ "@devseed-ui/theme-provider": "4.1.0", "@devseed-ui/toolbar": "4.1.0", "@devseed-ui/typography": "4.1.0", + "@floating-ui/react": "^0.25.1", "@mapbox/mapbox-gl-draw": "^1.3.0", "@mapbox/mapbox-gl-geocoder": "^5.0.1", "@parcel/transformer-raw": "~2.7.0", diff --git a/yarn.lock b/yarn.lock index 428295249..586619948 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1501,6 +1501,42 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" +"@floating-ui/core@^1.4.1": + version "1.4.1" + resolved "http://verdaccio.ds.io:4873/@floating-ui%2fcore/-/core-1.4.1.tgz#0d633f4b76052668afb932492ac452f7ebe97f17" + integrity sha512-jk3WqquEJRlcyu7997NtR5PibI+y5bi+LS3hPmguVClypenMsCY3CBa3LAQnozRCtCrYWSEtAdiskpamuJRFOQ== + dependencies: + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/dom@^1.3.0": + version "1.5.1" + resolved "http://verdaccio.ds.io:4873/@floating-ui%2fdom/-/dom-1.5.1.tgz#88b70defd002fe851f17b4a25efb2d3c04d7a8d7" + integrity sha512-KwvVcPSXg6mQygvA1TjbN/gh///36kKtllIF8SUm0qpFj8+rvYrpvlYdL1JoA71SHpDqgSSdGOSoQ0Mp3uY5aw== + dependencies: + "@floating-ui/core" "^1.4.1" + "@floating-ui/utils" "^0.1.1" + +"@floating-ui/react-dom@^2.0.1": + version "2.0.1" + resolved "http://verdaccio.ds.io:4873/@floating-ui%2freact-dom/-/react-dom-2.0.1.tgz#7972a4fc488a8c746cded3cfe603b6057c308a91" + integrity sha512-rZtAmSht4Lry6gdhAJDrCp/6rKN7++JnL1/Anbr/DdeyYXQPxvg/ivrbYvJulbRf4vL8b212suwMM2lxbv+RQA== + dependencies: + "@floating-ui/dom" "^1.3.0" + +"@floating-ui/react@^0.25.1": + version "0.25.1" + resolved "http://verdaccio.ds.io:4873/@floating-ui%2freact/-/react-0.25.1.tgz#174bf4322913aa3549aeff27a0755fe10c66686d" + integrity sha512-lxuWxfSgDJwOeZK07PIDjTSlH0CY6LRDKo6eI0H7TnctP+5IAn0n8+npNveM0L2wNIVdAr0S8RvvoHfhzPbBAQ== + dependencies: + "@floating-ui/react-dom" "^2.0.1" + "@floating-ui/utils" "^0.1.1" + tabbable "^6.0.1" + +"@floating-ui/utils@^0.1.1": + version "0.1.1" + resolved "http://verdaccio.ds.io:4873/@floating-ui%2futils/-/utils-0.1.1.tgz#1a5b1959a528e374e8037c4396c3e825d6cf4a83" + integrity sha512-m0G6wlnhm/AX0H12IOWtK8gASEMffnX08RtKkCgTdHb9JpHKGloI7icFfLg9ZmQeavcvR0PKmzxClyuFPSjKWw== + "@humanwhocodes/config-array@^0.9.2": version "0.9.5" resolved "http://verdaccio.ds.io:4873/@humanwhocodes%2fconfig-array/-/config-array-0.9.5.tgz#2cbaf9a89460da24b5ca6531b8bbfc23e1df50c7" @@ -11948,6 +11984,11 @@ symbol-tree@^3.2.4: resolved "http://verdaccio.ds.io:4873/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== +tabbable@^6.0.1: + version "6.2.0" + resolved "http://verdaccio.ds.io:4873/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + table@^6.6.0: version "6.8.0" resolved "http://verdaccio.ds.io:4873/table/-/table-6.8.0.tgz#87e28f14fa4321c3377ba286f07b79b281a3b3ca" From eebf5e6f75f3cb9e08a048ca75dd135a5a9181ec Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 21 Aug 2023 17:53:39 +0100 Subject: [PATCH 018/208] Add dataset hover interaction based on mouse position --- .../exploration/dataset-list-item.tsx | 11 ++ .../components/exploration/timeline.tsx | 5 + .../exploration/use-dataset-hover.ts | 118 ++++++++++++++++++ 3 files changed, 134 insertions(+) create mode 100644 app/scripts/components/exploration/use-dataset-hover.ts diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 1cb3059da..d43e62a2b 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -39,6 +39,7 @@ import { import { DatasetChart } from './dataset-chart'; import { activeAnalysisMetricsAtom, isAnalysisAtom } from './atoms'; import DatasetOptions from './dataset-options'; +import { useDatasetHover } from './use-dataset-hover'; import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; @@ -143,10 +144,20 @@ export function DatasetListItem(props: DatasetListItemProps) { const controls = useDragControls(); + // Hook to handle the hover state of the dataset. Check the source file as to + // why this is needed. + const { + ref: datasetLiRef, + isHovering, + clientX, + layerX, + midY + } = useDatasetHover(); const isError = dataset.status === TimelineDatasetStatus.ERRORED; return ( (voidMousePosition); + +/** + * This hook is used to track the mouse position on the interaction rect. The + * position is stored in an atom for later retrieval. + * + * @param interactionRectEl The element to which the different listeners will be + * attached. + */ +export function useInteractionRectHover( + interactionRectEl: HTMLDivElement | null +) { + const setMousePosition = useSetAtom(mousePositionAtom); + + useEffect(() => { + if (!interactionRectEl) return; + + const element = interactionRectEl; + + const moveListener = (e) => { + const { clientX, clientY, layerX, layerY } = e; + setMousePosition({ clientX, clientY, layerX, layerY }); + }; + + const moveOutListener = () => { + setMousePosition(voidMousePosition); + }; + + element.addEventListener('mousemove', moveListener); + element.addEventListener('mouseout', moveOutListener); + element.addEventListener('wheel', moveListener); + + return () => { + element.removeEventListener('mousemove', moveListener); + element.removeEventListener('mouseout', moveOutListener); + element.removeEventListener('wheel', moveListener); + }; + }, [interactionRectEl, setMousePosition]); +} + +type DatasetHoverHookReturn = + | { + ref: React.MutableRefObject; + isHovering: false; + clientX?: undefined; + midY?: undefined; + layerX?: undefined; + } + | { + ref: React.MutableRefObject; + isHovering: true; + clientX: number; + midY: number; + layerX: number; + }; + +/** + * This hook checks whether the mouse is hovering over a dataset in the timeline + * dataset list. + * + * This is needed because the interaction rectangle is covering (on top of) the + * dataset list item and events are not propagated to the list item. The + * solution is to get the mouse position when it interacts with the interaction + * rectangle (via useInteractionRectHover hook) and then check if it is inside + * the dataset list item bounds. This has to be done programmatically by + * accessing the DOM element's bounding rect. + * + * @returns + */ +export function useDatasetHover(): DatasetHoverHookReturn { + // Ref that will be attached to the dataset list item. + const elRef = useRef(); + const rect = elRef.current?.getBoundingClientRect(); + + const { clientX, clientY, layerX } = useAtomValue(mousePositionAtom); + + if ( + !rect || + clientX === undefined || + clientY === undefined || + layerX === undefined + ) { + return { ref: elRef, isHovering: false }; + } + + const isHovering = + clientX >= rect.left && + clientX <= rect.right && + clientY >= rect.top && + clientY <= rect.bottom; + + if (!isHovering) { + return { ref: elRef, isHovering: false }; + } + + return { + ref: elRef, + isHovering, + clientX, + layerX, + midY: rect.top + rect.height / 2, + }; +} From 05d259fc62d67b3b8413c5d82fe63d784df32bf1 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 21 Aug 2023 17:54:16 +0100 Subject: [PATCH 019/208] Add dataset analysis types and mock data --- .../components/exploration/constants.ts | 17 +++ .../components/exploration/datasets-mock.tsx | 107 ++++++++++++------ 2 files changed, 91 insertions(+), 33 deletions(-) diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index 19ef16650..ea09aab27 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -23,6 +23,22 @@ export enum TimelineDatasetStatus { ERRORED = 'errored' } +export type AnalysisTimeseriesEntry = Record & { + date: Date; +}; + +export interface TimelineDatasetAnalysis { + status: TimelineDatasetStatus; + data: { + timeseries?: AnalysisTimeseriesEntry[]; + }; + error: any; + meta: { + loaded?: number; + total?: number; + }; +} + export interface TimelineDataset { status: TimelineDatasetStatus; data: any; @@ -32,6 +48,7 @@ export interface TimelineDataset { isVisible?: boolean; opacity?: number; }; + analysis: TimelineDatasetAnalysis; } export interface DateRange { diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index f2204b0a6..2438063d1 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -5,7 +5,11 @@ import styled from 'styled-components'; import { Button } from '@devseed-ui/button'; import { isAnalysisAtom, isExpandedAtom, timelineDatasetsAtom } from './atoms'; -import { TimelineDataset, TimelineDatasetStatus } from './constants'; +import { + TimelineDataset, + TimelineDatasetAnalysis, + TimelineDatasetStatus +} from './constants'; const chartData = { status: 'succeeded', @@ -14,11 +18,9 @@ const chartData = { loaded: 9 }, data: { - isPeriodic: true, - timeDensity: 'month', timeseries: [ { - date: '2020-10-01T00:00:00', + date: new Date('2020-10-01T00:00:00'), min: -2086298214989824, max: 12890265228410880, mean: 798724301873588, @@ -27,7 +29,7 @@ const chartData = { std: 714889121800240.6 }, { - date: '2020-09-01T00:00:00', + date: new Date('2020-09-01T00:00:00'), min: -243302179799040, max: 6783829977071616, mean: 826268261725556.4, @@ -36,7 +38,7 @@ const chartData = { std: 525758341466615.44 }, { - date: '2020-08-01T00:00:00', + date: new Date('2020-08-01T00:00:00'), min: -141698134966272, max: 3727485078339584, mean: 725800731063289.5, @@ -45,7 +47,7 @@ const chartData = { std: 353335499843328.06 }, { - date: '2020-07-01T00:00:00', + date: new Date('2020-07-01T00:00:00'), min: -147666428231680, max: 3569522892079104, mean: 786568136687148.8, @@ -54,7 +56,7 @@ const chartData = { std: 366549997729682.4 }, { - date: '2020-06-01T00:00:00', + date: new Date('2020-06-01T00:00:00'), min: -255044100292608, max: 3507326933794816, mean: 771834013412698, @@ -63,7 +65,7 @@ const chartData = { std: 379108800939232 }, { - date: '2020-05-01T00:00:00', + date: new Date('2020-05-01T00:00:00'), min: -274287718039552, max: 5203029145944064, mean: 800096907890949, @@ -72,7 +74,7 @@ const chartData = { std: 396133006092423.75 }, { - date: '2020-04-01T00:00:00', + date: new Date('2020-04-01T00:00:00'), min: -1733678178762752, max: 4642589600907264, mean: 698716974847719.5, @@ -81,7 +83,7 @@ const chartData = { std: 450048731150610.9 }, { - date: '2020-03-01T00:00:00', + date: new Date('2020-03-01T00:00:00'), min: -800272532111360, max: 8905950232576000, mean: 705833097884692.6, @@ -90,7 +92,7 @@ const chartData = { std: 528843673285467.1 }, { - date: '2020-02-01T00:00:00', + date: new Date('2020-02-01T00:00:00'), min: -426854754287616, max: 12515318878437376, mean: 781040757444822.8, @@ -109,95 +111,93 @@ const chartData2 = { loaded: 15 }, data: { - isPeriodic: true, - timeDensity: 'day', timeseries: [ { - date: '2020-04-01T00:00:00', + date: new Date('2020-04-01T00:00:00'), min: -6, max: 17, mean: 8 }, { - date: '2020-04-02T00:00:00', + date: new Date('2020-04-02T00:00:00'), min: -9, max: 15, mean: 5 }, { - date: '2020-04-03T00:00:00', + date: new Date('2020-04-03T00:00:00'), min: -3, max: 15, mean: 10 }, { - date: '2020-04-04T00:00:00', + date: new Date('2020-04-04T00:00:00'), min: 2, max: 18, mean: 6 }, { - date: '2020-04-05T00:00:00', + date: new Date('2020-04-05T00:00:00'), min: 3, max: 19, mean: 15 }, { - date: '2020-04-06T00:00:00', + date: new Date('2020-04-06T00:00:00'), min: null, max: null, mean: null }, { - date: '2020-04-07T00:00:00', + date: new Date('2020-04-07T00:00:00'), min: -8, max: 17, mean: 8 }, { - date: '2020-04-08T00:00:00', + date: new Date('2020-04-08T00:00:00'), min: -8, max: 19, mean: 9 }, { - date: '2020-04-09T00:00:00', + date: new Date('2020-04-09T00:00:00'), min: 2, max: 15, mean: 8 }, { - date: '2020-04-10T00:00:00', + date: new Date('2020-04-10T00:00:00'), min: null, max: null, mean: null }, { - date: '2020-04-11T00:00:00', + date: new Date('2020-04-11T00:00:00'), min: 3, max: 20, mean: 8 }, { - date: '2020-04-12T00:00:00', + date: new Date('2020-04-12T00:00:00'), min: 0, max: 20, mean: 10 }, { - date: '2020-04-13T00:00:00', + date: new Date('2020-04-13T00:00:00'), min: -10, max: 16, mean: 11 }, { - date: '2020-04-14T00:00:00', + date: new Date('2020-04-14T00:00:00'), min: -9, max: 16, mean: 11 }, { - date: '2020-04-15T00:00:00', + date: new Date('2020-04-15T00:00:00'), min: 3, max: 17, mean: 9 @@ -353,10 +353,24 @@ const datasets = [ } ].map((d) => makeDataset(d)); +function makeAnalysis( + data, + meta, + status = TimelineDatasetStatus.IDLE +): TimelineDatasetAnalysis { + return { + status, + meta, + data, + error: null + }; +} + function makeDataset( data, status = TimelineDatasetStatus.SUCCEEDED, - settings: Record = {} + settings: Record = {}, + analysis = makeAnalysis({}, {}) ): TimelineDataset { return { status, @@ -365,7 +379,8 @@ function makeDataset( settings: { ...settings, isVisible: settings.isVisible === undefined ? true : settings.isVisible - } + }, + analysis }; } @@ -460,7 +475,20 @@ export function MockControls() { + ) : null} + + )} + ); /* eslint-enable react/no-array-index-key */ } diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index 076521c26..c35963b7a 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -15,7 +15,6 @@ import { } from 'date-fns'; import { ScaleTime } from 'd3'; import { - CollecticonArrowSpinCw, CollecticonEye, CollecticonEyeDisabled, CollecticonGripVertical @@ -177,7 +176,16 @@ export function DatasetListItem(props: DatasetListItemProps) { data: dataPoint }); - const isError = dataset.status === TimelineDatasetStatus.ERRORED; + const isDatasetError = dataset.status === TimelineDatasetStatus.ERRORED; + const isDatasetLoading = dataset.status === TimelineDatasetStatus.LOADING; + const isDatasetSucceeded = dataset.status === TimelineDatasetStatus.SUCCEEDED; + + const isAnalysisAndError = + isAnalysis && dataset.analysis.status === TimelineDatasetStatus.ERRORED; + const isAnalysisAndLoading = + isAnalysis && dataset.analysis.status === TimelineDatasetStatus.LOADING; + const isAnalysisAndSucceeded = + isAnalysis && dataset.analysis.status === TimelineDatasetStatus.SUCCEEDED; return ( controls.start(e)} /> - + {dataset.data.title} - {!isError ? ( - setVisible((v) => !v)}> - {isVisible ? ( - - ) : ( - - )} - - ) : ( - - setVisible((v) => !v)}> + {isVisible ? ( + + ) : ( + - - )} + )} +
    - {dataset.status === TimelineDatasetStatus.LOADING && ( - + {isDatasetLoading && } + + {isDatasetError && ( + { + /* eslint-disable-next-line no-console */ + console.log('Retry metadata loading'); + }} + /> )} - {isError && } - {dataset.status === TimelineDatasetStatus.SUCCEEDED && - (isAnalysis ? ( - - ) : ( - - ))} + + {isDatasetSucceeded && ( + <> + {isAnalysisAndLoading && ( + + )} + {isAnalysisAndError && ( + { + /* eslint-disable-next-line no-console */ + console.log('Retry analysis loading'); + }} + /> + )} + {isAnalysisAndSucceeded && ( + + )} + + )} + + {isDatasetSucceeded && !isAnalysis && ( + + )} + {isVisible && isPopoverVisible && dataPoint && ( toggle dataset 2020 + + + } + /> + ); +} diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index e7519d9f0..cd212c1aa 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -208,7 +208,7 @@ const chartData2 = { const extraDataset = { id: 'infinity', - title: 'Daily infinity!', + name: 'Daily infinity!', timeDensity: 'day', domain: eachDayOfInterval({ start: new Date('2000-01-01'), @@ -218,7 +218,7 @@ const extraDataset = { const dataset2020 = { id: '2020', - title: '2020', + name: '2020', timeDensity: 'month', domain: eachMonthOfInterval({ start: new Date('2020-01-01'), @@ -229,21 +229,21 @@ const dataset2020 = { const dataset2Months = { id: 'two-dates', - title: 'Two Dates', + name: 'Two Dates', timeDensity: 'month', domain: [new Date('2020-01-01'), new Date('2020-02-01')] }; const dataset2Days = { id: 'two-days', - title: 'Two Days', + name: 'Two Days', timeDensity: 'day', domain: [new Date('2020-01-05'), new Date('2020-01-06')] }; const datasetSingle = { id: 'single-dates', - title: 'Single Date', + name: 'Single Date', timeDensity: 'day', domain: [new Date('2020-01-01')], analysis: chartData2 @@ -252,7 +252,7 @@ const datasetSingle = { const datasets = [ { id: 'monthly', - title: 'Monthly dataset', + name: 'Monthly dataset', timeDensity: 'month', domain: [ new Date('2020-01-01'), @@ -264,7 +264,7 @@ const datasets = [ }, { id: 'daily', - title: 'Daily dataset', + name: 'Daily dataset', timeDensity: 'day', domain: [ new Date('2020-01-01'), @@ -332,7 +332,7 @@ const datasets = [ }, { id: 'daily2', - title: 'Daily 2', + name: 'Daily 2', timeDensity: 'day', domain: [ new Date('2020-01-01'), @@ -344,7 +344,7 @@ const datasets = [ }, { id: 'daily3', - title: 'Daily 3', + name: 'Daily 3', timeDensity: 'day', domain: eachDayOfInterval({ start: new Date('2020-01-01'), @@ -428,7 +428,7 @@ export function MockControls() { makeDataset( { id: 'loading', - title: 'Loading dataset' + name: 'Loading dataset' }, TimelineDatasetStatus.LOADING ) @@ -446,7 +446,7 @@ export function MockControls() { makeDataset( { id: 'errored', - title: 'Error dataset' + name: 'Error dataset' }, TimelineDatasetStatus.ERRORED ) @@ -523,7 +523,7 @@ export function MockControls() { { ...dataset2020, id: 'analysis-loading', - title: 'Analysis loading' + name: 'Analysis loading' }, TimelineDatasetStatus.SUCCEEDED, {}, @@ -548,7 +548,7 @@ export function MockControls() { { ...dataset2020, id: 'analysis-error', - title: 'Analysis Error' + name: 'Analysis Error' }, TimelineDatasetStatus.SUCCEEDED, {}, diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/hooks.ts index 1086afbe5..ab92b47e9 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/hooks.ts @@ -9,7 +9,7 @@ import { timelineSizesAtom, zoomTransformAtom } from './atoms'; -import { rescaleX } from './utils'; +import { rescaleX } from './timeline-utils'; import { DAY_SIZE_MAX, DAY_SIZE_MIN, diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index ca5cf6610..bb5762f88 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -1,10 +1,11 @@ -import React from 'react'; +import React, { useCallback, useState } from 'react'; import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; import styled from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; import { MockControls } from './datasets-mock'; import Timeline from './timeline'; +import { DatasetSelectorModal } from './dataset-selector-modal'; import { LayoutProps } from '$components/common/layout-root'; import PageHero from '$components/common/page-hero'; @@ -52,6 +53,11 @@ const Container = styled.div` `; function Exploration() { + const [datasetModalRevealed, setDatasetModalRevealed] = useState(true); + + const openModal = useCallback(() => setDatasetModalRevealed(true), []); + const closeModal = useCallback(() => setDatasetModalRevealed(false), []); + return ( <> - + + ); } - export default Exploration; diff --git a/app/scripts/components/exploration/utils.ts b/app/scripts/components/exploration/timeline-utils.ts similarity index 95% rename from app/scripts/components/exploration/utils.ts rename to app/scripts/components/exploration/timeline-utils.ts index b1035a64a..44ffae8b1 100644 --- a/app/scripts/components/exploration/utils.ts +++ b/app/scripts/components/exploration/timeline-utils.ts @@ -8,7 +8,11 @@ import { ScaleTime, Selection, ZoomBehavior, ZoomTransform } from 'd3'; * @param k Scale factor * @returns New scale */ -export function rescaleX(scale: ScaleTime, x: number, k: number) { +export function rescaleX( + scale: ScaleTime, + x: number, + k: number +) { const range = scale.range(); return scale.copy().domain( range.map((v) => { @@ -65,4 +69,4 @@ export function applyTransform( // a sourceEvent. On the zoom event listener, the updated zoom transform // is set on the state, so there's no need to do it here. zoomBehavior.transform(element, newTransform); -} \ No newline at end of file +} diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/timeline.tsx index 1dba4d7f6..888f9688c 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/timeline.tsx @@ -38,9 +38,10 @@ import { TimelineDatasetStatus, ZoomTransformPlain } from './constants'; -import { applyTransform, isEqualTransform, rescaleX } from './utils'; +import { applyTransform, isEqualTransform, rescaleX } from './timeline-utils'; import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; import { useInteractionRectHover } from './use-dataset-hover'; +import { datasetLayers } from './data-utils'; import { useLayoutEffectPrevious, @@ -120,7 +121,13 @@ const TimelineContentInner = styled.div` position: relative; `; -export default function Timeline() { +interface TimelineProps { + onDatasetAddClick: () => void; +} + +export default function Timeline(props: TimelineProps) { + const { onDatasetAddClick } = props; + // Refs for non react based interactions. // The interaction rect is used to capture the different d3 events for the // zoom. @@ -353,11 +360,11 @@ export default function Timeline() { Datasets - -

    {datasets.length} of Y

    +

    {datasets.length} of {datasetLayers.length}

    From 5ba5508dc34330adf57d139f3cebd596d77d083a Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 30 Aug 2023 21:46:18 +0100 Subject: [PATCH 024/208] Remove sandbox from timeline --- app/scripts/components/sandbox/index.js | 8 - .../components/sandbox/timeline/constants.ts | 14 - .../sandbox/timeline/dataset-list-item.tsx | 247 -------- .../components/sandbox/timeline/datasets.ts | 110 ---- .../components/sandbox/timeline/date-axis.tsx | 168 ------ .../components/sandbox/timeline/index.tsx | 65 -- .../timeline/test-timeline-side-scrub.tsx | 555 ------------------ .../sandbox/timeline/timeline-head.tsx | 200 ------- .../components/sandbox/timeline/timeline.tsx | 464 --------------- 9 files changed, 1831 deletions(-) delete mode 100644 app/scripts/components/sandbox/timeline/constants.ts delete mode 100644 app/scripts/components/sandbox/timeline/dataset-list-item.tsx delete mode 100644 app/scripts/components/sandbox/timeline/datasets.ts delete mode 100644 app/scripts/components/sandbox/timeline/date-axis.tsx delete mode 100644 app/scripts/components/sandbox/timeline/index.tsx delete mode 100644 app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx delete mode 100644 app/scripts/components/sandbox/timeline/timeline-head.tsx delete mode 100644 app/scripts/components/sandbox/timeline/timeline.tsx diff --git a/app/scripts/components/sandbox/index.js b/app/scripts/components/sandbox/index.js index 113e2abbb..17b999752 100644 --- a/app/scripts/components/sandbox/index.js +++ b/app/scripts/components/sandbox/index.js @@ -17,7 +17,6 @@ import SandboxRequest from './request'; import SandboxColors from './colors'; import SandboxMDXEditor from './mdx-editor'; import SandboxTable from './table'; -import SandboxTimeline from './timeline'; import { resourceNotFound } from '$components/uhoh'; import { Card, CardList } from '$components/common/card'; import { Fold, FoldHeader, FoldTitle } from '$components/common/fold'; @@ -105,13 +104,6 @@ const pages = [ id: 'sandboxtable', name: 'Table', component: SandboxTable - }, - { - id: 'timeline', - name: 'Timeline', - component: SandboxTimeline, - noHero: true, - noFooter: true } ]; diff --git a/app/scripts/components/sandbox/timeline/constants.ts b/app/scripts/components/sandbox/timeline/constants.ts deleted file mode 100644 index 53dad8b3d..000000000 --- a/app/scripts/components/sandbox/timeline/constants.ts +++ /dev/null @@ -1,14 +0,0 @@ -export const RIGHT_AXIS_SPACE = 80; -export const HEADER_COLUMN_WIDTH = 320; - -export enum TimeDensity { - YEAR = 'year', - MONTH = 'month', - DAY = 'day' -} - -export interface TimelineDataset { - title: string; - timeDensity: TimeDensity; - domain: Date[]; -} diff --git a/app/scripts/components/sandbox/timeline/dataset-list-item.tsx b/app/scripts/components/sandbox/timeline/dataset-list-item.tsx deleted file mode 100644 index e44ee0794..000000000 --- a/app/scripts/components/sandbox/timeline/dataset-list-item.tsx +++ /dev/null @@ -1,247 +0,0 @@ -import React, { useMemo, useState } from 'react'; -import { Reorder, useDragControls } from 'framer-motion'; -import styled, { useTheme } from 'styled-components'; -import { - addDays, - subDays, - endOfDay, - endOfMonth, - endOfYear, - startOfDay, - startOfMonth, - startOfYear, - areIntervalsOverlapping -} from 'date-fns'; -import { ScaleTime } from 'd3'; -import { - CollecticonEye, - CollecticonEyeDisabled, - CollecticonGripVertical -} from '@devseed-ui/collecticons'; -import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { Toolbar, ToolbarIconButton } from '@devseed-ui/toolbar'; -import { Heading } from '@devseed-ui/typography'; - -import { HEADER_COLUMN_WIDTH, TimeDensity, TimelineDataset } from './constants'; - -import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; - -function getBlockBoundaries(date: Date, timeDensity: TimeDensity) { - switch (timeDensity) { - case TimeDensity.MONTH: - return [startOfMonth(date), endOfMonth(date)]; - case TimeDensity.YEAR: - return [startOfYear(date), endOfYear(date)]; - } - - return [startOfDay(date), endOfDay(date)]; -} - -const DatasetItem = styled.article` - width: 100%; - display: flex; - position: relative; - - ::before, - ::after { - position: absolute; - content: ''; - display: block; - width: 100%; - background: ${themeVal('color.base-200')}; - height: 1px; - } - - ::before { - top: 0; - } - - ::after { - bottom: -1px; - } -`; - -const DatasetHeader = styled.header` - width: ${HEADER_COLUMN_WIDTH}px; - flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - background: ${themeVal('color.surface')}; - padding: ${glsp(0.5)}; - display: flex; - align-items: center; - gap: 0.5rem; - - ${CollecticonGripVertical} { - cursor: grab; - color: ${themeVal('color.base-300')}; - - &:active { - cursor: grabbing; - } - } -`; - -const DatasetInfo = styled.div` - width: 100%; - display: flex; - flex-flow: column; - gap: 0.5rem; -`; - -const DatasetHeadline = styled.div` - display: flex; - justify-content: space-between; - gap: ${glsp()}; -`; - -const DatasetData = styled.div` - padding: ${glsp(0.25, 0)}; - display: flex; - align-items: center; -`; - -interface DatasetListItemProps { - dataset: TimelineDataset; - width: number; - xScaled: ScaleTime; -} - -export function DatasetListItem(props: DatasetListItemProps) { - const { dataset, width, xScaled } = props; - - const [isVisible, setVisible] = useState(true); - - const controls = useDragControls(); - - return ( - - - - controls.start(e)} /> - - - - {dataset.title} - - - setVisible((v) => !v)}> - {isVisible ? ( - - ) : ( - - )} - - - - - - - - - - - - ); -} - -interface DatasetTrackProps { - width: number; - xScaled: ScaleTime; - dataset: TimelineDataset; - isVisible: boolean; -} - -const datasetTrackBlockHeight = 16; -function DatasetTrack(props: DatasetTrackProps) { - const { width, xScaled, dataset, isVisible } = props; - - // Limit the items to render to increase performance. - const domainToRender = useMemo(() => { - const domain = xScaled.domain(); - const start = subDays(domain[0], 1); - const end = addDays(domain[1], 1); - - return dataset.domain.filter((d) => { - const [blockStart, blockEnd] = getBlockBoundaries(d, dataset.timeDensity); - - return areIntervalsOverlapping( - { - start: blockStart, - end: blockEnd - }, - { start, end } - ); - }); - }, [xScaled, dataset]); - - return ( - - {domainToRender.map((date) => ( - - ))} - - ); -} - -interface DatasetTrackBlockProps { - xScaled: ScaleTime; - date: Date; - dataset: TimelineDataset; - isVisible: boolean; -} - -function DatasetTrackBlock(props: DatasetTrackBlockProps) { - const { xScaled, date, dataset, isVisible } = props; - - const [start, end] = getBlockBoundaries(date, dataset.timeDensity); - const s = xScaled(start); - const e = xScaled(end); - - const fill = useFillColors(isVisible); - - return ( - - - - ); -} - -const useFillColors = (isVisible: boolean): string | undefined => { - const theme = useTheme(); - - if (!isVisible) { - return theme.color?.['base-200']; - } - - return theme.color?.['base-400']; -}; diff --git a/app/scripts/components/sandbox/timeline/datasets.ts b/app/scripts/components/sandbox/timeline/datasets.ts deleted file mode 100644 index c6fdc84ca..000000000 --- a/app/scripts/components/sandbox/timeline/datasets.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { eachDayOfInterval } from 'date-fns'; - -export const datasets = [ - { - title: 'Monthly dataset', - timeDensity: 'month', - domain: [ - new Date('2020-01-01'), - new Date('2020-02-01'), - new Date('2020-03-01'), - new Date('2020-05-01'), - new Date('2020-06-01') - ] - }, - { - title: 'Daily dataset', - timeDensity: 'day', - domain: [ - new Date('2020-01-01'), - new Date('2020-01-02'), - new Date('2020-01-03'), - new Date('2020-01-04'), - new Date('2020-01-05'), - new Date('2020-01-07'), - new Date('2020-01-08'), - new Date('2020-01-09'), - new Date('2020-01-10'), - new Date('2020-01-11'), - new Date('2020-01-12'), - new Date('2020-01-13'), - new Date('2020-01-14'), - new Date('2020-01-15'), - new Date('2020-01-16'), - new Date('2020-01-19'), - new Date('2020-01-20'), - new Date('2020-01-21'), - new Date('2020-01-22'), - new Date('2020-01-23'), - new Date('2020-01-24'), - new Date('2020-01-25'), - new Date('2020-01-26'), - new Date('2020-01-27'), - new Date('2020-01-28'), - new Date('2020-01-29'), - new Date('2020-01-30'), - new Date('2020-01-31'), - new Date('2020-02-01'), - new Date('2020-02-02'), - new Date('2020-02-03'), - new Date('2020-02-04'), - new Date('2020-02-05'), - new Date('2020-02-06'), - new Date('2020-02-07'), - new Date('2020-02-08'), - new Date('2020-02-12'), - new Date('2020-02-13'), - new Date('2020-02-14'), - new Date('2020-02-15'), - new Date('2020-02-16'), - new Date('2020-02-17'), - new Date('2020-02-18'), - new Date('2020-02-19'), - new Date('2020-02-20'), - new Date('2020-02-22'), - new Date('2020-02-23'), - new Date('2020-02-24'), - new Date('2020-02-25'), - new Date('2020-02-26'), - new Date('2020-02-27'), - new Date('2020-02-28'), - new Date('2020-02-29'), - new Date('2020-03-01'), - new Date('2020-03-02'), - new Date('2020-03-03'), - new Date('2020-03-04'), - new Date('2020-03-05'), - new Date('2020-03-06'), - new Date('2020-03-07'), - new Date('2020-03-08') - ] - }, - { - title: 'Daily 2', - timeDensity: 'day', - domain: [ - new Date('2020-01-01'), - new Date('2020-02-01'), - new Date('2020-03-01'), - new Date('2020-05-01'), - new Date('2020-06-01') - ] - }, - { - title: 'Daily 3', - timeDensity: 'day', - domain: eachDayOfInterval({ - start: new Date('2020-01-01'), - end: new Date('2021-01-01') - }) - } -]; - -export const extraDataset = { - title: 'Daily infinity!', - timeDensity: 'day', - domain: eachDayOfInterval({ - start: new Date('2000-01-01'), - end: new Date('2021-12-12') - }) -}; diff --git a/app/scripts/components/sandbox/timeline/date-axis.tsx b/app/scripts/components/sandbox/timeline/date-axis.tsx deleted file mode 100644 index cfb626415..000000000 --- a/app/scripts/components/sandbox/timeline/date-axis.tsx +++ /dev/null @@ -1,168 +0,0 @@ -import React from 'react'; -import styled from 'styled-components'; -import { ScaleTime } from 'd3'; -import { - format, - isSameMonth, - isSameYear, - startOfMonth, - startOfYear -} from 'date-fns'; -import { themeVal } from '@devseed-ui/theme-provider'; - -import { RIGHT_AXIS_SPACE, TimeDensity } from './constants'; - -const GridLine = styled.line` - stroke: ${themeVal('color.base-200')}; -`; - -const DateAxisSVG = styled.svg` - text { - font-size: 0.75rem; - fill: ${themeVal('color.base')}; - - &.parent { - font-size: 0.625rem; - } - } -`; - -const GridSvg = styled.svg` - position: absolute; - right: ${RIGHT_AXIS_SPACE}px; - height: 100%; - pointer-events: none; -`; - -function getTimeDensity(domain) { - if (domain.every((d) => d.getTime() === startOfYear(d).getTime())) { - return TimeDensity.YEAR; - } else if (domain.every((d) => d.getTime() === startOfMonth(d).getTime())) { - return TimeDensity.MONTH; - } else { - return TimeDensity.DAY; - } -} - -function timeDensityFormat(date: Date, timeDensity: TimeDensity) { - switch (timeDensity) { - case TimeDensity.YEAR: - return format(date, 'yyyy'); - case TimeDensity.MONTH: - return format(date, 'MMM dd'); - case TimeDensity.DAY: - return format(date, 'iii dd'); - } -} - -/** - * Returns date ticks that are spaced out enough to be readable taking into - * account a minimum width for each tick of 60px. - * If the width of each tick is less than 60px, every other tick is returned. - * - * @param scale The scale to get the ticks from. - * @returns Date[] - */ -function getTicks(scale: ScaleTime) { - const [min, max] = scale.range(); - const width = max - min; - - return scale - .ticks() - .filter((v, i, a) => (width / a.length < 60 ? !(i % 2) : true)); -} - -interface DateAxisProps { - xScaled: ScaleTime; - width: number; -} - -export function DateAxis(props: DateAxisProps) { - const { xScaled, width } = props; - - const ticks = getTicks(xScaled); - const axisDensity = getTimeDensity(ticks); - - return ( - - {ticks.map((d) => { - const xPos = xScaled(d); - return ( - - - - {timeDensityFormat(d, axisDensity)} - - - - ); - })} - - ); -} - -interface ParentIndicatorProps { - timeDensity: TimeDensity; - date: Date; - domain: Date[]; - xScaled: ScaleTime; -} - -function ParentIndicator(props: ParentIndicatorProps) { - const { timeDensity, date, domain, xScaled } = props; - - if (timeDensity === TimeDensity.YEAR) return <>{false}; - - let dateFormat: string; - if (timeDensity === TimeDensity.MONTH) { - // Only render the first date for a given month. - // Get the first date that has the same month as the one being rendered and - // check if they're the same. - const firstDate = domain.find((d) => isSameYear(d, date)); - if (firstDate !== date) return <>{false}; - - dateFormat = 'yyyy'; - } else { - const firstDate = domain.find((d) => isSameMonth(d, date)); - if (firstDate !== date) return <>{false}; - - dateFormat = 'MMM yyyy'; - } - - return ( - - {format(date, dateFormat)} - - ); -} - -interface DateGridProps { - xScaled: ScaleTime; - width: number; -} - -export function DateGrid(props: DateGridProps) { - const { width, xScaled } = props; - - return ( - - {getTicks(xScaled).map((tick) => { - const xPos = xScaled(tick); - return ( - - ); - })} - - ); -} diff --git a/app/scripts/components/sandbox/timeline/index.tsx b/app/scripts/components/sandbox/timeline/index.tsx deleted file mode 100644 index ece118cd8..000000000 --- a/app/scripts/components/sandbox/timeline/index.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { themeVal } from '@devseed-ui/theme-provider'; -import React from 'react'; -import { Panel, PanelGroup, PanelResizeHandle } from 'react-resizable-panels'; -import styled from 'styled-components'; - -import Timeline from './timeline'; - -const Container = styled.div` - display: flex; - flex-flow: column; - flex-grow: 1; - - .panel-wrapper { - flex-grow: 1; - } - - .panel { - display: flex; - flex-direction: column; - } - - .panel-timeline { - box-shadow: 0 -1px 0 0 ${themeVal('color.base-100')}; - } - - .resize-handle { - flex: 0; - position: relative; - outline: none; - display: flex; - align-items: center; - justify-content: center; - width: 5rem; - margin: 0 auto -1.25rem auto; - padding: 0.25rem 0; - z-index: 5000; - - ::before { - content: ''; - display: block; - width: 2rem; - background: ${themeVal('color.base-200')}; - height: 0.25rem; - border-radius: ${themeVal('shape.ellipsoid')}; - } - } -`; - -function SandboxTimeline() { - return ( - - - -
    Top
    -
    - - - - -
    -
    - ); -} - -export default SandboxTimeline; diff --git a/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx b/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx deleted file mode 100644 index 3681c0b97..000000000 --- a/app/scripts/components/sandbox/timeline/test-timeline-side-scrub.tsx +++ /dev/null @@ -1,555 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import styled, { css, useTheme } from 'styled-components'; -import useDimensions from 'react-cool-dimensions'; -import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { datasets as srcDatasets } from './datasets'; -import { - ScaleTime, - ZoomTransform, - axisBottom, - axisTop, - create, - drag, - easeCubicIn, - extent, - scaleLinear, - scalePow, - scaleTime, - select, - zoom -} from 'd3'; -import { - clamp, - endOfDay, - endOfMonth, - endOfYear, - format, - isWithinInterval, - startOfDay, - startOfMonth, - startOfYear -} from 'date-fns'; -import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; -import { Button } from '@devseed-ui/button'; - -const TimelineWrapper = styled.div` - position: relative; - flex-grow: 1; - display: flex; - flex-flow: column; - height: 100%; - - svg { - display: block; - } -`; - -const InteractionRect = styled.div` - position: absolute; - inset: 0; - left: 20rem; - background-color: rgba(255, 0, 0, 0.08); - z-index: 1000; -`; - -const TimelineHeader = styled.header` - display: flex; - flex-shrink: 0; - box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; -`; - -const TimelineDetails = styled.div` - width: 20rem; - flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - padding: ${glsp(0.5)}; -`; - -const Headline = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -const TimelineControls = styled.div` - width: 100%; - display: flex; - flex-flow: column; - - .date-axis { - margin-top: auto; - } -`; - -const TimelineContent = styled.div` - height: 100%; - min-height: 0; - display: flex; - width: 100%; - position: relative; -`; - -const TimelineContentInner = styled.div` - height: 100%; - min-height: 0; - display: flex; - overflow-y: scroll; - overflow-x: hidden; - width: 100%; - position: relative; -`; - -const DatasetList = styled.ul` - ${listReset()} - width: 100%; - ${({ gridBg }) => - gridBg && - css` - background-image: url('${gridBg}'); - background-repeat: repeat-y; - background-position-x: 20rem; - `} - - li { - display: flex; - box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; - } -`; - -const DatasetInfo = styled.div` - width: 20rem; - flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - padding: ${glsp(0.5)}; -`; - -const DatasetData = styled.div` - padding: ${glsp(0.25, 0)}; -`; - -const DatasetSvg = styled.svg``; - -const GridSvg = styled.svg` - position: absolute; - right: 0; - height: 100%; - pointer-events: none; -`; - -function Timeline() { - const [datasets, setDatasets] = useState(srcDatasets); - - const { observe, width, height } = useDimensions(); - - const interactionRef = useRef(null); - const axisSvgRef = useRef(null); - const datasetsContainerRef = useRef(null); - - const theme = useTheme(); - - const [selectedDay, setSelectedDay] = useState(); - - const [zoomTransform, setZoomTransform] = useState({ - x: 0, - y: 0, - k: 1 - }); - - const dataDomain = useMemo( - () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], - [datasets] - ); - - const domainDays = useMemo( - () => (dataDomain[1].getTime() - dataDomain[0].getTime()) / 86400000, - [dataDomain] - ); - - const xMain = useMemo(() => { - return scaleTime().domain(dataDomain).range([0, width]); - }, [dataDomain, width]); - - const xScaled = useMemo(() => { - return rescaleX(xMain, zoomTransform.x, zoomTransform.k); - }, [xMain, zoomTransform.x, zoomTransform.k]); - - const xAxis = useMemo(() => { - return xScaled ? axisBottom(xScaled) : undefined; - }, [xScaled]); - - const zoomBehavior = useMemo(() => { - return ( - zoom() - // Make the maximum zoom level as such as each day has maximum of 100px. - .scaleExtent([1, 100 / (width / domainDays)]) - .translateExtent([ - [0, 0], - [width, height] - ]) - .extent([ - [0, 0], - [width, height] - ]) - .filter((event) => { - if (event.type === 'wheel' && !event.altKey) { - // The zoom behavior traps the scroll event. Propagate to the data - // container to scroll it. - if (datasetsContainerRef.current) { - datasetsContainerRef.current.scrollBy(0, event.deltaY); - } - return false; - } - return true; - }) - .on('zoom', function (event) { - const { sourceEvent } = event; - - if (sourceEvent?.type === 'wheel') { - // Alt key plus wheel makes the browser go back in history. Prevent. - if (sourceEvent.altKey) { - sourceEvent.preventDefault(); - } - } - const { x, y, k } = event.transform; - setZoomTransform((t) => - isEqualTransform(t, { x, y, k }) ? t : { x, y, k } - ); - }) - ); - }, [width, height, domainDays]); - - useEffect(() => { - if (!interactionRef.current) return; - - select(interactionRef.current) - .call(zoomBehavior) - .on('dblclick.zoom', null) - .on('click', (event) => { - const d = xScaled?.invert(event.layerX); - d && setSelectedDay(startOfDay(d)); - }) - .on('wheel', function (event) { - // Wheel is triggered when an horizontal wheel is used or when shift - // wheel is used. The zoom event is only for vertical wheel so we have - // to mimic the pan behavior. - if (event.altKey) { - event.preventDefault(); - } - - const element = select(this); - // Get the current zoom transform. - const currentT = element.property('__zoom'); - // Apply the delta to the x axis and then constrains according to the - // zoom definition. - const updatedT = new ZoomTransform( - currentT.k, - currentT.x - event.deltaX, - currentT.y - ); - const constrainFn = zoomBehavior.constrain(); - // Constrain the transform according to the timeline bounds. - const newTransform = constrainFn( - updatedT, - [ - [0, 0], - [width, height] - ], - zoomBehavior.translateExtent() - ); - - // Apply transform which will cause the zoom event to be emitted without - // a sourceEvent. - zoomBehavior.transform(element, newTransform); - }); - }, [width, height, xScaled, zoomBehavior]); - - useEffect(() => { - if (!interactionRef.current) return; - - // Get the current zoom transform. - const element = select(interactionRef.current); - const currentT = element.property('__zoom'); - - // Programmatically update if different, meaning that it came from setting - // the state. - if (!isEqualTransform(currentT, zoomTransform)) { - const { x, y, k } = zoomTransform; - zoomBehavior.transform(element, new ZoomTransform(k, x, y)); - } - }, [zoomBehavior, zoomTransform]); - - useEffect(() => { - if (!xAxis) return; - select(axisSvgRef.current).select('.x.axis').call(xAxis); - }, [xAxis]); - - return ( - - - {selectedDay ? ( - { - // zoomBehavior.translateBy(select(interactionRef.current), val * -1, 0); - }} - /> - ) : ( - false - )} - - - -

    Datasets

    {' '} - -
    -

    X of Y

    -
    - -
    {selectedDay ? format(selectedDay, 'yyyy-MM-dd') : null}
    - - - -
    -
    - - {xScaled ? ( - - {xScaled.ticks().map((tick) => ( - - ))} - - ) : null} - - - {datasets.map((dataset) => ( -
  • - {dataset.title} - - - {dataset.domain.map((date) => { - const [start, end] = getBlockBoundaries( - date, - dataset.timeDensity - ); - const s = xScaled(start); - const e = xScaled(end); - - const isSelected = selectedDay - ? isWithinInterval(selectedDay, { start, end }) - : false; - - const strokeWidth = 2; - return ( - - - - - ); - })} - - -
  • - ))} -
    -
    -
    -
    - ); -} - -export default Timeline; - -const TimelineHeadSVG = styled.svg` - position: absolute; - right: 0; - top: 2rem; - height: 100%; - pointer-events: none; - z-index: 2000; -`; - -function TimelineHead(props: any) { - const { - domain, - xScaled, - selectedDay, - width, - setSelectedDay, - onDistance - } = props; - - const rectRef = useRef(null); - const fnRef = useRef(onDistance); - fnRef.current = onDistance; - - useEffect(() => { - if (!rectRef.current) return; - - const anim = createAnimation(12); - - const dragger = drag() - .on('start', function dragstarted() { - document.body.style.cursor = 'grabbing'; - select(this).attr('cursor', 'grabbing'); - }) - .on('drag', function dragged(event) { - if (event.x < 0 || event.x > width) { - anim.run(() => { - // How much is out of bounds? - const excess = event.x > width ? event.x - width : event.x; - const val = dragDistanceScaler(excess); - console.log('x', event.x, 'excess', excess, 'val', val); - fnRef.current(val); - }); - return; - } - anim.stop(); - - const dx = event.x - event.subject.x; - const currPos = xScaled(selectedDay); - const newPos = currPos + dx; - - const dateFromPos = startOfDay(xScaled.invert(newPos)); - - const [start, end] = domain; - const interval = { start, end }; - - const newDate = clamp(dateFromPos, interval); - - if (selectedDay.getTime() !== newDate.getTime()) { - setSelectedDay(newDate); - } - }) - .on('end', function dragended() { - document.body.style.cursor = ''; - select(this).attr('cursor', 'grab'); - anim.stop(); - }); - - select(rectRef.current).call(dragger); - }, [width, domain, selectedDay, setSelectedDay, xScaled]); - - return ( - - - - - ); -} - -/** - * Rescales the given scale according to the given factors. - * @param scale Scale to rescale - * @param x X factor - * @param k Scale factor - * @returns new scale - */ -function rescaleX(scale, x, k) { - const range = scale.range(); - return scale.copy().domain( - range.map((v) => { - // New value after scaling - const value = (v - x) / k; - // Clamp value to the range - const valueClamped = Math.max(range[0], Math.min(value, range[1])); - return scale.invert(valueClamped); - }) - ); -} - -function isEqualTransform(t1, t2) { - return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; -} - -function getBlockBoundaries(date, timeDensity) { - switch (timeDensity) { - case 'month': - return [startOfMonth(date), endOfMonth(date)]; - case 'year': - return [startOfYear(date), endOfYear(date)]; - } - - return [startOfDay(date), endOfDay(date)]; -} - -function createAnimation(fps) { - let running = false; - let lastRun; - const frameTime = 1000 / fps; - let rafId; - let callback; - - return { - run(fn) { - callback = fn; - running = true; - function animate(timestamp) { - if (!running) return; - - const elapsed = lastRun ? timestamp - lastRun : null; - if (elapsed === null || elapsed >= frameTime) { - lastRun = timestamp; - callback?.(); - } - rafId = requestAnimationFrame(animate); - } - rafId = requestAnimationFrame(animate); - }, - stop() { - running = false; - cancelAnimationFrame(rafId); - } - }; -} - -function dragDistanceScaler(value) { - if (value === 0) return 0; - - const scale = scalePow() - .domain([-200, 0, 0, 200]) - .range([-100, -10, 10, 100]) - .clamp(true) - .exponent(2.5); - - return scale(value); -} diff --git a/app/scripts/components/sandbox/timeline/timeline-head.tsx b/app/scripts/components/sandbox/timeline/timeline-head.tsx deleted file mode 100644 index e0d938dd0..000000000 --- a/app/scripts/components/sandbox/timeline/timeline-head.tsx +++ /dev/null @@ -1,200 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import styled, { useTheme } from 'styled-components'; -import { drag, ScaleTime, select } from 'd3'; -import { clamp, startOfDay } from 'date-fns'; -import { themeVal } from '@devseed-ui/theme-provider'; - -import { RIGHT_AXIS_SPACE } from './constants'; - -const TimelineHeadSVG = styled.svg` - position: absolute; - right: ${RIGHT_AXIS_SPACE}px; - top: -1rem; - height: calc(100% + 1rem); - pointer-events: none; - z-index: 200; -`; - -const dropShadowFilter = - 'drop-shadow(0px 2px 2px rgba(44, 62, 80, 0.08)) drop-shadow(0px 0px 4px rgba(44, 62, 80, 0.08))'; - -interface TimelineHeadProps { - domain: [Date, Date]; - xScaled: ScaleTime; - selectedDay: Date; - width: number; - setSelectedDay: (date: Date) => void; - children: React.ReactNode; -} - -export function TimelineHead(props: TimelineHeadProps) { - const { domain, xScaled, selectedDay, width, setSelectedDay, children } = - props; - - const theme = useTheme(); - const rectRef = useRef(null); - - useEffect(() => { - if (!rectRef.current) return; - - const dragger = drag() - .on('start', function dragstarted() { - document.body.style.cursor = 'grabbing'; - select(this).attr('cursor', 'grabbing'); - }) - .on('drag', function dragged(event) { - if (event.x < 0 || event.x > width) { - return; - } - - const dx = event.x - event.subject.x; - const currPos = xScaled(selectedDay); - const newPos = currPos + dx; - - const dateFromPos = startOfDay(xScaled.invert(newPos)); - - const [start, end] = domain; - const interval = { start, end }; - - const newDate = clamp(dateFromPos, interval); - - if (selectedDay.getTime() !== newDate.getTime()) { - setSelectedDay(newDate); - } - }) - .on('end', function dragended() { - document.body.style.cursor = ''; - select(this).attr('cursor', 'grab'); - }); - - select(rectRef.current).call(dragger); - }, [width, domain, selectedDay, setSelectedDay, xScaled]); - - return ( - - - - {children} - - - ); -} - -export function TimelineHeadP(props: Omit) { - const theme = useTheme(); - - return ( - - - - P - - - ); -} - -export function TimelineHeadL(props: Omit) { - const theme = useTheme(); - - return ( - - - - L - - - ); -} - -export function TimelineHeadR(props: Omit) { - const theme = useTheme(); - return ( - - - - R - - - ); -} - -const TimelineRangeTrackSelf = styled.div` - position: absolute; - top: -1rem; - right: ${RIGHT_AXIS_SPACE}px; - overflow: hidden; - - .shaded { - position: relative; - background: ${themeVal('color.base-100a')}; - height: 1rem; - } -`; - -interface TimelineRangeTrackProps { - range: { start: Date; end: Date }; - xScaled: ScaleTime; - width: number; -} - -export function TimelineRangeTrack(props: TimelineRangeTrackProps) { - const { range, xScaled, width } = props; - - const start = xScaled(range.start); - const end = xScaled(range.end); - - return ( - -
    - - ); -} diff --git a/app/scripts/components/sandbox/timeline/timeline.tsx b/app/scripts/components/sandbox/timeline/timeline.tsx deleted file mode 100644 index 70ca53b5c..000000000 --- a/app/scripts/components/sandbox/timeline/timeline.tsx +++ /dev/null @@ -1,464 +0,0 @@ -import React, { useEffect, useMemo, useRef, useState } from 'react'; -import styled from 'styled-components'; -import useDimensions from 'react-cool-dimensions'; -import { Reorder } from 'framer-motion'; -import { ZoomTransform, extent, scaleTime, select, zoom } from 'd3'; -import { - add, - differenceInCalendarDays, - format, - isAfter, - isBefore, - startOfDay, - sub -} from 'date-fns'; -import { glsp, listReset, themeVal } from '@devseed-ui/theme-provider'; -import { - CollecticonChevronDownSmall, - CollecticonPlusSmall -} from '@devseed-ui/collecticons'; -import { Button } from '@devseed-ui/button'; -import { Heading } from '@devseed-ui/typography'; -import { DatePicker } from '@devseed-ui/date-picker'; - -import { extraDataset, datasets as srcDatasets } from './datasets'; -import { DatasetListItem } from './dataset-list-item'; -import { - TimelineHeadL, - TimelineHeadP, - TimelineHeadR, - TimelineRangeTrack -} from './timeline-head'; -import { DateAxis, DateGrid } from './date-axis'; -import { RIGHT_AXIS_SPACE } from './constants'; - -const TimelineWrapper = styled.div` - position: relative; - flex-grow: 1; - display: flex; - flex-flow: column; - height: 100%; - - svg { - display: block; - } -`; - -const InteractionRect = styled.div` - position: absolute; - left: 20rem; - top: 3.5rem; - bottom: 0; - right: ${RIGHT_AXIS_SPACE}px; - /* background-color: rgba(255, 0, 0, 0.08); */ - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - z-index: 100; -`; - -const TimelineHeader = styled.header` - display: flex; - flex-shrink: 0; - box-shadow: 0 1px 0 0 ${themeVal('color.base-200')}; -`; - -const TimelineDetails = styled.div` - width: 20rem; - flex-shrink: 0; - box-shadow: 1px 0 0 0 ${themeVal('color.base-200')}; - padding: ${glsp(0.5, 0.5, 0.5, 2)}; -`; - -const Headline = styled.div` - display: flex; - justify-content: space-between; - align-items: center; -`; - -const TimelineControls = styled.div` - width: 100%; - display: flex; - flex-flow: column; - min-width: 0; - - .date-axis { - margin-top: auto; - } -`; - -const ControlsToolbar = styled.div` - display: flex; - justify-content: space-between; - padding: ${glsp(1.5, 0.5, 0.5, 0.5)}; -`; - -const DatePickerButton = styled(Button)` - gap: ${glsp(0.5)}; - - .head-reference { - font-weight: ${themeVal('type.base.regular')}; - color: ${themeVal('color.base-400')}; - font-size: 0.875rem; - } -`; - -const TimelineContent = styled.div` - height: 100%; - min-height: 0; - display: flex; - width: 100%; - position: relative; -`; - -const TimelineContentInner = styled.div` - height: 100%; - min-height: 0; - display: flex; - overflow-y: scroll; - overflow-x: hidden; - width: 100%; - position: relative; -`; - -const DatasetListSelf = styled.ul` - ${listReset()} - width: 100%; -`; - -function Timeline() { - const [datasets, setDatasets] = useState(srcDatasets); - - const dataDomain = useMemo( - () => extent(datasets.flatMap((d) => d.domain)) as [Date, Date], - [datasets] - ); - - const { observe, width: w, height } = useDimensions(); - const width = Math.max(1, w - RIGHT_AXIS_SPACE); - - const interactionRef = useRef(null); - const datasetsContainerRef = useRef(null); - - const [selectedDay, setSelectedDay] = useState(null); - - const translateExtent = useMemo<[[number, number], [number, number]]>( - () => [ - [0, 0], - [width, height] - ], - [width, height] - ); - - const [selectedInterval, setSelectedInterval] = useState<{ - start: Date; - end: Date; - }>({ - start: new Date('2020-03-03'), - end: new Date('2020-08-03') - }); - - const [zoomTransform, setZoomTransform] = useState({ - x: 0, - y: 0, - k: 1 - }); - - // Calculate min and max scale factors, such has each day has a minimum of 2px - // and a maximum of 100px. - const { k0, k1 } = useMemo(() => { - if (width <= 0) return { k0: 0, k1: 1 }; - // Calculate how many days are in the domain. - const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); - return { - k0: Math.max(1, 2 / (width / domainDays)), - k1: 100 / (width / domainDays) - }; - }, [width, dataDomain]); - - const xMain = useMemo(() => { - return scaleTime().domain(dataDomain).range([0, width]); - }, [dataDomain, width]); - - const xScaled = useMemo(() => { - return rescaleX(xMain, zoomTransform.x, zoomTransform.k); - }, [xMain, zoomTransform.x, zoomTransform.k]); - - const zoomBehavior = useMemo(() => { - return zoom() - .scaleExtent([k0, k1]) - .translateExtent(translateExtent) - .extent(translateExtent) - .filter((event) => { - if (event.type === 'wheel' && !event.altKey) { - // The zoom behavior traps the scroll event. Propagate to the data - // container to scroll it. - if (datasetsContainerRef.current) { - datasetsContainerRef.current.scrollBy(0, event.deltaY); - } - return false; - } - return true; - }) - .on('zoom', function (event) { - const { sourceEvent } = event; - - if (sourceEvent?.type === 'wheel') { - // Alt key plus wheel makes the browser go back in history. Prevent. - if (sourceEvent.altKey) { - sourceEvent.preventDefault(); - } - } - const { x, y, k } = event.transform; - setZoomTransform((t) => - isEqualTransform(t, { x, y, k }) ? t : { x, y, k } - ); - }); - }, [translateExtent, k0, k1]); - - useEffect(() => { - if (!interactionRef.current) return; - - select(interactionRef.current) - .call(zoomBehavior) - .on('dblclick.zoom', null) - .on('click', (event) => { - const d = xScaled?.invert(event.layerX); - d && setSelectedDay(startOfDay(d)); - }) - .on('wheel', function (event) { - // Wheel is triggered when an horizontal wheel is used or when shift - // wheel is used. The zoom event is only for vertical wheel so we have - // to mimic the pan behavior. - if (event.altKey) { - event.preventDefault(); - } - - const element = select(this); - // Get the current zoom transform. - const currentT = element.property('__zoom'); - // Apply the delta to the x axis and then constrains according to the - // zoom definition. - const updatedT = new ZoomTransform( - currentT.k, - currentT.x - event.deltaX, - currentT.y - ); - const constrainFn = zoomBehavior.constrain(); - // Constrain the transform according to the timeline bounds. - const newTransform = constrainFn( - updatedT, - translateExtent, - zoomBehavior.translateExtent() - ); - - // Apply transform which will cause the zoom event to be emitted without - // a sourceEvent. - zoomBehavior.transform(element, newTransform); - }); - }, [translateExtent, xScaled, zoomBehavior]); - - useEffect(() => { - if (!interactionRef.current) return; - - // Get the current zoom transform. - const element = select(interactionRef.current); - const currentT = element.property('__zoom'); - - // Programmatically update if different, meaning that it came from setting - // the state. - if (!isEqualTransform(currentT, zoomTransform)) { - const { x, y, k } = zoomTransform; - zoomBehavior.transform(element, new ZoomTransform(k, x, y)); - } - }, [zoomBehavior, zoomTransform]); - - return ( - - - - - - - Datasets - - - -

    X of Y

    -
    - - - { - setSelectedDay(d.start); - }} - renderTriggerElement={(props, label) => ( - - P - {label} - - - )} - /> - { - setSelectedInterval(d); - }} - isClearable={false} - isRange - alignment='right' - renderTriggerElement={(props) => ( - - L - {format(selectedInterval.start, 'MMM do, yyyy')} - R - {format(selectedInterval.end, 'MMM do, yyyy')} - - - )} - /> - - - - -
    - - {selectedDay ? ( - - ) : ( - false - )} - { - setSelectedInterval((interval) => { - const prevDay = sub(interval.end, { days: 1 }); - return { - ...interval, - start: isAfter(d, prevDay) ? prevDay : d - }; - }); - }} - selectedDay={selectedInterval.start} - width={width} - /> - { - setSelectedInterval((interval) => { - const nextDay = add(interval.start, { days: 1 }); - return { - ...interval, - end: isBefore(d, nextDay) ? nextDay : d - }; - }); - }} - selectedDay={selectedInterval.end} - width={width} - /> - - - - - - - - - -
    - ); -} - -export default Timeline; - -function DatasetList(props: any) { - const { datasets, ...rest } = props; - - const [orderedDatasets, setOrderDatasets] = useState(datasets); - - useEffect(() => { - setOrderDatasets(datasets); - }, [datasets]); - - return ( - - {orderedDatasets.map((dataset) => ( - - ))} - - ); -} - -/** - * Rescales the given scale according to the given factors. - * - * @param scale Scale to rescale - * @param x X factor - * @param k Scale factor - * @returns New scale - */ -function rescaleX(scale, x, k) { - const range = scale.range(); - return scale.copy().domain( - range.map((v) => { - // New value after scaling - const value = (v - x) / k; - // Clamp value to the range - const valueClamped = Math.max(range[0], Math.min(value, range[1])); - return scale.invert(valueClamped); - }) - ); -} - -interface Transform { - x: number; - y: number; - k: number; -} - -/** - * Compares two transforms. - * - * @param t1 First transform - * @param t2 Second transform - * @returns Whether the transforms are equal. - */ -function isEqualTransform(t1: Transform, t2: Transform) { - return t1.x === t2.x && t1.y === t2.y && t1.k === t2.k; -} From 57975e40ec612ee48da8fe14340ed9ed0c5e5dd3 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Wed, 30 Aug 2023 21:48:01 +0100 Subject: [PATCH 025/208] Lint files --- app/scripts/components/exploration/chart-popover.tsx | 2 +- app/scripts/components/exploration/timeline-controls.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/scripts/components/exploration/chart-popover.tsx b/app/scripts/components/exploration/chart-popover.tsx index 0cde16fdb..d86a0fd85 100644 --- a/app/scripts/components/exploration/chart-popover.tsx +++ b/app/scripts/components/exploration/chart-popover.tsx @@ -125,7 +125,7 @@ export const DatasetPopover = styled(DatasetPopoverRef)` position: absolute; top: 0; left: 0; - background: ${themeVal('color.surface')}}; + background: ${themeVal('color.surface')}; padding: ${glsp()}; border-radius: ${themeVal('shape.rounded')}; box-shadow: ${themeVal('boxShadow.elevationD')}; diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/timeline-controls.tsx index 70c626650..14bc48f17 100644 --- a/app/scripts/components/exploration/timeline-controls.tsx +++ b/app/scripts/components/exploration/timeline-controls.tsx @@ -44,7 +44,7 @@ const TimelineControlsSelf = styled.div` const ControlsToolbar = styled.div` padding: ${glsp(1.5, 1, 0.5, 1)}; - ${ToolbarGroup}:last-child { + ${ToolbarGroup /* sc-selector */}:last-child { margin-left: auto; } `; From 9ab1a86a2e237f7f9609eef3410936e640702a2a Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 4 Sep 2023 11:00:11 +0100 Subject: [PATCH 026/208] Fix button disabled styles --- app/scripts/utils/utils.ts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/app/scripts/utils/utils.ts b/app/scripts/utils/utils.ts index 2d82928df..ebd4e85c5 100644 --- a/app/scripts/utils/utils.ts +++ b/app/scripts/utils/utils.ts @@ -1,4 +1,4 @@ -import styled, { css } from 'styled-components'; +import styled, { css, FlattenSimpleInterpolation } from 'styled-components'; import { visuallyDisabled } from '@devseed-ui/theme-provider'; /** @@ -53,9 +53,13 @@ export function isEqualObj(a, b) { * disables the component (through css) and prevents a click. * * @param Comp React Component to enhance + * @param additionalStyles Additional styles to apply when visually disabled. * @returns Enhanced styled component. */ -export function composeVisuallyDisabled(Comp) { +export function composeVisuallyDisabled( + Comp, + additionalStyles?: FlattenSimpleInterpolation +) { return styled(Comp).attrs((props) => { const onClickOriginal = props.onClick; return { @@ -72,6 +76,13 @@ export function composeVisuallyDisabled(Comp) { vd && css` ${visuallyDisabled()} + + &&&:hover { + ${visuallyDisabled()} + background: inherit; + } + + ${additionalStyles} `} `; } From 9307c7d4f64056962355105093e42014053517bf Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Thu, 31 Aug 2023 14:35:34 +0100 Subject: [PATCH 027/208] Add support for different legend types --- .../exploration/dataset-list-item.tsx | 31 ++++++++++++------ mock/datasets/sandbox.data.mdx | 32 +++++++++++++++++++ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/dataset-list-item.tsx index ef92ac399..023c3dd0c 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/dataset-list-item.tsx @@ -45,7 +45,10 @@ import { usePopover } from './chart-popover'; -import { LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; +import { + LayerCategoricalGraphic, + LayerGradientGraphic +} from '$components/common/mapbox/layer-legend'; function getBlockBoundaries(date: Date, timeDensity: TimeDensity) { switch (timeDensity) { @@ -187,6 +190,8 @@ export function DatasetListItem(props: DatasetListItemProps) { const isAnalysisAndSucceeded = isAnalysis && dataset.analysis.status === TimelineDatasetStatus.SUCCEEDED; + const datasetLegend = dataset.data.legend; + return ( - + {datasetLegend?.type === 'categorical' && ( + + )} + {datasetLegend?.type === 'gradient' && ( + + )} @@ -270,7 +283,7 @@ export function DatasetListItem(props: DatasetListItemProps) { xScaled={xScaled!} width={width} isVisible={!!isVisible} - data={dataset.data.analysis} + data={dataset.analysis} activeMetrics={activeMetrics} highlightDate={dataPoint?.date} /> diff --git a/mock/datasets/sandbox.data.mdx b/mock/datasets/sandbox.data.mdx index fd7091119..5138448e8 100644 --- a/mock/datasets/sandbox.data.mdx +++ b/mock/datasets/sandbox.data.mdx @@ -129,6 +129,38 @@ layers: stacCol: dev-fail name: Failing layer type: raster + - id: geoglam + stacCol: geoglam + name: GEOGLAM Crop Conditions + type: raster + description: Combined crop conditions across both the Crop Monitor for AMIS and Crop Monitor for Early Warning + zoomExtent: + - 0 + - 16 + sourceParams: + colormap: '{"1": [120, 120, 120], "2": [130, 65, 0], "3": [66, 207, 56], "4": [245, 239, 0], "5": [241, 89, 32], "6": [168, 0, 0], "7": [0, 143, 201]}' + bidx: 1 + unscale: false + resampling: nearest + max_size: 1024 + return_mask: true + legend: + type: categorical + stops: + - color: "#3A8DC6" + label: "Exceptional" + - color: "#62D246" + label: "Favourable" + - color: "#FFFF00" + label: "Watch" + - color: "#EC5830" + label: "Poor" + - color: "#891911" + label: "Failure" + - color: "#787878" + label: "Out of season" + - color: "#804115" + label: "No data" related: - type: dataset id: no2 From 7dc1fcf9eb63a9bad151b33cde5dc25a0f510d16 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 4 Sep 2023 13:06:38 +0100 Subject: [PATCH 028/208] Move code around according to review --- .../exploration/{ => atoms}/atoms.ts | 13 ++-- .../exploration/{ => atoms}/hooks.ts | 69 ++----------------- .../analysis-metrics-dropdown.tsx | 0 .../{ => components}/chart-popover.tsx | 4 +- .../dataset-selector-modal.tsx | 8 +-- .../datasets}/dataset-chart.tsx | 6 +- .../datasets}/dataset-list-item-status.tsx | 3 +- .../datasets}/dataset-list-item.tsx | 36 ++++++---- .../datasets}/dataset-list.tsx | 2 +- .../datasets}/dataset-options.tsx | 7 +- .../datasets}/dataset-track-message.tsx | 0 .../{ => components/timeline}/date-axis.tsx | 3 +- .../timeline}/timeline-controls.tsx | 7 +- .../timeline}/timeline-head.tsx | 3 +- .../timeline}/timeline-utils.ts | 0 .../{ => components/timeline}/timeline.tsx | 66 ++++++++++-------- .../components/exploration/constants.ts | 52 -------------- .../components/exploration/data-utils.ts | 2 +- .../components/exploration/datasets-mock.tsx | 4 +- .../exploration/hooks/scales-hooks.ts | 57 +++++++++++++++ .../{ => hooks}/use-dataset-hover.ts | 0 app/scripts/components/exploration/index.tsx | 4 +- .../components/exploration/types.d.ts.ts | 51 ++++++++++++++ 23 files changed, 204 insertions(+), 193 deletions(-) rename app/scripts/components/exploration/{ => atoms}/atoms.ts (86%) rename app/scripts/components/exploration/{ => atoms}/hooks.ts (63%) rename app/scripts/components/exploration/{ => components}/analysis-metrics-dropdown.tsx (100%) rename app/scripts/components/exploration/{ => components}/chart-popover.tsx (98%) rename app/scripts/components/exploration/{ => components}/dataset-selector-modal.tsx (95%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-chart.tsx (97%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-list-item-status.tsx (97%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-list-item.tsx (95%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-list.tsx (94%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-options.tsx (93%) rename app/scripts/components/exploration/{ => components/datasets}/dataset-track-message.tsx (100%) rename app/scripts/components/exploration/{ => components/timeline}/date-axis.tsx (96%) rename app/scripts/components/exploration/{ => components/timeline}/timeline-controls.tsx (96%) rename app/scripts/components/exploration/{ => components/timeline}/timeline-head.tsx (97%) rename app/scripts/components/exploration/{ => components/timeline}/timeline-utils.ts (100%) rename app/scripts/components/exploration/{ => components/timeline}/timeline.tsx (93%) create mode 100644 app/scripts/components/exploration/hooks/scales-hooks.ts rename app/scripts/components/exploration/{ => hooks}/use-dataset-hover.ts (100%) create mode 100644 app/scripts/components/exploration/types.d.ts.ts diff --git a/app/scripts/components/exploration/atoms.ts b/app/scripts/components/exploration/atoms/atoms.ts similarity index 86% rename from app/scripts/components/exploration/atoms.ts rename to app/scripts/components/exploration/atoms/atoms.ts index dae45944b..d20be9f22 100644 --- a/app/scripts/components/exploration/atoms.ts +++ b/app/scripts/components/exploration/atoms/atoms.ts @@ -1,12 +1,11 @@ import { atom } from 'jotai'; -import { DataMetric, dataMetrics } from './analysis-metrics-dropdown'; import { - DateRange, - HEADER_COLUMN_WIDTH, - RIGHT_AXIS_SPACE, - TimelineDataset, - ZoomTransformPlain -} from './constants'; + DataMetric, + dataMetrics +} from '../components/analysis-metrics-dropdown'; + +import { HEADER_COLUMN_WIDTH, RIGHT_AXIS_SPACE } from '../constants'; +import { DateRange, TimelineDataset, ZoomTransformPlain } from '../types.d.ts'; // Datasets to show on the timeline and their settings export const timelineDatasetsAtom = atom([]); diff --git a/app/scripts/components/exploration/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts similarity index 63% rename from app/scripts/components/exploration/hooks.ts rename to app/scripts/components/exploration/atoms/hooks.ts index ab92b47e9..202783af8 100644 --- a/app/scripts/components/exploration/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -1,21 +1,12 @@ import { useCallback, useMemo } from 'react'; -import { extent, scaleTime } from 'd3'; +import { extent } from 'd3'; import { PrimitiveAtom, useAtom, useAtomValue } from 'jotai'; import { focusAtom } from 'jotai-optics'; -import { add, differenceInCalendarDays, max } from 'date-fns'; - -import { - timelineDatasetsAtom, - timelineSizesAtom, - zoomTransformAtom -} from './atoms'; -import { rescaleX } from './timeline-utils'; -import { - DAY_SIZE_MAX, - DAY_SIZE_MIN, - TimelineDataset, - TimelineDatasetStatus -} from './constants'; +import { add, max } from 'date-fns'; + +import { DAY_SIZE_MAX } from '../constants'; +import { TimelineDataset, TimelineDatasetStatus } from '../types.d.ts'; +import { timelineDatasetsAtom, timelineSizesAtom } from './atoms'; /** * Calculates the date domain of the datasets, if any are selected. @@ -45,54 +36,6 @@ export function useTimelineDatasetsDomain() { }, [datasets, minDays]); } -/** - * Calculate min and max scale factors, such has each day has a minimum of - * {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px - * @returns Minimum and maximum scale factors as k0 and k1. - */ -export function useScaleFactors() { - const dataDomain = useTimelineDatasetsDomain(); - const { contentWidth } = useAtomValue(timelineSizesAtom); - - // Calculate min and max scale factors, such has each day has a minimum of - // {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px. - return useMemo(() => { - if (contentWidth <= 0 || !dataDomain) return { k0: 0, k1: 1 }; - // Calculate how many days are in the domain. - const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); - - return { - k0: Math.max(1, DAY_SIZE_MIN / (contentWidth / domainDays)), - k1: DAY_SIZE_MAX / (contentWidth / domainDays) - }; - }, [contentWidth, dataDomain]); -} - -/** - * Creates the scales for the timeline. - * The main scale takes into account the whole data domain. - * The scaled scale is the main scale rescaled according to the zoom transform. - * @param width - * @returns - */ -export function useScales() { - const dataDomain = useTimelineDatasetsDomain(); - const zoomTransform = useAtomValue(zoomTransformAtom); - const { contentWidth } = useAtomValue(timelineSizesAtom); - - const main = useMemo(() => { - if (!dataDomain) return undefined; - return scaleTime().domain(dataDomain).range([0, contentWidth]); - }, [dataDomain, contentWidth]); - - const scaled = useMemo(() => { - if (!main) return undefined; - return rescaleX(main, zoomTransform.x, zoomTransform.k); - }, [main, zoomTransform.x, zoomTransform.k]); - - return { main, scaled }; -} - /** * Creates a focus atom for a dataset with the given id. * diff --git a/app/scripts/components/exploration/analysis-metrics-dropdown.tsx b/app/scripts/components/exploration/components/analysis-metrics-dropdown.tsx similarity index 100% rename from app/scripts/components/exploration/analysis-metrics-dropdown.tsx rename to app/scripts/components/exploration/components/analysis-metrics-dropdown.tsx diff --git a/app/scripts/components/exploration/chart-popover.tsx b/app/scripts/components/exploration/components/chart-popover.tsx similarity index 98% rename from app/scripts/components/exploration/chart-popover.tsx rename to app/scripts/components/exploration/components/chart-popover.tsx index d86a0fd85..0fe3b1ad4 100644 --- a/app/scripts/components/exploration/chart-popover.tsx +++ b/app/scripts/components/exploration/components/chart-popover.tsx @@ -18,9 +18,9 @@ import { useAtomValue } from 'jotai'; import { format } from 'date-fns'; import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { AnalysisTimeseriesEntry, TimeDensity } from '../types.d.ts'; +import { isExpandedAtom } from '../atoms/atoms'; import { DataMetric } from './analysis-metrics-dropdown'; -import { AnalysisTimeseriesEntry, TimeDensity } from './constants'; -import { isExpandedAtom } from './atoms'; import { getNumForChart } from '$components/common/chart/utils'; diff --git a/app/scripts/components/exploration/dataset-selector-modal.tsx b/app/scripts/components/exploration/components/dataset-selector-modal.tsx similarity index 95% rename from app/scripts/components/exploration/dataset-selector-modal.tsx rename to app/scripts/components/exploration/components/dataset-selector-modal.tsx index 9c9b263f5..12befd3f1 100644 --- a/app/scripts/components/exploration/dataset-selector-modal.tsx +++ b/app/scripts/components/exploration/components/dataset-selector-modal.tsx @@ -8,12 +8,8 @@ import { Form, FormCheckable } from '@devseed-ui/form'; import { Overline } from '@devseed-ui/typography'; import { Button } from '@devseed-ui/button'; -import { timelineDatasetsAtom } from './atoms'; -import { - datasetLayers, - findParentDataset, - reconcileDatasets -} from './data-utils'; +import { timelineDatasetsAtom } from '../atoms/atoms'; +import { datasetLayers, findParentDataset, reconcileDatasets } from '../data-utils'; import { variableGlsp } from '$styles/variable-utils'; diff --git a/app/scripts/components/exploration/dataset-chart.tsx b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx similarity index 97% rename from app/scripts/components/exploration/dataset-chart.tsx rename to app/scripts/components/exploration/components/datasets/dataset-chart.tsx index 2286584cf..eaf9997fb 100644 --- a/app/scripts/components/exploration/dataset-chart.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-chart.tsx @@ -4,9 +4,9 @@ import { extent, scaleLinear, ScaleTime, line, ScaleLinear } from 'd3'; import { useAtomValue } from 'jotai'; import { AnimatePresence, motion } from 'framer-motion'; -import { isExpandedAtom } from './atoms'; -import { RIGHT_AXIS_SPACE } from './constants'; -import { DataMetric } from './analysis-metrics-dropdown'; +import { isExpandedAtom } from '../../atoms/atoms'; +import { RIGHT_AXIS_SPACE } from '../../constants'; +import { DataMetric } from '../analysis-metrics-dropdown'; import { DatasetTrackMessage } from './dataset-track-message'; import { getNumForChart } from '$components/common/chart/utils'; diff --git a/app/scripts/components/exploration/dataset-list-item-status.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item-status.tsx similarity index 97% rename from app/scripts/components/exploration/dataset-list-item-status.tsx rename to app/scripts/components/exploration/components/datasets/dataset-list-item-status.tsx index a69ab2c83..5e304e0d3 100644 --- a/app/scripts/components/exploration/dataset-list-item-status.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item-status.tsx @@ -4,9 +4,8 @@ import { glsp, themeVal } from '@devseed-ui/theme-provider'; import { Button } from '@devseed-ui/button'; import { CollecticonArrowLoop } from '@devseed-ui/collecticons'; -import { DATASET_TRACK_BLOCK_HEIGHT } from './constants'; - import { pulsingAnimation } from '$components/common/loading-skeleton'; +import { DATASET_TRACK_BLOCK_HEIGHT } from '$components/exploration/constants'; const loadingPattern = '.-.. --- .- -.. .. -. --.' .split(' ') diff --git a/app/scripts/components/exploration/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx similarity index 95% rename from app/scripts/components/exploration/dataset-list-item.tsx rename to app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index 023c3dd0c..44ffa5075 100644 --- a/app/scripts/components/exploration/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -24,31 +24,39 @@ import { Toolbar, ToolbarIconButton } from '@devseed-ui/toolbar'; import { Heading } from '@devseed-ui/typography'; import { - DATASET_TRACK_BLOCK_HEIGHT, - HEADER_COLUMN_WIDTH, - TimeDensity, - TimelineDataset, - TimelineDatasetStatus -} from './constants'; -import { useTimelineDatasetAtom, useTimelineDatasetVisibility } from './hooks'; + DatasetPopover, + getInteractionDataPoint, + usePopover +} from '../chart-popover'; import { DatasetTrackError, DatasetTrackLoading } from './dataset-list-item-status'; import { DatasetChart } from './dataset-chart'; -import { activeAnalysisMetricsAtom, isAnalysisAtom } from './atoms'; import DatasetOptions from './dataset-options'; -import { useDatasetHover } from './use-dataset-hover'; -import { - DatasetPopover, - getInteractionDataPoint, - usePopover -} from './chart-popover'; import { LayerCategoricalGraphic, LayerGradientGraphic } from '$components/common/mapbox/layer-legend'; +import { + TimeDensity, + TimelineDataset, + TimelineDatasetStatus +} from '$components/exploration/types.d.ts'; +import { + DATASET_TRACK_BLOCK_HEIGHT, + HEADER_COLUMN_WIDTH +} from '$components/exploration/constants'; +import { useDatasetHover } from '$components/exploration/hooks/use-dataset-hover'; +import { + useTimelineDatasetAtom, + useTimelineDatasetVisibility +} from '$components/exploration/atoms/hooks'; +import { + activeAnalysisMetricsAtom, + isAnalysisAtom +} from '$components/exploration/atoms/atoms'; function getBlockBoundaries(date: Date, timeDensity: TimeDensity) { switch (timeDensity) { diff --git a/app/scripts/components/exploration/dataset-list.tsx b/app/scripts/components/exploration/components/datasets/dataset-list.tsx similarity index 94% rename from app/scripts/components/exploration/dataset-list.tsx rename to app/scripts/components/exploration/components/datasets/dataset-list.tsx index 5f96a4f30..7ac9b282e 100644 --- a/app/scripts/components/exploration/dataset-list.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list.tsx @@ -5,8 +5,8 @@ import { ScaleTime } from 'd3'; import styled from 'styled-components'; import { listReset } from '@devseed-ui/theme-provider'; -import { timelineDatasetsAtom } from './atoms'; import { DatasetListItem } from './dataset-list-item'; +import { timelineDatasetsAtom } from '$components/exploration/atoms/atoms'; const DatasetListSelf = styled.ul` ${listReset()} diff --git a/app/scripts/components/exploration/dataset-options.tsx b/app/scripts/components/exploration/components/datasets/dataset-options.tsx similarity index 93% rename from app/scripts/components/exploration/dataset-options.tsx rename to app/scripts/components/exploration/components/datasets/dataset-options.tsx index 2c4298f50..208a011e3 100644 --- a/app/scripts/components/exploration/dataset-options.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-options.tsx @@ -8,14 +8,13 @@ import { Button } from '@devseed-ui/button'; import { CollecticonCog, CollecticonTrashBin } from '@devseed-ui/collecticons'; import { Overline } from '@devseed-ui/typography'; -import { useTimelineDatasetSettings } from './hooks'; -import { TimelineDataset } from './constants'; -import { timelineDatasetsAtom } from './atoms'; - import DropMenuItemButton from '$styles/drop-menu-item-button'; import { SliderInput, SliderInputProps } from '$styles/range-slider'; import { composeVisuallyDisabled } from '$utils/utils'; import { Tip } from '$components/common/tip'; +import { TimelineDataset } from '$components/exploration/types.d.ts'; +import { timelineDatasetsAtom } from '$components/exploration/atoms/atoms'; +import { useTimelineDatasetSettings } from '$components/exploration/atoms/hooks'; const RemoveButton = composeVisuallyDisabled(DropMenuItemButton); diff --git a/app/scripts/components/exploration/dataset-track-message.tsx b/app/scripts/components/exploration/components/datasets/dataset-track-message.tsx similarity index 100% rename from app/scripts/components/exploration/dataset-track-message.tsx rename to app/scripts/components/exploration/components/datasets/dataset-track-message.tsx diff --git a/app/scripts/components/exploration/date-axis.tsx b/app/scripts/components/exploration/components/timeline/date-axis.tsx similarity index 96% rename from app/scripts/components/exploration/date-axis.tsx rename to app/scripts/components/exploration/components/timeline/date-axis.tsx index cfb626415..a5264fe7f 100644 --- a/app/scripts/components/exploration/date-axis.tsx +++ b/app/scripts/components/exploration/components/timeline/date-axis.tsx @@ -10,7 +10,8 @@ import { } from 'date-fns'; import { themeVal } from '@devseed-ui/theme-provider'; -import { RIGHT_AXIS_SPACE, TimeDensity } from './constants'; +import { RIGHT_AXIS_SPACE } from '$components/exploration/constants'; +import { TimeDensity } from '$components/exploration/types.d.ts'; const GridLine = styled.line` stroke: ${themeVal('color.base-200')}; diff --git a/app/scripts/components/exploration/timeline-controls.tsx b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx similarity index 96% rename from app/scripts/components/exploration/timeline-controls.tsx rename to app/scripts/components/exploration/components/timeline/timeline-controls.tsx index 14bc48f17..8ef767ed4 100644 --- a/app/scripts/components/exploration/timeline-controls.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline-controls.tsx @@ -19,16 +19,17 @@ import { VerticalDivider } from '@devseed-ui/toolbar'; +import AnalysisMetricsDropdown from '../analysis-metrics-dropdown'; import { DateAxis } from './date-axis'; -import { emptyDateRange } from './constants'; -import AnalysisMetricsDropdown from './analysis-metrics-dropdown'; + import { activeAnalysisMetricsAtom, isAnalysisAtom, isExpandedAtom, selectedDateAtom, selectedIntervalAtom -} from './atoms'; +} from '$components/exploration/atoms/atoms'; +import { emptyDateRange } from '$components/exploration/constants'; const TimelineControlsSelf = styled.div` width: 100%; diff --git a/app/scripts/components/exploration/timeline-head.tsx b/app/scripts/components/exploration/components/timeline/timeline-head.tsx similarity index 97% rename from app/scripts/components/exploration/timeline-head.tsx rename to app/scripts/components/exploration/components/timeline/timeline-head.tsx index aabaea778..f319bc922 100644 --- a/app/scripts/components/exploration/timeline-head.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline-head.tsx @@ -4,7 +4,8 @@ import { drag, ScaleTime, select } from 'd3'; import { clamp, startOfDay } from 'date-fns'; import { themeVal } from '@devseed-ui/theme-provider'; -import { DateRange, RIGHT_AXIS_SPACE } from './constants'; +import { RIGHT_AXIS_SPACE } from '$components/exploration/constants'; +import { DateRange } from '$components/exploration/types.d.ts'; // Needs padding so that the timeline head is fully visible. // This value gets added to the width. diff --git a/app/scripts/components/exploration/timeline-utils.ts b/app/scripts/components/exploration/components/timeline/timeline-utils.ts similarity index 100% rename from app/scripts/components/exploration/timeline-utils.ts rename to app/scripts/components/exploration/components/timeline/timeline-utils.ts diff --git a/app/scripts/components/exploration/timeline.tsx b/app/scripts/components/exploration/components/timeline/timeline.tsx similarity index 93% rename from app/scripts/components/exploration/timeline.tsx rename to app/scripts/components/exploration/components/timeline/timeline.tsx index 888f9688c..6092aabc9 100644 --- a/app/scripts/components/exploration/timeline.tsx +++ b/app/scripts/components/exploration/components/timeline/timeline.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; -import { useAtomValue, useSetAtom, useAtom } from 'jotai'; -import styled from 'styled-components'; -import useDimensions from 'react-cool-dimensions'; +import { Button } from '@devseed-ui/button'; +import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { Heading } from '@devseed-ui/typography'; import { select, zoom } from 'd3'; import { add, @@ -11,42 +11,44 @@ import { startOfDay, sub } from 'date-fns'; -import { glsp, themeVal } from '@devseed-ui/theme-provider'; -import { CollecticonPlusSmall } from '@devseed-ui/collecticons'; -import { Button } from '@devseed-ui/button'; -import { Heading } from '@devseed-ui/typography'; +import { useAtom, useAtomValue, useSetAtom } from 'jotai'; +import React, { useEffect, useLayoutEffect, useMemo, useRef } from 'react'; +import useDimensions from 'react-cool-dimensions'; +import styled from 'styled-components'; -import { - selectedDateAtom, - selectedIntervalAtom, - timelineDatasetsAtom, - timelineSizesAtom, - timelineWidthAtom, - zoomTransformAtom -} from './atoms'; -import { DatasetList } from './dataset-list'; +import { DatasetList } from '../datasets/dataset-list'; + +import { applyTransform, isEqualTransform, rescaleX } from './timeline-utils'; +import { TimelineControls } from './timeline-controls'; import { TimelineHeadL, TimelineHeadP, TimelineHeadR, TimelineRangeTrack } from './timeline-head'; -import { TimelineControls } from './timeline-controls'; import { DateGrid } from './date-axis'; -import { - RIGHT_AXIS_SPACE, - TimelineDatasetStatus, - ZoomTransformPlain -} from './constants'; -import { applyTransform, isEqualTransform, rescaleX } from './timeline-utils'; -import { useScaleFactors, useScales, useTimelineDatasetsDomain } from './hooks'; -import { useInteractionRectHover } from './use-dataset-hover'; -import { datasetLayers } from './data-utils'; +import { + selectedDateAtom, + selectedIntervalAtom, + timelineDatasetsAtom, + timelineSizesAtom, + timelineWidthAtom, + zoomTransformAtom +} from '$components/exploration/atoms/atoms'; +import { useTimelineDatasetsDomain } from '$components/exploration/atoms/hooks'; +import { RIGHT_AXIS_SPACE } from '$components/exploration/constants'; import { useLayoutEffectPrevious, usePreviousValue } from '$utils/use-effect-previous'; +import { + useScaleFactors, + useScales +} from '$components/exploration/hooks/scales-hooks'; +import { TimelineDatasetStatus, ZoomTransformPlain } from '$components/exploration/types.d.ts'; +import { useInteractionRectHover } from '$components/exploration/hooks/use-dataset-hover'; +import { datasetLayers } from '$components/exploration/data-utils'; const TimelineWrapper = styled.div` position: relative; @@ -360,11 +362,17 @@ export default function Timeline(props: TimelineProps) { Datasets - -

    {datasets.length} of {datasetLayers.length}

    +

    + {datasets.length} of {datasetLayers.length} +

    diff --git a/app/scripts/components/exploration/constants.ts b/app/scripts/components/exploration/constants.ts index ea09aab27..b7124fca6 100644 --- a/app/scripts/components/exploration/constants.ts +++ b/app/scripts/components/exploration/constants.ts @@ -9,55 +9,3 @@ export const emptyDateRange = { start: null, end: null }; - -export enum TimeDensity { - YEAR = 'year', - MONTH = 'month', - DAY = 'day' -} - -export enum TimelineDatasetStatus { - IDLE = 'idle', - LOADING = 'loading', - SUCCEEDED = 'succeeded', - ERRORED = 'errored' -} - -export type AnalysisTimeseriesEntry = Record & { - date: Date; -}; - -export interface TimelineDatasetAnalysis { - status: TimelineDatasetStatus; - data: { - timeseries?: AnalysisTimeseriesEntry[]; - }; - error: any; - meta: { - loaded?: number; - total?: number; - }; -} - -export interface TimelineDataset { - status: TimelineDatasetStatus; - data: any; - error: any; - settings: { - // user defined settings like visibility, opacity - isVisible?: boolean; - opacity?: number; - }; - analysis: TimelineDatasetAnalysis; -} - -export interface DateRange { - start: Date; - end: Date; -} - -export interface ZoomTransformPlain { - x: number; - y: number; - k: number; -} diff --git a/app/scripts/components/exploration/data-utils.ts b/app/scripts/components/exploration/data-utils.ts index 3c4c2d567..eb21c588b 100644 --- a/app/scripts/components/exploration/data-utils.ts +++ b/app/scripts/components/exploration/data-utils.ts @@ -1,5 +1,5 @@ import { DatasetLayer, datasets } from 'veda'; -import { TimelineDataset, TimelineDatasetStatus } from './constants'; +import { TimelineDataset, TimelineDatasetStatus } from './types.d.ts'; export const findParentDataset = (layerId: string) => { const parentDataset = Object.values(datasets).find((dataset) => diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index cd212c1aa..e3a3bc637 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -4,12 +4,12 @@ import { useSetAtom } from 'jotai'; import styled from 'styled-components'; import { Button } from '@devseed-ui/button'; -import { isAnalysisAtom, isExpandedAtom, timelineDatasetsAtom } from './atoms'; +import { isAnalysisAtom, isExpandedAtom, timelineDatasetsAtom } from './atoms/atoms'; import { TimelineDataset, TimelineDatasetAnalysis, TimelineDatasetStatus -} from './constants'; +} from './types.d.ts'; const chartData = { status: 'succeeded', diff --git a/app/scripts/components/exploration/hooks/scales-hooks.ts b/app/scripts/components/exploration/hooks/scales-hooks.ts new file mode 100644 index 000000000..164da9643 --- /dev/null +++ b/app/scripts/components/exploration/hooks/scales-hooks.ts @@ -0,0 +1,57 @@ +import { useMemo } from 'react'; +import { scaleTime } from 'd3'; +import { useAtomValue } from 'jotai'; +import { differenceInCalendarDays } from 'date-fns'; + +import { timelineSizesAtom, zoomTransformAtom } from '../atoms/atoms'; +import { DAY_SIZE_MAX, DAY_SIZE_MIN } from '../constants'; +import { useTimelineDatasetsDomain } from '../atoms/hooks'; +import { rescaleX } from '../components/timeline/timeline-utils'; + +/** + * Calculate min and max scale factors, such has each day has a minimum of + * {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px + * @returns Minimum and maximum scale factors as k0 and k1. + */ +export function useScaleFactors() { + const dataDomain = useTimelineDatasetsDomain(); + const { contentWidth } = useAtomValue(timelineSizesAtom); + + // Calculate min and max scale factors, such has each day has a minimum of + // {DAY_SIZE_MIN}px and a maximum of {DAY_SIZE_MAX}px. + return useMemo(() => { + if (contentWidth <= 0 || !dataDomain) return { k0: 0, k1: 1 }; + // Calculate how many days are in the domain. + const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]); + + return { + k0: Math.max(1, DAY_SIZE_MIN / (contentWidth / domainDays)), + k1: DAY_SIZE_MAX / (contentWidth / domainDays) + }; + }, [contentWidth, dataDomain]); +} + +/** + * Creates the scales for the timeline. + * The main scale takes into account the whole data domain. + * The scaled scale is the main scale rescaled according to the zoom transform. + * @param width + * @returns + */ +export function useScales() { + const dataDomain = useTimelineDatasetsDomain(); + const zoomTransform = useAtomValue(zoomTransformAtom); + const { contentWidth } = useAtomValue(timelineSizesAtom); + + const main = useMemo(() => { + if (!dataDomain) return undefined; + return scaleTime().domain(dataDomain).range([0, contentWidth]); + }, [dataDomain, contentWidth]); + + const scaled = useMemo(() => { + if (!main) return undefined; + return rescaleX(main, zoomTransform.x, zoomTransform.k); + }, [main, zoomTransform.x, zoomTransform.k]); + + return { main, scaled }; +} diff --git a/app/scripts/components/exploration/use-dataset-hover.ts b/app/scripts/components/exploration/hooks/use-dataset-hover.ts similarity index 100% rename from app/scripts/components/exploration/use-dataset-hover.ts rename to app/scripts/components/exploration/hooks/use-dataset-hover.ts diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index bb5762f88..e8c72785b 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -4,8 +4,8 @@ import styled from 'styled-components'; import { themeVal } from '@devseed-ui/theme-provider'; import { MockControls } from './datasets-mock'; -import Timeline from './timeline'; -import { DatasetSelectorModal } from './dataset-selector-modal'; +import Timeline from './components/timeline/timeline'; +import { DatasetSelectorModal } from './components/dataset-selector-modal'; import { LayoutProps } from '$components/common/layout-root'; import PageHero from '$components/common/page-hero'; diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts new file mode 100644 index 000000000..d3abaa7a4 --- /dev/null +++ b/app/scripts/components/exploration/types.d.ts.ts @@ -0,0 +1,51 @@ +export enum TimeDensity { + YEAR = 'year', + MONTH = 'month', + DAY = 'day' +} + +export enum TimelineDatasetStatus { + IDLE = 'idle', + LOADING = 'loading', + SUCCEEDED = 'succeeded', + ERRORED = 'errored' +} + +export type AnalysisTimeseriesEntry = Record & { + date: Date; +}; + +export interface TimelineDatasetAnalysis { + status: TimelineDatasetStatus; + data: { + timeseries?: AnalysisTimeseriesEntry[]; + }; + error: any; + meta: { + loaded?: number; + total?: number; + }; +} + +export interface TimelineDataset { + status: TimelineDatasetStatus; + data: any; + error: any; + settings: { + // user defined settings like visibility, opacity + isVisible?: boolean; + opacity?: number; + }; + analysis: TimelineDatasetAnalysis; +} + +export interface DateRange { + start: Date; + end: Date; +} + +export interface ZoomTransformPlain { + x: number; + y: number; + k: number; +} From 8e1041e08ece67f322c320d247d7f53a91bd91b4 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Mon, 4 Sep 2023 14:24:04 +0200 Subject: [PATCH 029/208] Set up basic map --- app/scripts/components/common/map/index.tsx | 31 ++ .../common/map/mapbox-style-override.ts | 318 ++++++++++++++++++ app/scripts/components/exploration/index.tsx | 4 +- package.json | 1 + yarn.lock | 92 ++++- 5 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 app/scripts/components/common/map/index.tsx create mode 100644 app/scripts/components/common/map/mapbox-style-override.ts diff --git a/app/scripts/components/common/map/index.tsx b/app/scripts/components/common/map/index.tsx new file mode 100644 index 000000000..ba73750b9 --- /dev/null +++ b/app/scripts/components/common/map/index.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import styled from 'styled-components'; +import Map from 'react-map-gl'; +import 'mapbox-gl/dist/mapbox-gl.css'; +import MapboxStyleOverride from './mapbox-style-override'; + +const MapContainer = styled.div` + && { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + } + ${MapboxStyleOverride} +`; + +export default function MapWrapper() { + return ( + + + + ); +} diff --git a/app/scripts/components/common/map/mapbox-style-override.ts b/app/scripts/components/common/map/mapbox-style-override.ts new file mode 100644 index 000000000..51eef4f64 --- /dev/null +++ b/app/scripts/components/common/map/mapbox-style-override.ts @@ -0,0 +1,318 @@ +import { css } from 'styled-components'; +import { + createButtonGroupStyles, + createButtonStyles +} from '@devseed-ui/button'; +import { + iconDataURI, + CollecticonPlusSmall, + CollecticonMinusSmall, + CollecticonMagnifierLeft, + CollecticonXmarkSmall +} from '@devseed-ui/collecticons'; +import { glsp, themeVal } from '@devseed-ui/theme-provider'; +import { variableGlsp } from '$styles/variable-utils'; +import '@mapbox/mapbox-gl-geocoder/dist/mapbox-gl-geocoder.css'; + +const MapboxStyleOverride = css` + .mapboxgl-control-container { + position: absolute; + z-index: 2; + inset: ${variableGlsp()}; + pointer-events: none; + + > * { + display: flex; + flex-flow: column nowrap; + gap: ${variableGlsp(0.5)}; + align-items: flex-start; + float: none; + pointer-events: auto; + } + + .mapboxgl-ctrl { + margin: 0; + } + + .mapboxgl-ctrl-attrib { + order: 100; + padding: 0; + background: none; + } + + .mapboxgl-ctrl-attrib-inner { + color: ${themeVal('color.surface')}; + border-radius: ${themeVal('shape.ellipsoid')}; + padding: ${glsp(0.125, 0.5)}; + font-size: 0.75rem; + line-height: 1rem; + background: ${themeVal('color.base-400a')}; + transform: translateY(-0.075rem); + + a, + a:visited { + color: inherit; + font-size: inherit; + line-height: inherit; + vertical-align: top; + text-decoration: none; + } + + a:hover { + opacity: 0.64; + } + } + } + + /* stylelint-disable no-descending-specificity */ + .mapboxgl-ctrl-logo, + .mapboxgl-ctrl-attrib-inner { + margin: 0; + opacity: 0.48; + transition: all 0.24s ease-in-out 0s; + + &:hover { + opacity: 1; + } + } + /* stylelint-enable no-descending-specificity */ + + .mapboxgl-ctrl-bottom-left { + flex-flow: row wrap; + align-items: flex-end; + align-items: center; + } + + .mapboxgl-ctrl-group { + ${createButtonGroupStyles({ orientation: 'vertical' } as any)} + background: none; + + &, + &:not(:empty) { + box-shadow: ${themeVal('boxShadow.elevationA')}; + } + + > button { + span { + display: none; + } + + &::before { + display: inline-block; + content: ''; + background-repeat: no-repeat; + background-size: 1rem 1rem; + width: 1rem; + height: 1rem; + } + } + + > button + button { + margin-top: -${themeVal('button.shape.border')}; + } + + > button:first-child:not(:last-child) { + &, + &::after { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; + } + + &::after { + clip-path: inset(-100% -100% 0 -100%); + } + } + > button:last-child:not(:first-child) { + &, + &::after { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + &::after { + clip-path: inset(0 -100% -100% -100%); + } + } + > button:not(:first-child):not(:last-child) { + &, + &::after { + border-radius: 0; + } + + &::after { + clip-path: inset(0 -100%); + } + } + } + + .mapboxgl-ctrl-zoom-in.mapboxgl-ctrl-zoom-in, + .mapboxgl-ctrl-zoom-out.mapboxgl-ctrl-zoom-out { + ${createButtonStyles({ variation: 'primary-fill', fitting: 'skinny' })} + } + + .mapboxgl-ctrl-zoom-in.mapboxgl-ctrl-zoom-in::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonPlusSmall, { + color: theme.color?.surface + })}); + } + + .mapboxgl-ctrl-zoom-out.mapboxgl-ctrl-zoom-out::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonMinusSmall, { + color: theme.color?.surface + })}); + } + + .mapboxgl-marker:hover { + cursor: pointer; + } + + .mapboxgl-ctrl-scale { + color: ${themeVal('color.surface')}; + border-color: ${themeVal('color.surface')}; + background-color: ${themeVal('color.base-400a')}; + } + + /* GEOCODER styles */ + .mapboxgl-ctrl.mapboxgl-ctrl-geocoder { + background-color: ${themeVal('color.surface')}; + color: ${themeVal('type.base.color')}; + font: ${themeVal('type.base.style')} ${themeVal('type.base.weight')} + 0.875rem/1.25rem ${themeVal('type.base.family')}; + transition: all 0.24s ease 0s; + + &::before { + position: absolute; + top: 8px; + left: 8px; + content: ''; + width: 1rem; + height: 1rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonMagnifierLeft, { + color: theme.color?.primary + })}); + background-repeat: no-repeat; + } + + &.mapboxgl-ctrl-geocoder--collapsed { + width: 2rem; + min-width: 2rem; + background-color: ${themeVal('color.primary')}; + + &::before { + background-image: url(${({ theme }) => + iconDataURI(CollecticonMagnifierLeft, { + color: theme.color?.surface + })}); + } + } + + .mapboxgl-ctrl-geocoder--icon { + display: none; + } + + .mapboxgl-ctrl-geocoder--icon-loading { + top: 5px; + right: 8px; + } + + .mapboxgl-ctrl-geocoder--button { + width: 2rem; + height: 2rem; + top: 0; + right: 0; + background: none; + border-radius: ${themeVal('shape.rounded')}; + transition: all 0.24s ease 0s; + color: inherit; + + &:hover { + opacity: 0.64; + } + + &::before { + position: absolute; + top: 8px; + left: 8px; + content: ''; + width: 1rem; + height: 1rem; + background-image: url(${({ theme }) => + iconDataURI(CollecticonXmarkSmall, { + color: theme.color?.['base-300'] + })}); + } + } + + .mapboxgl-ctrl-geocoder--input { + height: 2rem; + width: 100%; + outline: none; + font: ${themeVal('type.base.style')} ${themeVal('type.base.weight')} + 0.875rem / ${themeVal('type.base.line')} ${themeVal('type.base.family')}; + padding: 0.25rem 2rem; + color: inherit; + + &::placeholder { + color: inherit; + opacity: 0.64; + } + } + + .mapboxgl-ctrl-geocoder--powered-by { + display: none !important; + } + + .suggestions { + margin-bottom: 0.5rem; + border-radius: ${themeVal('shape.rounded')}; + font: inherit; + + a { + padding: 0.375rem 1rem; + color: inherit; + transition: all 0.24s ease 0s; + + &:hover { + opacity: 1; + color: ${themeVal('color.primary')}; + background: ${themeVal('color.primary-100')}; + } + } + + li { + &:first-child a { + padding-top: 0.5rem; + } + + &:last-child a { + padding-bottom: 0.75rem; + } + + &.active > a { + position: relative; + background: ${themeVal('color.primary-50')}; + color: ${themeVal('color.primary')}; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 0.25rem; + background: ${themeVal('color.primary')}; + } + + &:hover { + background: ${themeVal('color.primary-100')}; + } + } + } + } + } +`; + +export default MapboxStyleOverride; diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index ca5cf6610..c13711c85 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -9,6 +9,7 @@ import Timeline from './timeline'; import { LayoutProps } from '$components/common/layout-root'; import PageHero from '$components/common/page-hero'; import { PageMainContent } from '$styles/page'; +import Map from '$components/common/map'; const Container = styled.div` display: flex; @@ -22,6 +23,7 @@ const Container = styled.div` .panel { display: flex; flex-direction: column; + position: relative; } .panel-timeline { @@ -65,7 +67,7 @@ function Exploration() { -
    Top
    +
    diff --git a/package.json b/package.json index 0992061fa..e33af4b51 100644 --- a/package.json +++ b/package.json @@ -162,6 +162,7 @@ "react-helmet": "^6.1.0", "react-indiana-drag-scroll": "^2.2.0", "react-lazyload": "^3.2.0", + "react-map-gl": "^7.1.5", "react-nl2br": "^1.0.2", "react-range-slider-input": "^3.0.7", "react-resizable-panels": "^0.0.45", diff --git a/yarn.lock b/yarn.lock index 586619948..a450a1164 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1953,7 +1953,7 @@ get-stream "^6.0.1" minimist "^1.2.6" -"@mapbox/jsonlint-lines-primitives@^2.0.2": +"@mapbox/jsonlint-lines-primitives@^2.0.2", "@mapbox/jsonlint-lines-primitives@~2.0.2": version "2.0.2" resolved "http://verdaccio.ds.io:4873/@mapbox%2fjsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz#ce56e539f83552b58d10d672ea4d6fc9adc7b234" integrity sha1-zlblOfg1UrWNENZy6k1vya3HsjQ= @@ -2049,6 +2049,18 @@ resolved "http://verdaccio.ds.io:4873/@mapbox%2fwhoots-js/-/whoots-js-3.1.0.tgz#497c67a1cef50d1a2459ba60f315e448d2ad87fe" integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== +"@maplibre/maplibre-gl-style-spec@^19.2.1": + version "19.3.0" + resolved "http://verdaccio.ds.io:4873/@maplibre%2fmaplibre-gl-style-spec/-/maplibre-gl-style-spec-19.3.0.tgz#00a1dde3d389313b0b9805b57fc0b3d023cfcf19" + integrity sha512-ZbhX9CTV+Z7vHwkRIasDOwTSzr76e8Q6a55RMsAibjyX6+P0ZNL1qAKNzOjjBDP3+aEfNMl7hHo5knuY6pTAUQ== + dependencies: + "@mapbox/jsonlint-lines-primitives" "~2.0.2" + "@mapbox/unitbezier" "^0.0.1" + json-stringify-pretty-compact "^3.0.0" + minimist "^1.2.8" + rw "^1.3.3" + sort-object "^3.0.3" + "@mdx-js/mdx@^1.6.22": version "1.6.22" resolved "http://verdaccio.ds.io:4873/@mdx-js%2fmdx/-/mdx-1.6.22.tgz#8a723157bf90e78f17dc0f27995398e6c731f1ba" @@ -3554,6 +3566,13 @@ dependencies: "@types/node" "*" +"@types/mapbox-gl@>=1.0.0": + version "2.7.13" + resolved "http://verdaccio.ds.io:4873/@types%2fmapbox-gl/-/mapbox-gl-2.7.13.tgz#35f96ca3f8f651ff0258baf081f4bd501874a78b" + integrity sha512-qNffhTdYkeFl8QG9Q1zPPJmcs8PvHgmLa1PcwP1rxvcfMsIgcFr/FnrCttG0ZnH7Kzdd7xfECSRNTWSr4jC3PQ== + dependencies: + "@types/geojson" "*" + "@types/mapbox-gl@^2.7.5": version "2.7.5" resolved "http://verdaccio.ds.io:4873/@types%2fmapbox-gl/-/mapbox-gl-2.7.5.tgz#9e31fc592adb2762e4e5c7727dca5ec367dfc780" @@ -4469,6 +4488,21 @@ buffer@^6.0.3: base64-js "^1.3.1" ieee754 "^1.2.1" +bytewise-core@^1.2.2: + version "1.2.3" + resolved "http://verdaccio.ds.io:4873/bytewise-core/-/bytewise-core-1.2.3.tgz#3fb410c7e91558eb1ab22a82834577aa6bd61d42" + integrity sha512-nZD//kc78OOxeYtRlVk8/zXqTB4gf/nlguL1ggWA8FuchMyOxcyHR4QPQZMUmA7czC+YnaBrPUCubqAWe50DaA== + dependencies: + typewise-core "^1.2" + +bytewise@^1.1.0: + version "1.1.0" + resolved "http://verdaccio.ds.io:4873/bytewise/-/bytewise-1.1.0.tgz#1d13cbff717ae7158094aa881b35d081b387253e" + integrity sha512-rHuuseJ9iQ0na6UDhnrRVDh8YnWVlU6xM3VH6q/+yHDeUH2zIhUzP+2/h3LIrhLDBtTqzWpE3p3tP/boefskKQ== + dependencies: + bytewise-core "^1.2.2" + typewise "^1.0.3" + cache-base@^1.0.1: version "1.0.1" resolved "http://verdaccio.ds.io:4873/cache-base/-/cache-base-1.0.1.tgz#0a7f46416831c8b662ee36fe4e7c59d76f666ab2" @@ -6693,7 +6727,7 @@ get-user-locale@^1.2.0: dependencies: lodash.once "^4.1.1" -get-value@^2.0.3, get-value@^2.0.6: +get-value@^2.0.2, get-value@^2.0.3, get-value@^2.0.6: version "2.0.6" resolved "http://verdaccio.ds.io:4873/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28" integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= @@ -8293,6 +8327,11 @@ json-stable-stringify-without-jsonify@^1.0.1: resolved "http://verdaccio.ds.io:4873/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651" integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE= +json-stringify-pretty-compact@^3.0.0: + version "3.0.0" + resolved "http://verdaccio.ds.io:4873/json-stringify-pretty-compact/-/json-stringify-pretty-compact-3.0.0.tgz#f71ef9d82ef16483a407869556588e91b681d9ab" + integrity sha512-Rc2suX5meI0S3bfdZuA7JMFBGkJ875ApfVyq2WHELjBiiG22My/l7/8zPpH/CfFVQHuVLd8NLR0nv6vi0BYYKA== + json5@^1.0.1: version "1.0.1" resolved "http://verdaccio.ds.io:4873/json5/-/json5-1.0.1.tgz#779fb0018604fa854eacbf6252180d83543e3dbe" @@ -9531,6 +9570,11 @@ minimist@^1.2.5, minimist@^1.2.6: resolved "http://verdaccio.ds.io:4873/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.8: + version "1.2.8" + resolved "http://verdaccio.ds.io:4873/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + mixin-deep@^1.2.0: version "1.3.2" resolved "http://verdaccio.ds.io:4873/mixin-deep/-/mixin-deep-1.3.2.tgz#1120b43dc359a785dce65b55b82e257ccf479566" @@ -10697,6 +10741,14 @@ react-lifecycles-compat@^3.0.4: resolved "http://verdaccio.ds.io:4873/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-map-gl@^7.1.5: + version "7.1.5" + resolved "http://verdaccio.ds.io:4873/react-map-gl/-/react-map-gl-7.1.5.tgz#728dcd5dcbb8ca0a2a5221a4d811467e3ee8f810" + integrity sha512-YS2u2cSLlZVGjfa394f0snO6f6ZwZVTKqQwjbq/jj0w7fHU01Mn+Xqvm/Qr7nZChoT3OG7kh8JluDcXeBrDG/g== + dependencies: + "@maplibre/maplibre-gl-style-spec" "^19.2.1" + "@types/mapbox-gl" ">=1.0.0" + react-nl2br@^1.0.2: version "1.0.4" resolved "http://verdaccio.ds.io:4873/react-nl2br/-/react-nl2br-1.0.4.tgz#20079e2660b9e9a5293b115466e3749abeed6d87" @@ -11479,6 +11531,28 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" +sort-asc@^0.2.0: + version "0.2.0" + resolved "http://verdaccio.ds.io:4873/sort-asc/-/sort-asc-0.2.0.tgz#00a49e947bc25d510bfde2cbb8dffda9f50eb2fc" + integrity sha512-umMGhjPeHAI6YjABoSTrFp2zaBtXBej1a0yKkuMUyjjqu6FJsTF+JYwCswWDg+zJfk/5npWUUbd33HH/WLzpaA== + +sort-desc@^0.2.0: + version "0.2.0" + resolved "http://verdaccio.ds.io:4873/sort-desc/-/sort-desc-0.2.0.tgz#280c1bdafc6577887cedbad1ed2e41c037976646" + integrity sha512-NqZqyvL4VPW+RAxxXnB8gvE1kyikh8+pR+T+CXLksVRN9eiQqkQlPwqWYU0mF9Jm7UnctShlxLyAt1CaBOTL1w== + +sort-object@^3.0.3: + version "3.0.3" + resolved "http://verdaccio.ds.io:4873/sort-object/-/sort-object-3.0.3.tgz#945727165f244af9dc596ad4c7605a8dee80c269" + integrity sha512-nK7WOY8jik6zaG9CRwZTaD5O7ETWDLZYMM12pqY8htll+7dYeqGfEUPcUBHOpSJg2vJOrvFIY2Dl5cX2ih1hAQ== + dependencies: + bytewise "^1.1.0" + get-value "^2.0.2" + is-extendable "^0.1.1" + sort-asc "^0.2.0" + sort-desc "^0.2.0" + union-value "^1.0.1" + source-map-js@^1.0.2: version "1.0.2" resolved "http://verdaccio.ds.io:4873/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" @@ -12344,6 +12418,18 @@ typescript@^4.5.5: resolved "http://verdaccio.ds.io:4873/typescript/-/typescript-4.7.4.tgz#1a88596d1cf47d59507a1bcdfb5b9dfe4d488235" integrity sha512-C0WQT0gezHuw6AdY1M2jxUO83Rjf0HP7Sk1DtXj6j1EwkQNZrHAg2XPWlq62oqEhYvONq5pkC2Y9oPljWToLmQ== +typewise-core@^1.2, typewise-core@^1.2.0: + version "1.2.0" + resolved "http://verdaccio.ds.io:4873/typewise-core/-/typewise-core-1.2.0.tgz#97eb91805c7f55d2f941748fa50d315d991ef195" + integrity sha512-2SCC/WLzj2SbUwzFOzqMCkz5amXLlxtJqDKTICqg30x+2DZxcfZN2MvQZmGfXWKNWaKK9pBPsvkcwv8bF/gxKg== + +typewise@^1.0.3: + version "1.0.3" + resolved "http://verdaccio.ds.io:4873/typewise/-/typewise-1.0.3.tgz#1067936540af97937cc5dcf9922486e9fa284651" + integrity sha512-aXofE06xGhaQSPzt8hlTY+/YWQhm9P0jYUp1f2XtmW/3Bk0qzXcyFWAtPoo2uTGQj1ZwbDuSyuxicq+aDo8lCQ== + dependencies: + typewise-core "^1.2.0" + unbox-primitive@^1.0.2: version "1.0.2" resolved "http://verdaccio.ds.io:4873/unbox-primitive/-/unbox-primitive-1.0.2.tgz#29032021057d5e6cdbd08c5129c226dff8ed6f9e" @@ -12448,7 +12534,7 @@ unified@^9.1.0: trough "^1.0.0" vfile "^4.0.0" -union-value@^1.0.0: +union-value@^1.0.0, union-value@^1.0.1: version "1.0.1" resolved "http://verdaccio.ds.io:4873/union-value/-/union-value-1.0.1.tgz#0b6fe7b835aecda61c6ea4d4f02c14221e109847" integrity sha512-tJfXmxMeWYnczCVs7XAEvIV7ieppALdyepWMkHkwciRpZraG/xwT+s2JN8+pr1+8jCRf80FFzvr+MpQeeoF4Xg== From ec5f90db2fcc70eebb2fe029ec18c3949e30c64c Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 4 Sep 2023 20:27:15 +0100 Subject: [PATCH 030/208] Add STAC metadata integration --- .../components/datasets/dataset-list-item.tsx | 2 +- .../components/exploration/data-utils.ts | 54 +++++- .../hooks/use-stac-metadata-datasets.ts | 181 ++++++++++++++++++ app/scripts/components/exploration/index.tsx | 3 + .../components/exploration/types.d.ts.ts | 22 ++- 5 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index 44ffa5075..ca83707e4 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -308,7 +308,7 @@ export function DatasetListItem(props: DatasetListItemProps) { /> )} - {isVisible && isPopoverVisible && dataPoint && ( + {isDatasetSucceeded && isVisible && isPopoverVisible && dataPoint && ( { const parentDataset = Object.values(datasets).find((dataset) => @@ -24,7 +36,7 @@ export function reconcileDatasets( ids: string[], datasetsList: DatasetLayer[], reconciledDatasets: TimelineDataset[] -) { +): TimelineDataset[] { return ids.map((id) => { const alreadyReconciled = reconciledDatasets.find((d) => d.data.id === id); @@ -34,6 +46,10 @@ export function reconcileDatasets( const dataset = datasetsList.find((d) => d.id === id); + if (!dataset) { + throw new Error(`Dataset [${id}] not found`); + } + return { status: TimelineDatasetStatus.IDLE, data: dataset, @@ -51,3 +67,37 @@ export function reconcileDatasets( }; }); } + +export function resolveLayerTemporalExtent( + datasetId: string, + datasetData: StacDatasetData +): Date[] { + const { domain, isPeriodic, timeDensity } = datasetData; + + if (!domain || domain.length === 0) { + throw new Error(`Invalid domain on dataset [${datasetId}]`); + } + + if (!isPeriodic) return domain.map((d) => utcString2userTzDate(d)); + + if (timeDensity === TimeDensity.YEAR) { + return eachYearOfInterval({ + start: utcString2userTzDate(domain[0]), + end: utcString2userTzDate(domain.last) + }); + } else if (timeDensity === TimeDensity.MONTH) { + return eachMonthOfInterval({ + start: utcString2userTzDate(domain[0]), + end: utcString2userTzDate(domain.last) + }); + } else if (timeDensity === TimeDensity.DAY) { + return eachDayOfInterval({ + start: utcString2userTzDate(domain[0]), + end: utcString2userTzDate(domain.last) + }); + } + + throw new Error( + `Invalid time density [${timeDensity}] on dataset [${datasetId}]` + ); +} diff --git a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts new file mode 100644 index 000000000..08f0314e7 --- /dev/null +++ b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts @@ -0,0 +1,181 @@ + +import { + useQueries, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; +import axios from 'axios'; +import { useAtom } from 'jotai'; + +import { timelineDatasetsAtom } from '../atoms/atoms'; +import { + StacDatasetData, + TimelineDataset, + TimelineDatasetData, + TimelineDatasetStatus +} from '../types.d.ts'; +import { resolveLayerTemporalExtent } from '../data-utils'; + +import { useEffectPrevious } from '$utils/use-effect-previous'; + + +function didDataChange(curr: UseQueryResult, prev?: UseQueryResult) { + const currKey = `${curr.errorUpdatedAt}-${curr.dataUpdatedAt}`; + const prevKey = `${prev?.errorUpdatedAt}-${prev?.dataUpdatedAt}`; + + return prevKey !== currKey; +} + +/** + * Merges STAC metadata with local dataset, computing the domain. + * + * @param queryData react-query response with data from STAC request + * @param dataset Local dataset data. + * + * @returns Reconciled dataset with STAC data. + */ +function reconcileQueryDataWithDataset( + queryData: UseQueryResult, + dataset: TimelineDataset +): TimelineDataset { + try { + let base: TimelineDataset = { + ...dataset, + status: queryData.status as TimelineDatasetStatus, + error: queryData.error + }; + + if (queryData.status === TimelineDatasetStatus.SUCCEEDED) { + const domain = resolveLayerTemporalExtent(base.data.id, queryData.data); + + base = { + ...base, + data: { + ...base.data, + ...queryData.data, + domain + } + }; + } + + + return base; + } catch (error) { + const e = new Error('Error reconciling query data with dataset'); + // @ts-expect-error detail is not a property of Error + e.detail = error; + + return { + ...dataset, + status: TimelineDatasetStatus.ERRORED, + error: e + }; + } +} + +async function fetchStacDatasetById( + dataset: TimelineDatasetData +): Promise { + const { type, stacCol } = dataset; + + const { data } = await axios.get( + `${process.env.API_STAC_ENDPOINT}/collections/${stacCol}` + ); + + const commonTimeseriesParams = { + isPeriodic: data['dashboard:is_periodic'], + timeDensity: data['dashboard:time_density'] + }; + + if (type === 'vector') { + const featuresApiEndpoint = data.links.find( + (l) => l.rel === 'external' + ).href; + const { data: featuresApiData } = await axios.get(featuresApiEndpoint); + + return { + ...commonTimeseriesParams, + domain: featuresApiData.extent.temporal.interval[0] + }; + } else { + const domain = data.summaries + ? data.summaries.datetime + : data.extent.temporal.interval[0]; + + return { + ...commonTimeseriesParams, + domain + }; + } +} + +// Create a query object for react query. +function makeQueryObject( + dataset: TimelineDataset +): UseQueryOptions { + return { + queryKey: ['dataset', dataset.data.id], + queryFn: () => fetchStacDatasetById(dataset.data), + // This data will not be updated in the context of a browser session, so it is + // safe to set the staleTime to Infinity. As specified by react-query's + // "Important Defaults", cached data is considered stale which means that + // there would be a constant refetching. + staleTime: Infinity, + // Errors are always considered stale. If any layer errors, any refocus would + // cause a refetch. This is normally a good thing but since we have a refetch + // button, this is not needed. + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false + }; +} + +/** + * Extends local dataset state with STAC metadata. + * Whenever a dataset is added to the timeline, this hook will fetch the STAC + * metadata for that dataset and add it to the dataset state atom. + */ +export function useStacMetadataOnDatasets() { + const [datasets, setDatasets] = useAtom(timelineDatasetsAtom); + + const datasetsQueryData = useQueries({ + queries: datasets.map((dataset) => makeQueryObject(dataset)) + }); + + useEffectPrevious< + [typeof datasetsQueryData, TimelineDataset[]] + >( + (prev) => { + const prevQueryData = prev[0]; + if (!prevQueryData) return; + + const { changed, data: updatedDatasets } = datasets.reduce<{ + changed: boolean; + data: TimelineDataset[]; + }>( + (acc, dataset, idx) => { + const curr = datasetsQueryData[idx]; + + if (didDataChange(curr, prevQueryData[idx])) { + // Changed + return { + changed: true, + data: [...acc.data, reconcileQueryDataWithDataset(curr, dataset)] + }; + } else { + return { + ...acc, + data: [...acc.data, dataset] + }; + } + }, + { changed: false, data: [] } + ); + + if (changed as boolean) { + setDatasets(updatedDatasets); + } + }, + [datasetsQueryData, datasets] + ); +} diff --git a/app/scripts/components/exploration/index.tsx b/app/scripts/components/exploration/index.tsx index e8c72785b..b8804b922 100644 --- a/app/scripts/components/exploration/index.tsx +++ b/app/scripts/components/exploration/index.tsx @@ -6,6 +6,7 @@ import { themeVal } from '@devseed-ui/theme-provider'; import { MockControls } from './datasets-mock'; import Timeline from './components/timeline/timeline'; import { DatasetSelectorModal } from './components/dataset-selector-modal'; +import { useStacMetadataOnDatasets } from './hooks/use-stac-metadata-datasets'; import { LayoutProps } from '$components/common/layout-root'; import PageHero from '$components/common/page-hero'; @@ -58,6 +59,8 @@ function Exploration() { const openModal = useCallback(() => setDatasetModalRevealed(true), []); const closeModal = useCallback(() => setDatasetModalRevealed(false), []); + useStacMetadataOnDatasets(); + return ( <> & { - date: Date; + date: Date; }; export interface TimelineDatasetAnalysis { @@ -27,9 +35,15 @@ export interface TimelineDatasetAnalysis { }; } +export interface TimelineDatasetData extends DatasetLayer { + isPeriodic: boolean; + timeDensity: TimeDensity; + domain: Date[]; +} + export interface TimelineDataset { status: TimelineDatasetStatus; - data: any; + data: TimelineDatasetData; error: any; settings: { // user defined settings like visibility, opacity From 38b08aaeb36b0835fee0b54aec0cecd9b398e1a1 Mon Sep 17 00:00:00 2001 From: Daniel da Silva Date: Mon, 4 Sep 2023 20:29:53 +0100 Subject: [PATCH 031/208] Rename errors to better match react query --- .../components/exploration/atoms/hooks.ts | 2 +- .../components/datasets/dataset-list-item.tsx | 18 +++++++------- .../components/timeline/timeline.tsx | 2 +- .../components/exploration/datasets-mock.tsx | 24 +++++++++---------- .../hooks/use-stac-metadata-datasets.ts | 4 ++-- .../components/exploration/types.d.ts.ts | 4 ++-- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/app/scripts/components/exploration/atoms/hooks.ts b/app/scripts/components/exploration/atoms/hooks.ts index 202783af8..52aa7222b 100644 --- a/app/scripts/components/exploration/atoms/hooks.ts +++ b/app/scripts/components/exploration/atoms/hooks.ts @@ -20,7 +20,7 @@ export function useTimelineDatasetsDomain() { return useMemo(() => { const successDatasets = datasets.filter( - (d) => d.status === TimelineDatasetStatus.SUCCEEDED + (d) => d.status === TimelineDatasetStatus.SUCCESS ); if (!successDatasets.length) return undefined; diff --git a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx index ca83707e4..0ddc4e461 100644 --- a/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx +++ b/app/scripts/components/exploration/components/datasets/dataset-list-item.tsx @@ -187,16 +187,16 @@ export function DatasetListItem(props: DatasetListItemProps) { data: dataPoint }); - const isDatasetError = dataset.status === TimelineDatasetStatus.ERRORED; + const isDatasetError = dataset.status === TimelineDatasetStatus.ERROR; const isDatasetLoading = dataset.status === TimelineDatasetStatus.LOADING; - const isDatasetSucceeded = dataset.status === TimelineDatasetStatus.SUCCEEDED; + const isDatasetSuccess = dataset.status === TimelineDatasetStatus.SUCCESS; const isAnalysisAndError = - isAnalysis && dataset.analysis.status === TimelineDatasetStatus.ERRORED; + isAnalysis && dataset.analysis.status === TimelineDatasetStatus.ERROR; const isAnalysisAndLoading = isAnalysis && dataset.analysis.status === TimelineDatasetStatus.LOADING; - const isAnalysisAndSucceeded = - isAnalysis && dataset.analysis.status === TimelineDatasetStatus.SUCCEEDED; + const isAnalysisAndSuccess = + isAnalysis && dataset.analysis.status === TimelineDatasetStatus.SUCCESS; const datasetLegend = dataset.data.legend; @@ -270,7 +270,7 @@ export function DatasetListItem(props: DatasetListItemProps) { /> )} - {isDatasetSucceeded && ( + {isDatasetSuccess && ( <> {isAnalysisAndLoading && ( )} - {isAnalysisAndSucceeded && ( + {isAnalysisAndSuccess && ( )} - {isDatasetSucceeded && !isAnalysis && ( + {isDatasetSuccess && !isAnalysis && ( )} - {isDatasetSucceeded && isVisible && isPopoverVisible && dataPoint && ( + {isDatasetSuccess && isVisible && isPopoverVisible && dataPoint && ( d.status === TimelineDatasetStatus.SUCCEEDED + (d) => d.status === TimelineDatasetStatus.SUCCESS ); // When a loaded dataset is added from an empty state, compute the correct diff --git a/app/scripts/components/exploration/datasets-mock.tsx b/app/scripts/components/exploration/datasets-mock.tsx index e3a3bc637..461270d17 100644 --- a/app/scripts/components/exploration/datasets-mock.tsx +++ b/app/scripts/components/exploration/datasets-mock.tsx @@ -12,7 +12,7 @@ import { } from './types.d.ts'; const chartData = { - status: 'succeeded', + status: 'success', meta: { total: 9, loaded: 9 @@ -105,7 +105,7 @@ const chartData = { }; const chartData2 = { - status: 'succeeded', + status: 'success', meta: { total: 15, loaded: 15 @@ -368,7 +368,7 @@ function makeAnalysis( function makeDataset( data, - status = TimelineDatasetStatus.SUCCEEDED, + status = TimelineDatasetStatus.SUCCESS, settings: Record = {}, analysis = makeAnalysis({}, {}) ): TimelineDataset { @@ -445,10 +445,10 @@ export function MockControls() { toggleDataset( makeDataset( { - id: 'errored', + id: 'error', name: 'Error dataset' }, - TimelineDatasetStatus.ERRORED + TimelineDatasetStatus.ERROR ) ) ); @@ -479,12 +479,12 @@ export function MockControls() { toggleDataset( makeDataset( datasetSingle, - TimelineDatasetStatus.SUCCEEDED, + TimelineDatasetStatus.SUCCESS, {}, makeAnalysis( chartData2.data, chartData2.meta, - TimelineDatasetStatus.SUCCEEDED + TimelineDatasetStatus.SUCCESS ) ) ) @@ -500,12 +500,12 @@ export function MockControls() { toggleDataset( makeDataset( dataset2020, - TimelineDatasetStatus.SUCCEEDED, + TimelineDatasetStatus.SUCCESS, {}, makeAnalysis( chartData.data, chartData.meta, - TimelineDatasetStatus.SUCCEEDED + TimelineDatasetStatus.SUCCESS ) ) ) @@ -525,7 +525,7 @@ export function MockControls() { id: 'analysis-loading', name: 'Analysis loading' }, - TimelineDatasetStatus.SUCCEEDED, + TimelineDatasetStatus.SUCCESS, {}, makeAnalysis( {}, @@ -550,12 +550,12 @@ export function MockControls() { id: 'analysis-error', name: 'Analysis Error' }, - TimelineDatasetStatus.SUCCEEDED, + TimelineDatasetStatus.SUCCESS, {}, makeAnalysis( {}, { loaded: 34, total: 100 }, - TimelineDatasetStatus.ERRORED + TimelineDatasetStatus.ERROR ) ) ) diff --git a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts index 08f0314e7..59a43547e 100644 --- a/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts +++ b/app/scripts/components/exploration/hooks/use-stac-metadata-datasets.ts @@ -45,7 +45,7 @@ function reconcileQueryDataWithDataset( error: queryData.error }; - if (queryData.status === TimelineDatasetStatus.SUCCEEDED) { + if (queryData.status === TimelineDatasetStatus.SUCCESS) { const domain = resolveLayerTemporalExtent(base.data.id, queryData.data); base = { @@ -67,7 +67,7 @@ function reconcileQueryDataWithDataset( return { ...dataset, - status: TimelineDatasetStatus.ERRORED, + status: TimelineDatasetStatus.ERROR, error: e }; } diff --git a/app/scripts/components/exploration/types.d.ts.ts b/app/scripts/components/exploration/types.d.ts.ts index 856842229..421e0530f 100644 --- a/app/scripts/components/exploration/types.d.ts.ts +++ b/app/scripts/components/exploration/types.d.ts.ts @@ -9,8 +9,8 @@ export enum TimeDensity { export enum TimelineDatasetStatus { IDLE = 'idle', LOADING = 'loading', - SUCCEEDED = 'success', - ERRORED = 'error' + SUCCESS = 'success', + ERROR = 'error' } export interface StacDatasetData { From 61db4889d5d99b9ebaebab11edd09d6023c94218 Mon Sep 17 00:00:00 2001 From: Erik Escoffier Date: Tue, 5 Sep 2023 14:18:18 +0200 Subject: [PATCH 032/208] Set up compare map --- .../map/controls/map-options/basemap.ts | 66 +++++++++ app/scripts/components/common/map/index.tsx | 127 +++++++++++++++-- .../common/map/styleGenerators/basemap.tsx | 117 ++++++++++++++++ app/scripts/components/common/map/styles.tsx | 128 ++++++++++++++++++ app/scripts/components/common/map/types.d.ts | 21 +++ app/scripts/components/exploration/index.tsx | 23 +++- 6 files changed, 463 insertions(+), 19 deletions(-) create mode 100644 app/scripts/components/common/map/controls/map-options/basemap.ts create mode 100644 app/scripts/components/common/map/styleGenerators/basemap.tsx create mode 100644 app/scripts/components/common/map/styles.tsx create mode 100644 app/scripts/components/common/map/types.d.ts diff --git a/app/scripts/components/common/map/controls/map-options/basemap.ts b/app/scripts/components/common/map/controls/map-options/basemap.ts new file mode 100644 index 000000000..4096e6a09 --- /dev/null +++ b/app/scripts/components/common/map/controls/map-options/basemap.ts @@ -0,0 +1,66 @@ +/** + * Basemap style requirements (followed by standaard Mapbox Studio styles) + * - have a layer named "admin-0-boundary-bg". Data will be added below + * this layer to ensure country oulines and labels are visible. + * - for label and boundaries layers to be toggled on and off, they must + * belong to a group specifically named - see GROUPS_BY_OPTION for the + * list of accepted group names + */ + +export const BASE_STYLE_PATH = 'https://api.mapbox.com/styles/v1/covid-nasa'; + +export const getStyleUrl = (mapboxId: string) => + `${BASE_STYLE_PATH}/${mapboxId}?access_token=${process.env.MAPBOX_TOKEN}`; + +export const BASEMAP_STYLES = [ + { + id: 'satellite', + label: 'Satellite', + mapboxId: 'cldu1cb8f00ds01p6gi583w1m', + thumbnailUrl: `https://api.mapbox.com/styles/v1/covid-nasa/cldu1cb8f00ds01p6gi583w1m/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'dark', + label: 'Default dark', + mapboxId: 'cldu14gii006801mgq3dn1jpd', + thumbnailUrl: `https://api.mapbox.com/styles/v1/mapbox/dark-v10/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'light', + label: 'Default light', + mapboxId: 'cldu0tceb000701qnrl7p9woh', + thumbnailUrl: `https://api.mapbox.com/styles/v1/mapbox/light-v10/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + }, + { + id: 'topo', + label: 'Topo', + mapboxId: 'cldu1yayu00au01qqrbdahb3m', + thumbnailUrl: `https://api.mapbox.com/styles/v1/covid-nasa/cldu1yayu00au01qqrbdahb3m/static/-9.14,38.7,10.5,0/480x320?access_token=${process.env.MAPBOX_TOKEN}` + } +] as const; + +export const BASEMAP_ID_DEFAULT = 'satellite'; + +// Default style used in stories and analysis, satellite no labels +export const DEFAULT_MAP_STYLE_URL = + 'mapbox://styles/covid-nasa/ckb01h6f10bn81iqg98ne0i2y'; + +export const GROUPS_BY_OPTION: Record = { + labels: [ + 'Natural features, natural-labels', + 'Place labels, place-labels', + 'Point of interest labels, poi-labels', + 'Road network, road-labels', + 'Transit, transit-labels' + ], + boundaries: [ + 'Country Borders, country-borders', + 'Administrative boundaries, admin' + ] +}; + +export type Basemap = typeof BASEMAP_STYLES[number]; + +export type BasemapId = typeof BASEMAP_STYLES[number]['id']; + +export type Option = 'labels' | 'boundaries'; diff --git a/app/scripts/components/common/map/index.tsx b/app/scripts/components/common/map/index.tsx index ba73750b9..a7329e79b 100644 --- a/app/scripts/components/common/map/index.tsx +++ b/app/scripts/components/common/map/index.tsx @@ -1,31 +1,128 @@ -import React from 'react'; +import React, { + useCallback, + ReactNode, + Children, + useMemo, + useEffect, + ReactElement, + JSXElementConstructor +} from 'react'; import styled from 'styled-components'; -import Map from 'react-map-gl'; +import Map, { MapProvider, useMap } from 'react-map-gl'; +import MapboxCompare from 'mapbox-gl-compare'; import 'mapbox-gl/dist/mapbox-gl.css'; +import 'mapbox-gl-compare/dist/mapbox-gl-compare.css'; import MapboxStyleOverride from './mapbox-style-override'; +import { Styles } from './styles'; const MapContainer = styled.div` - && { + { position: absolute; inset: 0; width: 100%; - height: 100%; + top: 0, + bottom: 0, + left: 0 } ${MapboxStyleOverride} `; -export default function MapWrapper() { +function CompareHandler() { + const { main, compared } = useMap(); + useEffect(() => { + if (!main) return; + + let compare; + if (compared) { + compare = new MapboxCompare(main, compared, '#comparison-container', { + mousemove: false, + orientation: 'vertical' + }); + } + + return () => { + if (compare) compare.remove(); + }; + }, [main, compared]); + + return
    sdsd
    ; +} + +export default function MapWrapper({ children }: { children: ReactNode }) { + const onStyleUpdate = useCallback((style) => { + console.log('style', style); + }, []); + + const { layers, compareLayers, controls } = useMemo(() => { + const childrenArr = Children.toArray(children) as ReactElement[]; + + // Split children into layers and controls + let layers: ReactElement[] = []; + let controls: ReactElement[] = []; + let compareLayers: ReactElement[] = []; + + childrenArr.forEach((child) => { + const componentName = (child.type as JSXElementConstructor).name; + if (componentName === 'Compare') { + compareLayers = Children.toArray( + child.props.children + ) as ReactElement[]; + } else if (['Basemap', 'RasterTimeseries'].includes(componentName)) { + layers = [...layers, child]; + } else { + controls = [...controls, child]; + } + }); + return { + layers, + controls, + compareLayers + }; + }, [children]); + return ( - - + + + + + {controls} + {layers} + + {compareLayers.length && ( + + {/* {controls} */} + {/* {layers} */} + + )} + ); } + +export function Compare({ children }: { children: ReactNode }) { + return children; +} + +export function Basemap() { + return null; +} diff --git a/app/scripts/components/common/map/styleGenerators/basemap.tsx b/app/scripts/components/common/map/styleGenerators/basemap.tsx new file mode 100644 index 000000000..3d86bc907 --- /dev/null +++ b/app/scripts/components/common/map/styleGenerators/basemap.tsx @@ -0,0 +1,117 @@ +import { useQuery } from '@tanstack/react-query'; +import { AnySourceImpl, Layer, Style } from 'mapbox-gl'; +import { useEffect, useState } from 'react'; +import { + BasemapId, + BASEMAP_STYLES, + getStyleUrl, + GROUPS_BY_OPTION +} from '../controls/map-options/basemap'; +import { useMapStyle } from '../styles'; +import { ExtendedLayer } from '../types'; + +interface BasemapProps { + basemapStyleId?: BasemapId; + labelsOption?: boolean; + boundariesOption?: boolean; +} + +function mapGroupNameToGroupId( + groupNames: string[], + mapboxGroups: Record +) { + const groupsAsArray = Object.entries(mapboxGroups); + + return groupNames.map((groupName) => { + return groupsAsArray.find(([, group]) => group.name === groupName)?.[0]; + }); +} + +export function Basemap({ + basemapStyleId = 'satellite', + labelsOption = true, + boundariesOption = true +}: BasemapProps) { + const { updateStyle } = useMapStyle(); + + const [baseStyle, setBaseStyle] = useState