Skip to content

Commit

Permalink
Merge pull request #335 from xcube-dev/forman-improve_ts_zoom
Browse files Browse the repository at this point in the history
Forman improve ts zoom
  • Loading branch information
forman authored May 6, 2024
2 parents 3b06aea + 4f8837f commit bfc76ad
Show file tree
Hide file tree
Showing 4 changed files with 112 additions and 123 deletions.
3 changes: 3 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
* Users can now define their own color bars.
This feature requires xcube server >= 1.6. (#334)

* Users can now zoom into arbitrary regions of a time-series chart
by pressing the `CTRL` key of the keyboard. (#285)

* Introduced overlay layers that can be selected in the settings.

* Users can now define their own base maps and overlay layers.
Expand Down
136 changes: 58 additions & 78 deletions src/components/TimeSeriesCharts/TimeSeriesChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ import {
TimeSeriesPoint,
} from "@/model/timeSeries";
import { WithLocale } from "@/util/lang";
import { utcTimeToIsoDateString } from "@/util/time";
import CustomLegend from "./CustomLegend";
import CustomTooltip from "./CustomTooltip";
import TimeSeriesLine from "@/components/TimeSeriesCharts/TimeSeriesLine";
import TimeSeriesChartHeader from "@/components/TimeSeriesCharts/TimeSeriesChartHeader";
import { isNumber } from "@/util/types";
import {
formatTimeTick,
formatValueTick,
} from "@/components/TimeSeriesCharts/util";

// Fix typing problem in recharts v2.12.4
type CategoricalChartState_Fixed = Omit<
Expand All @@ -78,11 +81,10 @@ const useStyles = makeStyles((theme: Theme) => ({
}));

interface TimeRangeSelection {
firstTime?: number;
secondTime?: number;

firstValue?: number;
secondValue?: number;
time1?: number;
value1?: number;
time2?: number;
value2?: number;
}

interface TimeSeriesChartProps extends WithLocale {
Expand Down Expand Up @@ -179,13 +181,6 @@ export default function TimeSeriesChart({
setTimeRangeSelection({});
};

const formatTimeTick = (value: number | string) => {
if (!isNumber(value) || !Number.isFinite(value)) {
return "";
}
return utcTimeToIsoDateString(value);
};

const handleClick = (
chartState: CategoricalChartState | CategoricalChartState_Fixed,
) => {
Expand All @@ -210,21 +205,19 @@ export default function TimeSeriesChart({
if (!isNumber(chartX) || !isNumber(chartY)) {
return;
}
const firstTime = chartState.activeLabel;
const chartCoords = getChartCoords(chartX, chartY);
if (isNumber(firstTime) && chartCoords) {
// Actually we should use firstTime=chartCoords[0] but recharts cannot clip
// correctly
setTimeRangeSelection({ firstTime, firstValue: chartCoords[1] });
const point1 = getChartCoords(chartX, chartY);
if (point1) {
const [time1, value1] = point1;
setTimeRangeSelection({ time1, value1 });
}
};

const handleMouseMove = (
chartState: CategoricalChartState | CategoricalChartState_Fixed | null,
mouseEvent: MouseEvent<HTMLElement>,
) => {
const firstTime = timeRangeSelection.firstTime;
if (firstTime === undefined) {
const { time1, value1 } = timeRangeSelection;
if (!isNumber(time1) || !isNumber(value1)) {
return;
}
if (!chartState) {
Expand All @@ -234,19 +227,21 @@ export default function TimeSeriesChart({
if (!isNumber(chartX) || !isNumber(chartY)) {
return;
}
const secondTime = chartState.activeLabel;
const chartCoords = getChartCoords(chartX, chartY);
if (isNumber(secondTime) && chartCoords) {
const point2 = getChartCoords(chartX, chartY);
if (point2) {
const [time2, value2] = point2;
if (mouseEvent.ctrlKey) {
setTimeRangeSelection({
...timeRangeSelection,
secondTime,
secondValue: chartCoords[1],
time1,
value1,
time2,
value2,
});
} else {
setTimeRangeSelection({
...timeRangeSelection,
secondTime,
time1,
value1,
time2,
});
}
}
Expand All @@ -269,41 +264,22 @@ export default function TimeSeriesChart({
};

const zoomIn = () => {
const { firstTime, secondTime, firstValue, secondValue } =
timeRangeSelection;
if (
firstTime === secondTime ||
firstTime === undefined ||
secondTime === undefined
) {
clearTimeRangeSelection();
return;
}
let timeRange: [number, number];
if (firstTime < secondTime) {
timeRange = [firstTime, secondTime];
} else {
timeRange = [secondTime, firstTime];
}
let valueRange: [number, number] | undefined = undefined;
if (firstValue !== undefined && secondValue !== undefined) {
if (firstValue < secondValue) {
valueRange = [firstValue, secondValue];
const [selectedXRange, selectedYRange] =
normalizeTimeRangeSelection(timeRangeSelection);
if (selectedXRange && selectedXRange[0] < selectedXRange[1]) {
if (selectedYRange) {
selectTimeRange(selectedXRange, timeSeriesGroup.id, selectedYRange);
} else {
valueRange = [secondValue, firstValue];
selectTimeRange(selectedXRange, timeSeriesGroup.id, null);
}
}
clearTimeRangeSelection();
if (selectTimeRange) {
selectTimeRange(timeRange, timeSeriesGroup.id, valueRange);
} else {
clearTimeRangeSelection();
}
};

const resetZoom = () => {
clearTimeRangeSelection();
if (selectTimeRange) {
selectTimeRange(dataTimeRange || null, timeSeriesGroup.id, null);
}
selectTimeRange(dataTimeRange || null, timeSeriesGroup.id, null);
};

const handleChartResize = (w: number, h: number) => {
Expand Down Expand Up @@ -376,7 +352,8 @@ export default function TimeSeriesChart({
return [xMin + wx * (xMax - xMin), yMax - wy * (yMax - yMin)];
};

const { firstTime, secondTime, firstValue, secondValue } = timeRangeSelection;
const [selectedXRange, selectedYRange] =
normalizeTimeRangeSelection(timeRangeSelection);

return (
<div ref={containerRef} className={classes.chartContainer}>
Expand All @@ -391,7 +368,7 @@ export default function TimeSeriesChart({
/>
<ResponsiveContainer
// 99% per https://github.com/recharts/recharts/issues/172
width="99%"
width="98%"
className={classes.responsiveContainer}
onResize={handleChartResize}
>
Expand All @@ -412,20 +389,19 @@ export default function TimeSeriesChart({
domain={getXDomain}
tickFormatter={formatTimeTick}
stroke={labelTextColor}
allowDuplicatedCategory={false}
allowDataOverflow
/>
<YAxis
dataKey={commonValueDataKey || "mean"}
type="number"
tickCount={5}
domain={getYDomain}
tickFormatter={(value: number) => {
return value.toFixed(2);
}}
tickFormatter={formatValueTick}
stroke={labelTextColor}
allowDataOverflow
/>
<CartesianGrid strokeDasharray="3 3" />
{!timeRangeSelection.firstTime && (
{!isNumber(timeRangeSelection.time1) && (
<Tooltip content={<CustomTooltip />} />
)}
<Legend
Expand All @@ -441,7 +417,6 @@ export default function TimeSeriesChart({
timeSeriesGroup,
timeSeriesIndex,
selectTimeSeries,
selectedTimeRange,
showPointsOnly,
showErrorBars,
places,
Expand All @@ -451,20 +426,12 @@ export default function TimeSeriesChart({
paletteMode: theme.palette.mode,
}),
)}
{isNumber(firstTime) && isNumber(secondTime) && (
{selectedXRange && (
<ReferenceArea
x1={firstTime}
y1={
isNumber(firstValue) && isNumber(secondValue)
? firstValue
: undefined
}
x2={secondTime}
y2={
isNumber(firstValue) && isNumber(secondValue)
? secondValue
: undefined
}
x1={selectedXRange[0]}
y1={selectedYRange ? selectedYRange[0] : undefined}
x2={selectedXRange[1]}
y2={selectedYRange ? selectedYRange[1] : undefined}
strokeOpacity={0.3}
fill={lightStroke}
fillOpacity={0.3}
Expand All @@ -484,3 +451,16 @@ export default function TimeSeriesChart({
</div>
);
}

function normalizeTimeRangeSelection(timeRangeSelection: TimeRangeSelection) {
const { time1, time2, value1, value2 } = timeRangeSelection;
let timeRange: [number, number] | undefined = undefined;
let valueRange: [number, number] | undefined = undefined;
if (isNumber(time1) && isNumber(time2)) {
timeRange = time1 < time2 ? [time1, time2] : [time2, time1];
if (isNumber(value1) && isNumber(value2)) {
valueRange = value1 < value2 ? [value1, value2] : [value2, value1];
}
}
return [timeRange, valueRange];
}
59 changes: 14 additions & 45 deletions src/components/TimeSeriesCharts/TimeSeriesLine.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,8 @@ import { getUserPlaceColor } from "@/config";
import { Place, PlaceInfo } from "@/model/place";
import {
PlaceGroupTimeSeries,
TimeRange,
TimeSeries,
TimeSeriesGroup,
TimeSeriesPoint,
} from "@/model/timeSeries";
import CustomDot from "./CustomDot";
import { PaletteMode } from "@mui/material";
Expand All @@ -41,7 +39,6 @@ interface TimeSeriesLineProps {
timeSeriesIndex: number;
showPointsOnly: boolean;
showErrorBars: boolean;
selectedTimeRange: TimeRange | null;
// Not implemented yet
selectTimeSeries?: (
timeSeriesGroupId: string,
Expand All @@ -62,7 +59,6 @@ interface TimeSeriesLineProps {
export default function TimeSeriesLine({
timeSeriesGroup,
timeSeriesIndex,
selectedTimeRange,
showErrorBars,
showPointsOnly,
selectTimeSeries,
Expand All @@ -81,8 +77,6 @@ export default function TimeSeriesLine({
selectPlace(timeSeries.source.placeId, places, true);
};

const [time1, time2] = selectedTimeRange ? selectedTimeRange : [null, null];

const source = timeSeries.source;
const valueDataKey = source.valueDataKey;
let lineName = source.variableName;
Expand Down Expand Up @@ -117,35 +111,7 @@ export default function TimeSeriesLine({
}
}

const data: TimeSeriesPoint[] = [];
timeSeries.data.forEach((point) => {
if (point[valueDataKey] !== null) {
let time1Ok = true;
let time2Ok = true;
if (time1 !== null) {
time1Ok = point.time >= time1;
}
if (time2 !== null) {
time2Ok = point.time <= time2;
}
if (time1Ok && time2Ok) {
data.push(point);
}
}
});

const shadedLineColor = getUserPlaceColor(lineColor, paletteMode);
let errorBar;
if (valueDataKey && showErrorBars && source.errorDataKey) {
errorBar = (
<ErrorBar
dataKey={source.errorDataKey}
width={4}
strokeWidth={1}
stroke={shadedLineColor}
/>
);
}

let strokeOpacity;
let dotProps: {
Expand All @@ -169,22 +135,18 @@ export default function TimeSeriesLine({
};
}

const dot = (
<CustomDot {...dotProps} stroke={shadedLineColor} fill={"white"} />
);
const activeDot = (
<CustomDot {...dotProps} stroke={"white"} fill={shadedLineColor} />
);

return (
<Line
key={timeSeriesIndex}
type="monotone"
name={lineName}
unit={source.variableUnits}
data={data}
data={timeSeries.data}
dataKey={valueDataKey}
dot={dot}
activeDot={activeDot}
dot={<CustomDot {...dotProps} stroke={shadedLineColor} fill={"white"} />}
activeDot={
<CustomDot {...dotProps} stroke={"white"} fill={shadedLineColor} />
}
stroke={shadedLineColor}
strokeOpacity={strokeOpacity}
// strokeWidth={2 * (ts.dataProgress || 1)}
Expand All @@ -193,7 +155,14 @@ export default function TimeSeriesLine({
isAnimationActive={false}
onClick={handleClick}
>
{errorBar}
{valueDataKey && showErrorBars && source.errorDataKey && (
<ErrorBar
dataKey={source.errorDataKey}
width={4}
strokeWidth={1}
stroke={shadedLineColor}
/>
)}
</Line>
);
}
Loading

0 comments on commit bfc76ad

Please sign in to comment.