Skip to content

Commit

Permalink
Lump dataset blocks (#649)
Browse files Browse the repository at this point in the history
Merges dataset blocks visually to allow for greater zooming out.
  • Loading branch information
nerik authored Sep 19, 2023
2 parents 560fdd7 + 7a1b27b commit e34c350
Show file tree
Hide file tree
Showing 5 changed files with 152 additions and 63 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import {
endOfDay,
endOfMonth,
endOfYear,
startOfDay,
startOfMonth,
startOfYear,
} from 'date-fns';
import { TimeDensity } from '$components/exploration/types.d.ts';

/**
* Calculate the start and end of a block of time, given a date and a time
* density
* @param date Starting date
* @param timeDensity Dataset time density
*
* @returns Array of two dates, the first being the start of the block and the
* second being the end of the block.
*/
export 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)];
}

/**
* Lumps blocks of time together if they are too small to be seen on the chart.
*
* @param params.domain The dataset domain from which to calculate the blocks
* @param params.xScaled The xScaled function from the chart
* @param params.timeDensity The dataset time density
* @param params.minBlockSize The minimum size of a block
*
* @returns The blocks and whether or not any lumping happened.
*/
export function lumpBlocks({ domain, xScaled, timeDensity, minBlockSize = 4 }) {
// How big would a block be?
const [start, end] = getBlockBoundaries(domain[0], timeDensity);

const blockWidth = xScaled(end) - xScaled(start);

if (blockWidth >= minBlockSize) {
return {
blocks: domain.map((d) => getBlockBoundaries(d, timeDensity)),
wasLumped: false
};
}

let blocks: Date[][] = [];
let startBoundary = start;
let endBoundary = end;

for (let i = 0; i < domain.length; i++) {
if (i === domain.length - 1) {
blocks = [...blocks, [startBoundary, endBoundary]];
break;
}

const nextDate = domain[i + 1];
const [startNext, endNext] = getBlockBoundaries(nextDate, timeDensity);

// Distance between the end of the current block and the start of the next.
const distance = xScaled(startNext) - xScaled(endBoundary);

if (distance < minBlockSize / 2) {
endBoundary = endNext;
continue;
}

blocks = [...blocks, [startBoundary, endBoundary]];
startBoundary = startNext;
endBoundary = endNext;
}

return { blocks, wasLumped: true };
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,7 @@ import React, { useCallback, useMemo } from 'react';
import { useAtomValue } from 'jotai';
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 { addDays, subDays, areIntervalsOverlapping } from 'date-fns';
import { useQueryClient } from '@tanstack/react-query';
import { ScaleTime } from 'd3';
import {
Expand All @@ -35,13 +25,13 @@ import {
} from './dataset-list-item-status';
import { DatasetChart } from './dataset-chart';
import DatasetOptions from './dataset-options';
import { getBlockBoundaries, lumpBlocks } from './block-utils';

import {
LayerCategoricalGraphic,
LayerGradientGraphic
} from '$components/common/mapbox/layer-legend';
import {
TimeDensity,
TimelineDatasetStatus,
TimelineDatasetSuccess
} from '$components/exploration/types.d.ts';
Expand All @@ -59,17 +49,6 @@ import {
isAnalysisAtom
} from '$components/exploration/atoms/atoms';

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;
Expand Down Expand Up @@ -367,15 +346,22 @@ function DatasetTrack(props: DatasetTrackProps) {
});
}, [xScaled, dataset]);

const { blocks, wasLumped } = lumpBlocks({
domain: domainToRender,
xScaled,
timeDensity: dataset.data.timeDensity
});

return (
<svg width={width} height={DATASET_TRACK_BLOCK_HEIGHT}>
{domainToRender.map((date) => (
<svg width={width} height={DATASET_TRACK_BLOCK_HEIGHT + 2}>
{blocks.map(([blockStart, blockEnd]) => (
<DatasetTrackBlock
key={date.getTime()}
key={blockStart.getTime()}
xScaled={xScaled}
date={date}
dataset={dataset}
startDate={blockStart}
endDate={blockEnd}
isVisible={isVisible}
isGroup={wasLumped}
/>
))}
</svg>
Expand All @@ -384,34 +370,47 @@ function DatasetTrack(props: DatasetTrackProps) {

interface DatasetTrackBlockProps {
xScaled: ScaleTime<number, number>;
date: Date;
dataset: TimelineDatasetSuccess;
startDate: Date;
endDate: Date;
isVisible: boolean;
isGroup: boolean;
}

function DatasetTrackBlock(props: DatasetTrackBlockProps) {
const { xScaled, date, dataset, isVisible } = props;
const { xScaled, startDate, endDate, isVisible, isGroup } = props;

const theme = useTheme();

const [start, end] = getBlockBoundaries(date, dataset.data.timeDensity);
const s = xScaled(start);
const e = xScaled(end);
const xStart = xScaled(startDate);
const xEnd = xScaled(endDate);

const fill = isVisible
? theme.color?.['base-400']
: theme.color?.['base-200'];

return (
<React.Fragment key={date.getTime()}>
<g>
{isGroup && (
<rect
fill={fill}
y={0}
height={DATASET_TRACK_BLOCK_HEIGHT}
x={xStart}
width={xEnd - xStart}
rx={4}
transform='translate(2, 2)'
/>
)}
<rect
fill={fill}
y={0}
height={DATASET_TRACK_BLOCK_HEIGHT}
x={s}
width={e - s}
x={xStart}
width={xEnd - xStart}
rx={4}
stroke='#fff'
strokeWidth={isGroup ? 1 : 0}
/>
</React.Fragment>
</g>
);
}
1 change: 1 addition & 0 deletions app/scripts/components/exploration/datasets-mock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,7 @@ function makeDataset(
analysis = makeAnalysis({}, {})
) {
return {
mocked: true,
status,
data,
error: status === TimelineDatasetStatus.ERROR ? new Error('Mock error') : null,
Expand Down
5 changes: 3 additions & 2 deletions app/scripts/components/exploration/hooks/scales-hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ 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 { DAY_SIZE_MAX } from '../constants';
import { useTimelineDatasetsDomain } from '../atoms/hooks';
import { rescaleX } from '../components/timeline/timeline-utils';

Expand All @@ -25,7 +25,8 @@ export function useScaleFactors() {
const domainDays = differenceInCalendarDays(dataDomain[1], dataDomain[0]);

return {
k0: Math.max(1, DAY_SIZE_MIN / (contentWidth / domainDays)),
// k0: Math.max(1, DAY_SIZE_MIN / (contentWidth / domainDays)),
k0: 1,
k1: DAY_SIZE_MAX / (contentWidth / domainDays)
};
}, [contentWidth, dataDomain]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -136,36 +136,43 @@ export function useStacMetadataOnDatasets() {
const [datasets, setDatasets] = useAtom(timelineDatasetsAtom);

const datasetsQueryData = useQueries({
queries: datasets.map((dataset) => makeQueryObject(dataset))
queries: datasets
.filter((d) => !(d as any).mocked)
.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: [] }
);
const { changed, data: updatedDatasets } = datasets
.filter((d) => !(d as any).mocked)
.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);
Expand Down

0 comments on commit e34c350

Please sign in to comment.