diff --git a/ui-charts/src/spaceTimeChart/components/PatternRect.tsx b/ui-charts/src/spaceTimeChart/components/PatternRect.tsx index 97f51d68..1616902d 100644 --- a/ui-charts/src/spaceTimeChart/components/PatternRect.tsx +++ b/ui-charts/src/spaceTimeChart/components/PatternRect.tsx @@ -2,55 +2,27 @@ import { useCallback } from 'react'; import { useDraw } from '../hooks/useCanvas'; import { type DrawingFunction } from '../lib/types'; +import { fillRect, type CanvasRect } from '../utils/canvas'; -export type PatternRectProps = { - timeStart: Date; - timeEnd: Date; - spaceStart: number; // mm - spaceEnd: number; // mm +export type PatternRectProps = CanvasRect & { imageElement: HTMLImageElement; }; /** * draws a repeating pattern in the space time chart */ -export const PatternRect = ({ - timeStart, - timeEnd, - spaceStart, - spaceEnd, - imageElement, -}: PatternRectProps) => { - const drawRegion = useCallback( - (ctx, { getSpacePixel, getTimePixel, spaceAxis }) => { - const timeStartPixel = getTimePixel(Number(timeStart)); - const endTimePixel = getTimePixel(Number(timeEnd)); - const spaceStartPixel = getSpacePixel(spaceStart); - const spaceEndPixel = getSpacePixel(spaceEnd); - - const areaSpaceSize = spaceEndPixel - spaceStartPixel; - const areaTimeSize = endTimePixel - timeStartPixel; - if (!areaSpaceSize || !areaTimeSize) return; - +export const PatternRect = ({ imageElement, ...rect }: PatternRectProps) => { + const drawPatternRect = useCallback( + (ctx, context) => { const pattern = ctx.createPattern(imageElement, 'repeat'); - if (!pattern) { - return; - } - - ctx.save(); - ctx.fillStyle = pattern; - if (spaceAxis === 'x') { - ctx.translate(spaceStartPixel, timeStartPixel); - ctx.fillRect(0, 0, areaSpaceSize, areaTimeSize); - } else { - ctx.translate(timeStartPixel, spaceStartPixel); - ctx.fillRect(0, 0, areaTimeSize, areaSpaceSize); + if (pattern) { + ctx.fillStyle = pattern; + fillRect(ctx, rect, context); } - ctx.restore(); }, - [timeStart, timeEnd, spaceStart, spaceEnd, imageElement] + [imageElement, rect] ); - useDraw('background', drawRegion); + useDraw('background', drawPatternRect); return null; }; diff --git a/ui-charts/src/spaceTimeChart/components/ZoomRect.tsx b/ui-charts/src/spaceTimeChart/components/ZoomRect.tsx new file mode 100644 index 00000000..c9b6dee0 --- /dev/null +++ b/ui-charts/src/spaceTimeChart/components/ZoomRect.tsx @@ -0,0 +1,53 @@ +import { useCallback } from 'react'; + +import { useDraw } from '../hooks/useCanvas'; +import { type DrawingFunction } from '../lib/types'; +import { fillRect, type CanvasRect } from '../utils/canvas'; + +/** + * radius 1 black dot with radius 3 white region around it + */ +function squareDot(ctx: CanvasRenderingContext2D, cx: number, cy: number) { + ctx.save(); + ctx.fillStyle = 'white'; + ctx.fillRect(cx - 1, cy - 1, 3, 3); + ctx.fill(); + + ctx.fillStyle = 'black'; + ctx.fillRect(cx, cy, 1, 1); + ctx.fill(); + ctx.restore(); +} + +const LINE_WIDTH = 1; +const SPACING = 4; + +export const ZoomRect = (rect: CanvasRect) => { + const drawZoomRect = useCallback( + (ctx, context) => { + ctx.save(); + ctx.fillStyle = '#0000000D'; /* black5 */ + const { areaTimeSize, areaSpaceSize } = fillRect(ctx, rect, context); + if (areaTimeSize && areaSpaceSize) { + const width = context.timeAxis === 'x' ? areaTimeSize : areaSpaceSize; + const height = context.timeAxis === 'x' ? areaSpaceSize : areaTimeSize; + + ctx.lineWidth = LINE_WIDTH; + + for (let i = 0; Math.abs(i) < Math.abs(width); i += SPACING * Math.sign(width)) { + squareDot(ctx, i, 0); + squareDot(ctx, i, 0 + height); + } + for (let i = 0; Math.abs(i) < Math.abs(height); i += SPACING * Math.sign(height)) { + squareDot(ctx, 0, i); + squareDot(ctx, 0 + width, i); + } + } + ctx.restore(); + }, + [rect] + ); + useDraw('background', drawZoomRect); + + return null; +}; diff --git a/ui-charts/src/spaceTimeChart/stories/draw-rectangle.stories.tsx b/ui-charts/src/spaceTimeChart/stories/draw-rectangle.stories.tsx new file mode 100644 index 00000000..fabfebe1 --- /dev/null +++ b/ui-charts/src/spaceTimeChart/stories/draw-rectangle.stories.tsx @@ -0,0 +1,211 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { Button } from '@osrd-project/ui-core'; +import type { Meta } from '@storybook/react'; +import { clamp } from 'lodash'; + +import '@osrd-project/ui-core/dist/theme.css'; + +import { OPERATIONAL_POINTS, PATHS } from './lib/paths'; +import { PathLayer } from '../components/PathLayer'; +import { SpaceTimeChart } from '../components/SpaceTimeChart'; +import { ZoomRect } from '../components/ZoomRect'; +import { type Point, type PathData, type OperationalPoint } from '../lib/types'; +import { getDiff } from '../utils/vectors'; +import { MouseTracker } from './lib/components'; + +const DEFAULT_WIDTH = 900; +const DEFAULT_HEIGHT = 450; + +const MIN_ZOOM = 0; +const MAX_ZOOM = 100; +const MIN_ZOOM_MS_PER_PX = 600000; +const MAX_ZOOM_MS_PER_PX = 625; +const DEFAULT_ZOOM_MS_PER_PX = 7500; +type SpaceTimeHorizontalZoomWrapperProps = { + swapAxes: boolean; + keepDrawing: boolean; + spaceOrigin: number; + xOffset: number; + yOffset: number; + operationalPoints: OperationalPoint[]; + paths: (PathData & { color: string })[]; +}; + +const zoomValueToTimeScale = (slider: number) => + MIN_ZOOM_MS_PER_PX * Math.pow(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX, slider / 100); + +const timeScaleToZoomValue = (timeScale: number) => + (100 * Math.log(timeScale / MIN_ZOOM_MS_PER_PX)) / + Math.log(MAX_ZOOM_MS_PER_PX / MIN_ZOOM_MS_PER_PX); + +/** + * This story demonstrates the behavior of drawing a rectangle with the mouse, and how it should behave in regards to the default zoom and pan. + */ +const DrawRectangleWrapper = ({ + swapAxes, + keepDrawing, + spaceOrigin, + xOffset, + yOffset, + operationalPoints = [], + paths = [], +}: SpaceTimeHorizontalZoomWrapperProps) => { + const [state, setState] = useState<{ + zoomValue: number; + xOffset: number; + yOffset: number; + panning: null | { initialOffset: Point }; + drawing: boolean; + rect: { + timeStart: Date; + timeEnd: Date; + spaceStart: number; // mm + spaceEnd: number; // mm + } | null; + }>({ + zoomValue: timeScaleToZoomValue(DEFAULT_ZOOM_MS_PER_PX), + xOffset, + yOffset, + panning: null, + drawing: false, + rect: null, + }); + useEffect(() => { + setState((prev) => ({ ...prev, xOffset })); + }, [xOffset]); + useEffect(() => { + setState((prev) => ({ ...prev, yOffset })); + }, [yOffset]); + const handleZoom = (zoomValue: number, position = DEFAULT_WIDTH / 2) => { + if (state.drawing) { + return; + } + const boundedXZoom = clamp(zoomValue, MIN_ZOOM, MAX_ZOOM); + const oldTimeScale = zoomValueToTimeScale(state.zoomValue); + const newTimeScale = zoomValueToTimeScale(boundedXZoom); + const newOffset = position - ((position - state.xOffset) * oldTimeScale) / newTimeScale; + setState((prev) => ({ ...prev, zoomValue: boundedXZoom, xOffset: newOffset })); + }; + const simpleOperationalPoints = operationalPoints.map(({ id, position }) => ({ + id, + label: id, + position, + })); + const timeOrigin = +new Date('2024-04-02T00:00:00'); + const timeScale = zoomValueToTimeScale(state.zoomValue); + const spaceScale = useMemo( + () => [ + { + from: -100000, + to: 100000, + coefficient: 300, + }, + ], + [] + ); + return ( +
+ { + handleZoom(state.zoomValue + delta, x); + }} + onPan={({ initialPosition, position, isPanning, data, initialData }) => { + const diff = getDiff(initialPosition, position); + setState((prev) => { + // when releasing the mouse, onPan is called one last time with isPanning false + if (!isPanning) { + const newDrawing = keepDrawing ? {} : { drawing: false }; + return { ...prev, panning: null, ...newDrawing }; + } + if (state.drawing) { + const rect = { + timeStart: new Date(initialData.time), + timeEnd: new Date(data.time), + spaceStart: initialData.position, + spaceEnd: data.position, + }; + + return { + ...prev, + rect, + }; + } + // Start panning: + else if (!prev.panning) { + return { + ...prev, + panning: { + initialOffset: { + x: prev.xOffset, + y: prev.yOffset, + }, + }, + }; + } + // Keep panning: + else { + const { initialOffset } = prev.panning; + return { + ...prev, + xOffset: initialOffset.x + diff.x, + yOffset: initialOffset.y + diff.y, + }; + } + }); + }} + > + {paths.map((path) => ( + + ))} + {state.rect && ( + + )} + + +
+ ); +}; + +export default { + title: 'SpaceTimeChart/Draw rectangle', + component: DrawRectangleWrapper, +} as Meta; + +export const Default = { + args: { + swapAxes: false, + keepDrawing: true, + spaceOrigin: 0, + xOffset: 0, + yOffset: 0, + operationalPoints: OPERATIONAL_POINTS, + paths: PATHS.slice(1, 2), + }, +}; diff --git a/ui-charts/src/spaceTimeChart/utils/canvas.ts b/ui-charts/src/spaceTimeChart/utils/canvas.ts index 07abf031..a6a8a338 100644 --- a/ui-charts/src/spaceTimeChart/utils/canvas.ts +++ b/ui-charts/src/spaceTimeChart/utils/canvas.ts @@ -1,6 +1,13 @@ import { clamp, identity } from 'lodash'; -import type { Direction, PathEnd, Point, RGBAColor, RGBColor } from '../lib/types'; +import type { + SpaceTimeChartContextType, + Direction, + PathEnd, + Point, + RGBAColor, + RGBColor, +} from '../lib/types'; /** * This function draws a thick lines from "from" to "to" on the given ImageData, with no @@ -331,3 +338,37 @@ export function getCrispLineCoordinate( Math.round((rawCoordinate - centerOffset) * devicePixelRatio) / devicePixelRatio + centerOffset ); } + +export type CanvasRect = { + timeStart: Date; + timeEnd: Date; + spaceStart: number; // mm + spaceEnd: number; // mm +}; + +export function fillRect( + ctx: CanvasRenderingContext2D, + rect: CanvasRect, + spaceTimeContext: SpaceTimeChartContextType +) { + const { getTimePixel, getSpacePixel, timeAxis } = spaceTimeContext; + const { timeStart, timeEnd, spaceStart, spaceEnd } = rect; + + const timeStartPixel = getTimePixel(Number(timeStart)); + const endTimePixel = getTimePixel(Number(timeEnd)); + const spaceStartPixel = getSpacePixel(spaceStart); + const spaceEndPixel = getSpacePixel(spaceEnd); + + const areaSpaceSize = spaceEndPixel - spaceStartPixel; + const areaTimeSize = endTimePixel - timeStartPixel; + if (!areaSpaceSize || !areaTimeSize) return {}; + + if (timeAxis === 'x') { + ctx.translate(timeStartPixel, spaceStartPixel); + ctx.fillRect(0, 0, areaTimeSize, areaSpaceSize); + } else { + ctx.translate(spaceStartPixel, timeStartPixel); + ctx.fillRect(0, 0, areaSpaceSize, areaTimeSize); + } + return { areaTimeSize, areaSpaceSize }; +}