diff --git a/package-lock.json b/package-lock.json index 895022a..5dcea26 100755 --- a/package-lock.json +++ b/package-lock.json @@ -28,6 +28,7 @@ "react-router-dom": "^6.3.0", "rxjs": "^7.5.5", "typescript": "^4.7.2", + "usehooks-ts": "^2.6.0", "web-vitals": "^2.1.4" }, "devDependencies": { @@ -11065,9 +11066,9 @@ } }, "node_modules/undici": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz", - "integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz", + "integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==", "dev": true, "engines": { "node": ">=12.18" @@ -11171,6 +11172,14 @@ "requires-port": "^1.0.0" } }, + "node_modules/usehooks-ts": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz", + "integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==", + "peerDependencies": { + "react": ">=16.9.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -19998,9 +20007,9 @@ "optional": true }, "undici": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-5.4.0.tgz", - "integrity": "sha512-A1SRXysDg7J+mVP46jF+9cKANw0kptqSFZ8tGyL+HBiv0K1spjxPX8Z4EGu+Eu6pjClJUBdnUPlxrOafR668/g==", + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-5.5.1.tgz", + "integrity": "sha512-MEvryPLf18HvlCbLSzCW0U00IMftKGI5udnjrQbC5D4P0Hodwffhv+iGfWuJwg16Y/TK11ZFK8i+BPVW2z/eAw==", "dev": true }, "unicode-canonical-property-names-ecmascript": { @@ -20067,6 +20076,12 @@ "requires-port": "^1.0.0" } }, + "usehooks-ts": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.6.0.tgz", + "integrity": "sha512-Kj/4oc2nOxRDGTDb2v1ZulF7+tpeXFuqI6cUesM0Vic7TPPDlFORxKh4ivsYg+NTvX/YbM+lhqqkfFTiIt23eg==", + "requires": {} + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 7c93111..9841e76 100755 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "react-router-dom": "^6.3.0", "rxjs": "^7.5.5", "typescript": "^4.7.2", + "usehooks-ts": "^2.6.0", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/src/components/d3/D3Chart.tsx b/src/components/d3/D3Chart.tsx new file mode 100644 index 0000000..eb017da --- /dev/null +++ b/src/components/d3/D3Chart.tsx @@ -0,0 +1,395 @@ +import { memo, ReactNode, useCallback, useEffect, useMemo, useState } from 'react' +import { useElementSize } from 'usehooks-ts' +import { Empty } from 'antd' +import { Property } from 'csstype' +import * as d3 from 'd3' + +import LoaderPortal from '@components/LoaderPortal' +import { formatDate, makePointsOptimizator, usePartialProps } from '@utils' + +import D3MouseZone from './D3MouseZone' +import { + BasePluginSettings, + D3ContextMenu, + D3ContextMenuSettings, + D3Cursor, + D3CursorSettings, + D3Tooltip, + D3TooltipSettings +} from './plugins' + +import '@styles/d3.less' + +type DefaultDataType = Record + +export type ChartAxis = { + type: 'linear' | 'time', + accessor: keyof DataType | ((d: DataType) => any) +} + +export type BaseChartDataset = { + key: string | number + label?: ReactNode + yAxis: ChartAxis + color?: Property.Color + opacity?: number + width?: Property.StrokeWidth + tooltip?: D3TooltipSettings + animDurationMs?: number + afterDraw?: (d: any) => void +} + +export type LineChartDataset = { + type: 'line' + point?: { + radius?: number + color?: Property.Color + } + nullValues?: 'skip' | 'gap' | 'none' + optimization?: boolean +} + +export type AreaChartDataset = { + type: 'area' + fillColor?: Property.Color + point?: { + radius?: number + color?: Property.Color + } +} + +export type NeedleChartDataset = { + type: 'needle' +} + +export type ChartDataset = BaseChartDataset & ( + AreaChartDataset | + LineChartDataset | + NeedleChartDataset +) + +export type ChartDomain = { + x: { min?: number, max?: number } + y: { min?: number, max?: number } +} + +export type ChartOffset = { + top: number + bottom: number + left: number + right: number +} + +export type ChartTicks = { + color?: Property.Color + x?: { visible?: boolean, count?: number } + y?: { visible?: boolean, count?: number } +} + +export type D3ChartProps = React.DetailedHTMLProps, HTMLDivElement> & { + xAxis: ChartAxis + datasets: ChartDataset[] + data?: DataType[] + domain?: Partial + width?: number | string + height?: number | string + loading?: boolean + offset?: Partial + mode: 'horizontal' | 'vertical' + animDurationMs?: number + backgroundColor?: Property.Color + ticks?: ChartTicks + plugins?: { + menu?: BasePluginSettings & D3ContextMenuSettings + tooltip?: BasePluginSettings & D3TooltipSettings + cursor?: BasePluginSettings & D3CursorSettings + } +} + +type Selection = d3.Selection + +type ChartRegistry = ChartDataset & { + (): Selection + y: (value: any) => number +} + +const defaultOffsets: ChartOffset = { + top: 10, + bottom: 30, + left: 50, + right: 10, +} + +const defaultXAxisConfig: ChartAxis = { + type: 'time', + accessor: (d: any) => new Date(d.date) +} + +const defaultCursorPlugin: BasePluginSettings & D3CursorSettings = { + enabled: true, +} + +const getGroupClass = (key: string | number) => `chart-id-${key}` + +const getByAccessor = >(accessor: string | ((d: T) => any)) => { + if (typeof accessor === 'function') + return accessor + return (d: T) => d[accessor] +} + +const createAxis = (config: ChartAxis) => { + if (config.type === 'time') + return d3.scaleTime() + return d3.scaleLinear() +} + +export const D3Chart = memo(({ + className = '', + xAxis: _xAxisConfig, + datasets, + data, + domain, + width: givenWidth = '100%', + height: givenHeight = '100%', + loading, + offset: _offset, + mode = 'horizontal', + animDurationMs = 200, + backgroundColor = 'transparent', + ticks, + plugins, + ...other +}) => { + const xAxisConfig = usePartialProps(_xAxisConfig, defaultXAxisConfig) + const offset = usePartialProps(_offset, defaultOffsets) + + const [svgRef, setSvgRef] = useState(null) + const [xAxisRef, setXAxisRef] = useState(null) + const [yAxisRef, setYAxisRef] = useState(null) + const [chartAreaRef, setChartAreaRef] = useState(null) + + const xAxisArea = useCallback(() => d3.select(xAxisRef), [xAxisRef]) + const yAxisArea = useCallback(() => d3.select(yAxisRef), [yAxisRef]) + const chartArea = useCallback(() => d3.select(chartAreaRef), [chartAreaRef]) + + const getX = useMemo(() => getByAccessor(xAxisConfig.accessor), [xAxisConfig.accessor]) + + const [charts, setCharts] = useState([]) + + const [rootRef, { width, height }] = useElementSize() + + const xAxis = useMemo(() => { + if (!data) return + + const xAxis = createAxis(xAxisConfig) + + const [minX, maxX] = d3.extent(data, getX) + xAxis.domain([domain?.x?.min ?? minX, domain?.x?.max ?? maxX]) + xAxis.range([0, width - offset.left - offset.right]) + + return xAxis + }, [xAxisConfig, getX, data, domain, width, offset]) + + const yAxis = useMemo(() => { + if (!data) return + + const yAxis = d3.scaleLinear() + + if (domain?.y) { + const { min, max } = domain.y + if (min && max && Number.isFinite(min + max)) { + yAxis.domain([min, max]) + return yAxis + } + } + + let minY = Infinity + let maxY = -Infinity + charts.forEach(({ y }) => { + const [min, max] = d3.extent(data, y) + if (min && min < minY) minY = min + if (max && max > maxY) maxY = max + }) + + yAxis.domain([ + domain?.y?.min ?? minY, + domain?.y?.max ?? maxY, + ]) + + yAxis.range([height - offset.top - offset.bottom, 0]) + + return yAxis + }, [charts, data, domain, height, offset]) + + useEffect(() => { // Рисуем ось X + if (!xAxis) return + xAxisArea().transition() + .duration(animDurationMs) + .call(d3.axisBottom(xAxis) + .tickSize((ticks?.x?.visible ?? false) ? -height + offset.bottom : 0) + .tickFormat((d) => formatDate(d, undefined, 'YYYY-MM-DD') || 'NaN') + .ticks(ticks?.x?.count ?? 10) as any // TODO: Исправить тип + ) + + xAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray') + }, [xAxisArea, xAxis, animDurationMs, height, offset, ticks]) + + useEffect(() => { // Рисуем ось Y + if (!yAxis) return + yAxisArea().transition() + .duration(animDurationMs) + .call(d3.axisLeft(yAxis) + .tickSize((ticks?.y?.visible ?? false) ? -width + offset.left + offset.right : 0) + .ticks(ticks?.y?.count ?? 10) as any // TODO: Исправить тип + ) + + yAxisArea().selectAll('.tick line').attr('stroke', ticks?.color || 'lightgray') + }, [yAxisArea, yAxis, animDurationMs, width, offset, ticks]) + + useEffect(() => { + setCharts((oldCharts) => { + const charts: ChartRegistry[] = [] + + for (const chart of oldCharts) { // Удаляем ненужные графики + if (datasets.find(({ key }) => key === chart.key)) + charts.push(chart) + else + chart().remove() + } + + datasets.forEach((dataset) => { // Добавляем новые + let chartIdx = charts.findIndex(({ key }) => key === dataset.key) + if (chartIdx < 0) + chartIdx = charts.length + + const newChart: ChartRegistry = Object.assign( + () => chartArea().select('.' + getGroupClass(dataset.key)), + { + ...dataset, + y: getByAccessor(dataset.yAxis.accessor) + } + ) + + if (!newChart().node()) + chartArea() + .append('g') + .attr('class', getGroupClass(newChart.key)) + + charts[chartIdx] = newChart + }) + + return charts + }) + }, [chartArea, datasets]) + + const redrawCharts = useCallback(() => { + if (!data || !xAxis || !yAxis) return + + charts.forEach((chart) => { + chart() + .attr('stroke', String(chart.color)) + .attr('stroke-width', chart.width ?? 1) + .attr('opacity', chart.opacity ?? 1) + .attr('fill', 'none') + + let d = data + + switch (chart.type) { + case 'needle': { + const bars = chart() + .selectAll('line') + .data(data) + + bars.exit().remove() + bars.enter().append('line') + + const newBars = chart() + .selectAll('line') + .transition() + .duration(chart.animDurationMs ?? animDurationMs) + .attr('x1', (d: any) => xAxis(getX(d))) + .attr('x2', (d: any) => xAxis(getX(d))) + .attr('y1', height - offset.bottom - offset.top) + .attr('y2', (d: any) => yAxis(chart.y(d))) + + chart.afterDraw?.(newBars) + break + } + case 'line': { + let line = d3.line() + .x(d => xAxis(getX(d))) + .y(d => yAxis(chart.y(d))) + + switch (chart.nullValues || 'skip') { + case 'gap': + line = line.defined(d => (chart.y(d) ?? null) !== null && !Number.isNaN(chart.y(d))) + break + case 'skip': + d = d.filter(chart.y) + break + default: + break + } + + if (chart.optimization ?? true) { + const optimize = makePointsOptimizator((a, b) => chart.y(a) === chart.y(b)) + d = optimize(d) + } + + if (chart().selectAll('path').empty()) + chart().append('path') + + const lineElm = chart().selectAll('path') + .transition() + .duration(chart.animDurationMs ?? animDurationMs) + .attr('d', line(d as any)) + + chart.afterDraw?.(lineElm) + break + } + default: + break + } + }) + }, [charts, data, xAxis, yAxis, height]) + + useEffect(() => { + redrawCharts() + }, [redrawCharts]) + + return ( + +
+ {data ? ( + + + + + + + + + {(plugins?.tooltip?.enabled ?? true) && ( )} + {(plugins?.cursor?.enabled ?? true) && ( )} + + + + ) : ( +
+ +
+ )} +
+
+ ) +}) + +export default D3Chart diff --git a/src/components/d3/D3MouseZone.tsx b/src/components/d3/D3MouseZone.tsx new file mode 100644 index 0000000..ab8cdeb --- /dev/null +++ b/src/components/d3/D3MouseZone.tsx @@ -0,0 +1,102 @@ +import { createContext, memo, ReactNode, useCallback, useContext, useMemo, useRef, useState } from 'react' +import * as d3 from 'd3' + +import '@styles/d3.less' + +export type D3MouseState = { + x: number + y: number + visible: boolean +} + +export type D3MouseZoneContext = { + mouseState: D3MouseState, + zone: (() => d3.Selection) | null + zoneRect: DOMRect | null +} + +export type D3MouseZoneProps = { + width: number + height: number + offset: Record + children: ReactNode +} + +export const D3MouseZoneContext = createContext({ + mouseState: { + x: NaN, + y: NaN, + visible: false + }, + zone: null, + zoneRect: null +}) + +export const useD3MouseZone = () => useContext(D3MouseZoneContext) + +export const D3MouseZone = memo(({ width, height, offset, children }) => { + const zoneRef = useRef(null) + const rectRef = useRef(null) + + const [state, setState] = useState({ x: 0, y: 0, visible: false }) + + const subscribeEvent = useCallback((name: keyof SVGElementEventMap, handler: EventListenerOrEventListenerObject) => { + if (!rectRef.current) return null + rectRef.current.addEventListener(name, handler) + return () => { + if (!rectRef.current) return false + rectRef.current.removeEventListener(name, handler) + return true + } + }, [rectRef.current]) + + const onMouse = useCallback((e: any) => { + const rect = e.target.getBoundingClientRect() + + setState({ + x: e.nativeEvent.clientX - rect.left, + y: e.nativeEvent.clientY - rect.top, + visible: true, + }) + }, []) + + const onMouseOut = useCallback((e: any) => { + setState((prevState) => ({ + ...prevState, + visible: false, + })) + }, []) + + const childContext: D3MouseZoneContext = useMemo(() => { + const zone = zoneRef.current ? (() => d3.select(zoneRef.current)) : null + + return { + mouseState: state, + zone, + zoneRect: zoneRef.current?.getBoundingClientRect() || null, + subscribe: subscribeEvent, + } + }, [zoneRef.current, state, subscribeEvent]) + + return ( + + + + {children} + + + ) +}) + +export default D3MouseZone diff --git a/src/components/d3/D3Tooltip.tsx b/src/components/d3/D3Tooltip.tsx deleted file mode 100644 index 1366aeb..0000000 --- a/src/components/d3/D3Tooltip.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import * as d3 from 'd3' -import { Selection } from 'd3' -import { HTMLAttributes, memo, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' - -import '@styles/d3.less' - -export type D3TooltipProps = HTMLAttributes & { - targets: Selection - onTargetHover?: (e: MouseEvent) => void - onTargetOut?: (e: MouseEvent) => void - width?: number - height?: number - content: (data: unknown, target: Selection) => ReactNode -} - -export const D3Tooltip = memo(({ targets, width, height, content, onTargetHover, onTargetOut, className, style, ...other }) => { - const [target, setTarget] = useState>() - const [position, setMousePosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 }) - const [visible, setVisible] = useState(false) - - const tooltipRef = useRef(null) - - const onMouseOver = useCallback((e: MouseEvent) => { - onTargetHover?.(e) - setTarget(d3.select(e.target as Element)) - setMousePosition({ x: e.pageX, y: e.pageY }) - setVisible(true) - }, [onTargetHover]) - - const onMouseOut = useCallback((e: MouseEvent) => { - onTargetOut?.(e) - setVisible(false) - }, [onTargetOut]) - - const onMouseMove = useCallback((e: MouseEvent) => { - setMousePosition({ x: e.pageX, y: e.pageY }) - }, []) - - useEffect(() => { - document.onmousemove = onMouseMove - }, []) - - useEffect(() => { - console.log('a') - if (!targets) return - - targets - .on('mouseover', onMouseOver) - .on('mouseout', onMouseOut) - - return () => { - targets - .on('mouseover', null) - .on('mouseout', null) - } - }, [targets, onMouseOver, onMouseOut]) - - const data = useMemo(() => { - if (!target) return null - - const data = target.data()[0] - return content(data, target) - }, [target]) - - const tooltipStyle = useMemo(() => { - const rect: DOMRect = tooltipRef.current?.getBoundingClientRect() ?? new DOMRect(0, 0, width, height) - - const offsetX = -rect.width / 2 // По центру - const offsetY = 30 // Чуть выше курсора - - const left = Math.max(0, Math.min(document.documentElement.clientWidth - rect.width, position.x + offsetX)) - - const bottom = document.documentElement.clientHeight - Math.max(position.y - offsetY, rect.height + offsetY) - - return ({ - ...style, - width, - height, - left, - bottom, - opacity: visible ? 1 : 0 - }) - }, [tooltipRef, style, width, height, position, visible]) - - return ( -
- {data} -
- ) -}) diff --git a/src/components/d3/index.ts b/src/components/d3/index.ts index 1154fa7..c71522a 100644 --- a/src/components/d3/index.ts +++ b/src/components/d3/index.ts @@ -1,3 +1,2 @@ -export * from './D3Tooltip' - -export type { D3TooltipProps } from './D3Tooltip' +export * from './D3Chart' +export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart' diff --git a/src/components/d3/plugins/D3ContextMenu.tsx b/src/components/d3/plugins/D3ContextMenu.tsx new file mode 100644 index 0000000..d344420 --- /dev/null +++ b/src/components/d3/plugins/D3ContextMenu.tsx @@ -0,0 +1,59 @@ +import { memo, ReactElement, useMemo } from 'react' +import { ItemType } from 'antd/lib/menu/hooks/useItems' +import { Dropdown, Menu } from 'antd' + +import { FunctionalValue, svgToDataURL, useFunctionalValue } from '@utils' + +import { BasePluginSettings } from './base' + +export type D3ContextMenuSettings = { + overlay?: FunctionalValue + downloadFilename?: string + onUpdate?: () => void + additionalMenuItems?: ItemType[] + trigger?: ('click' | 'hover' | 'contextMenu')[] +} + +export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & { + children: any + svg: SVGSVGElement | null +} + +export const D3ContextMenu = memo(({ + overlay: _overlay = null, + downloadFilename = 'chart', + additionalMenuItems, + onUpdate, + trigger = ['contextMenu'], + enabled = true, + children, + svg +}) => { + const overlay = useFunctionalValue(_overlay) + + const menuItems = useMemo(() => { + const menuItems: ItemType[] = [ + { key: 'refresh', label: 'Обновить', onClick: onUpdate }, + { key: 'download', label: svg && ( + Сохранить + )}, + ] + + if (additionalMenuItems) + menuItems.push(...additionalMenuItems) + + return menuItems + }, [svg, downloadFilename, onUpdate, additionalMenuItems]) + + return ( + )} + disabled={!enabled} + trigger={trigger} + > + {children} + + ) +}) + +export default D3ContextMenu diff --git a/src/components/d3/plugins/D3Cursor.tsx b/src/components/d3/plugins/D3Cursor.tsx new file mode 100644 index 0000000..c35a580 --- /dev/null +++ b/src/components/d3/plugins/D3Cursor.tsx @@ -0,0 +1,82 @@ +import { SVGProps, useEffect, useMemo, useRef } from 'react' +import * as d3 from 'd3' + +import { useD3MouseZone } from '@components/d3/D3MouseZone' +import { usePartialProps } from '@utils' + +import { wrapPlugin } from './base' + +import '@styles/d3.less' + +export type D3CursorSettings = { + lineStyle?: SVGProps +} + +const defaultLineStyle: SVGProps = { + stroke: 'gray', + strokeWidth: 1, + strokeOpacity: 1, + className: '', +} + +const defaultSettings: D3CursorSettings = { + lineStyle: defaultLineStyle, +} + +export type D3CursorProps = D3CursorSettings + +export const D3Cursor = wrapPlugin(((props) => { + const settings = usePartialProps(props, defaultSettings) + const lineStyle = usePartialProps(settings.lineStyle, defaultLineStyle) + + const { mouseState, zoneRect } = useD3MouseZone() + const zoneRef = useRef(null) + + const zone = useMemo(() => zoneRef.current ? (() => d3.select(zoneRef.current)) : null, [zoneRef.current]) + + const getXLine = useMemo(() => zone ? (() => zone().select('.tooltip-x-line')) : null, [zone]) + const getYLine = useMemo(() => zone ? (() => zone().select('.tooltip-y-line')) : null, [zone]) + + useEffect(() => { + if (!zone || !getXLine || !getYLine) return + const z = zone() + + if (z.selectAll('line').empty()) { + z.append('line').attr('class', 'tooltip-x-line').style('pointer-events', 'none') + z.append('line').attr('class', 'tooltip-y-line').style('pointer-events', 'none') + } + + getXLine() + .attr('y1', 0) + .attr('y2', zoneRect?.height ?? 0) + + getYLine() + .attr('x1', 0) + .attr('x2', zoneRect?.width ?? 0) + }, [zone, getXLine, getYLine, zoneRect]) + + useEffect(() => { + if (!getXLine || !getYLine || !mouseState) return + + getXLine() + .attr('x1', mouseState.x) + .attr('x2', mouseState.x) + .attr('opacity', mouseState.visible ? '1' : '0') + + getYLine() + .attr('y1', mouseState.y) + .attr('y2', mouseState.y) + .attr('opacity', mouseState.visible ? '1' : '0') + }, [mouseState, getXLine, getYLine]) + + + return ( + + ) +}), true) + +export default D3Cursor diff --git a/src/components/d3/plugins/D3Tooltip.tsx b/src/components/d3/plugins/D3Tooltip.tsx new file mode 100644 index 0000000..aab0571 --- /dev/null +++ b/src/components/d3/plugins/D3Tooltip.tsx @@ -0,0 +1,38 @@ +import { ReactNode } from 'react' +import * as d3 from 'd3' + +import { useD3MouseZone } from '../D3MouseZone' +import { wrapPlugin } from './base' + +import '@styles/d3.less' + +export type BaseTooltip = { + render?: (data: DataType) => ReactNode + width?: number | string + height?: number | string +} + +export type AccurateTooltip = { type: 'accurate' } +export type NearestTooltip = { + type: 'nearest' + limit?: number +} + +export type D3TooltipSettings = BaseTooltip & (AccurateTooltip | NearestTooltip) + +export type D3TooltipProps> = Partial> & { + charts: any[], + data: DataType[] +} + +export const D3Tooltip = wrapPlugin(({ type = 'accurate', width, height, render, data, charts }) => { + const { mouseState, zoneRect } = useD3MouseZone() + + return ( + + {mouseState.visible && render && render(data)} + + ) +}) + +export default D3Tooltip diff --git a/src/components/d3/plugins/base.tsx b/src/components/d3/plugins/base.tsx new file mode 100644 index 0000000..c011c52 --- /dev/null +++ b/src/components/d3/plugins/base.tsx @@ -0,0 +1,15 @@ +import { FC, memo } from 'react' + +export type BasePluginSettings = { + enabled?: boolean +} + +export const wrapPlugin = (Component: FC, defaultEnabled?: boolean) => { + const wrappedComponent = memo(({ enabled, ...props }) => { + if (!(enabled ?? defaultEnabled)) return <> + + return + }) + + return wrappedComponent +} diff --git a/src/components/d3/plugins/index.ts b/src/components/d3/plugins/index.ts new file mode 100644 index 0000000..5ca80dd --- /dev/null +++ b/src/components/d3/plugins/index.ts @@ -0,0 +1,4 @@ +export * from './base' +export * from './D3ContextMenu' +export * from './D3Cursor' +export * from './D3Tooltip' diff --git a/src/pages/Telemetry/Operations/OperationsChart.jsx b/src/pages/Telemetry/Operations/OperationsChart.jsx index 634ac8e..250dbc4 100644 --- a/src/pages/Telemetry/Operations/OperationsChart.jsx +++ b/src/pages/Telemetry/Operations/OperationsChart.jsx @@ -1,125 +1,93 @@ -import { memo, useEffect, useMemo, useRef, useState } from 'react' -import * as d3 from 'd3' +import { memo, useCallback, useMemo, useState } from 'react' -import { D3Tooltip } from '@components/d3' -import { Grid, GridItem } from '@components/Grid' -import { makePointsOptimizator } from '@utils/functions/chart' -import { formatDate } from '@utils' +import { D3Chart } from '@components/d3' import '@styles/detected_operations.less' -const defaultBar = { - width: 2, /// Толщина столбцов графика - color: 'royalblue', /// Цвет столбца операций - hover: 'red', /// Цвет выделеного столбца операций +// Палитра: https://colorhunt.co/palette/f9f2ed3ab0ffffb562f87474 + +const chartDatasets = [{ + key: 'normLine', + type: 'area', + width: 2, + color: '#FFB562', + opacity: 0.3, + yAxis: { + type: 'linear', + accessor: (row) => row.operationValue?.standardValue, + }, + fillColor: '#FFB562' +}, { + key: 'normBars', + type: 'needle', + width: 2, + color: '#FFB562', + opacity: 0.65, + yAxis: { + type: 'linear', + accessor: (row) => row.operationValue?.standardValue, + }, +}, { + key: 'bars', + type: 'needle', + width: 2, + color: '#3AB0FF', + opacity: 0.75, + yAxis: { + type: 'linear', + accessor: 'value', + }, +}, { + key: 'target', + type: 'line', + color: '#F87474', + yAxis: { + type: 'linear', + accessor: (row) => row.operationValue?.targetValue, + }, + nullValues: 'gap', +}] + +const xAxis = { + type: 'time', + accessor: (row) => new Date(row.dateStart) } -const defaultTarget = { - width: 1, /// Толщина линий целевых параметров - color: 'red', /// Цвет линий целевых параметров -} +export const OperationsChart = memo(({ data, yDomain, height }) => { + const [isChartLoading, setIsChartLoading] = useState(false) -const defaultOffsets = { - top: 10, - bottom: 30, - left: 50, - right: 10, -} + const domain = useMemo(() => ({ + y: { min: 0, max: yDomain }, + // x: { min: new Date('2021-11-04 03:57'), max: new Date('2022-06-17 13:16') } + }), [yDomain]) -const optimizePoints = makePointsOptimizator((a, b) => a.target === b.target) - -export const OperationsChart = memo(({ data, yDomain, width, height, offsets, barsStyle, targetLineStyle }) => { - const [ref, setRef] = useState(null) - const [bars, setBars] = useState() - - const offset = useMemo(() => ({ ...defaultOffsets, ...offsets }), [offsets]) - const bar = useMemo(() => ({ ...defaultBar, ...barsStyle }), [barsStyle]) - const target = useMemo(() => ({ ...defaultTarget, ...targetLineStyle }), [targetLineStyle]) - - const axisX = useRef(null) - const axisY = useRef(null) - const targetPath = useRef(null) - const chartBars = useRef(null) - - const w = useMemo(() => Number.isFinite(+width) ? +width : ref?.offsetWidth, [width, ref]) - const h = useMemo(() => Number.isFinite(+height) ? +height : ref?.offsetHeight, [height, ref]) - - const d = useMemo(() => data.map((row) => ({ - date: new Date(row.dateStart), - value: row.value, - target: row.operationValue?.targetValue, - })), [data]) // Нормализуем данные для графика - - const x = useMemo(() => d3 - .scaleTime() - .range([0, w - offset.left - offset.right]) - .domain([ - d3.min(d, d => d.date), - d3.max(d, d => d.date) - ]) - , [w, d, offset]) // Создаём ось X - - const y = useMemo(() => d3 - .scaleLinear() - .range([h - offset.bottom - offset.top, 0]) - .domain([0, yDomain ?? d3.max(d, d => d.value)]) - , [h, d, offset, yDomain]) // Создаём ось Y - - const targetLine = useMemo(() => d3.line() - .x(d => x(d.date)) - .y(d => y(d.target)) - .defined(d => (d.target ?? null) !== null && !Number.isNaN(d.target)) - , [x, y]) - - useEffect(() => { d3.select(targetPath.current).attr('d', targetLine(optimizePoints(d))) }, [d, targetLine, target.color]) // Рисуем линию целевого значения - useEffect(() => { d3.select(axisX.current).call(d3.axisBottom(x)) }, [axisX, x]) // Рисуем ось X - useEffect(() => { d3.select(axisY.current).call(d3.axisLeft(y)) }, [axisY, y]) // Рисуем ось Y - - useEffect(() => { // Рисуем столбики операций - const bars = d3 - .select(chartBars.current) - .selectAll('line') - .data(d) - - bars.exit().remove() // Удаляем лишние линии - bars.enter().append('line') // Добавляем новые, если нужно - - const newBars = d3.select(chartBars.current) - .selectAll('line') - .attr('x1', d => x(d.date)) // Присваиваем значения - .attr('x2', d => x(d.date)) - .attr('y1', h - offset.bottom - offset.top) - .attr('y2', d => y(d.value)) - - setBars(newBars) - }, [d, x, y, h, offset]) + const onChartUpdate = useCallback(() => { + setIsChartLoading(true) + setTimeout(() => setIsChartLoading(false), 2000) + }, []) return ( -
- - - - - - - - - d3.select(e.target).attr('stroke', bar.hover)} - onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)} - content={(v) => ( - - Дата: - {formatDate(v.date)} - Ключевой параметр: - {(v.value || 0).toFixed(2)} - Целевой параметр: - {(v.target || 0).toFixed(2)} - - )} - /> -
+ ) }) diff --git a/src/pages/Telemetry/Operations/index.jsx b/src/pages/Telemetry/Operations/index.jsx index 7efee11..48e8c9f 100644 --- a/src/pages/Telemetry/Operations/index.jsx +++ b/src/pages/Telemetry/Operations/index.jsx @@ -129,13 +129,7 @@ const Operations = memo(() => {
- {data.operations ? ( - - ) : ( -
- -
- )} +
diff --git a/src/styles/d3.less b/src/styles/d3.less index 9610535..1bdc757 100644 --- a/src/styles/d3.less +++ b/src/styles/d3.less @@ -1,30 +1,41 @@ -.asb-d3-tooltip { - @color: white; - @bg-color: rgba(0, 0, 0, .75); +.asb-d3-chart { - color: @color; - position: absolute; - padding: 0; - border: none; - border-radius: 2px; - background-color: @bg-color; - - transition: opacity .1s ease-out; - - @arrow-size: 8px; - - &::after { - content: ' '; + & .tooltip { + @color: white; + @bg-color: rgba(0, 0, 0, .75); + + color: @color; position: absolute; - width: 0; - height: 0; - border: @arrow-size solid transparent; + padding: 0; + border: none; + border-radius: 2px; + background-color: @bg-color; + + transition: opacity .1s ease-out; + + @arrow-size: 8px; + + &::after { + content: ' '; + position: absolute; + width: 0; + height: 0; + border: @arrow-size solid transparent; + } + + &.bottom::after { + border-top-color: @bg-color; + top: 100%; + left: 50%; + margin-left: -@arrow-size; + } } - &.bottom::after { - border-top-color: @bg-color; - top: 100%; - left: 50%; - margin-left: -@arrow-size; + & .chart-empty { + display: flex; + justify-content: center; + align-items: center; + width: 100%; + height: 100%; } -} \ No newline at end of file +}