forked from ddrilling/asb_cloud_front
* Добавлен компонент графика d3
* Добавлены плагины для графиков * Empty перемещён из опрелённых операций в компонент графика * добавлена зависимость usehooks-ts
This commit is contained in:
parent
8db3476c0e
commit
1a05704375
27
package-lock.json
generated
27
package-lock.json
generated
@ -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",
|
||||
|
@ -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": {
|
||||
|
395
src/components/d3/D3Chart.tsx
Normal file
395
src/components/d3/D3Chart.tsx
Normal file
@ -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<string, any>
|
||||
|
||||
export type ChartAxis<DataType> = {
|
||||
type: 'linear' | 'time',
|
||||
accessor: keyof DataType | ((d: DataType) => any)
|
||||
}
|
||||
|
||||
export type BaseChartDataset<DataType> = {
|
||||
key: string | number
|
||||
label?: ReactNode
|
||||
yAxis: ChartAxis<DataType>
|
||||
color?: Property.Color
|
||||
opacity?: number
|
||||
width?: Property.StrokeWidth
|
||||
tooltip?: D3TooltipSettings<DataType>
|
||||
animDurationMs?: number
|
||||
afterDraw?: (d: any) => void
|
||||
}
|
||||
|
||||
export type LineChartDataset<DataType> = {
|
||||
type: 'line'
|
||||
point?: {
|
||||
radius?: number
|
||||
color?: Property.Color
|
||||
}
|
||||
nullValues?: 'skip' | 'gap' | 'none'
|
||||
optimization?: boolean
|
||||
}
|
||||
|
||||
export type AreaChartDataset<DataType> = {
|
||||
type: 'area'
|
||||
fillColor?: Property.Color
|
||||
point?: {
|
||||
radius?: number
|
||||
color?: Property.Color
|
||||
}
|
||||
}
|
||||
|
||||
export type NeedleChartDataset<DataType> = {
|
||||
type: 'needle'
|
||||
}
|
||||
|
||||
export type ChartDataset<DataType> = BaseChartDataset<DataType> & (
|
||||
AreaChartDataset<DataType> |
|
||||
LineChartDataset<DataType> |
|
||||
NeedleChartDataset<DataType>
|
||||
)
|
||||
|
||||
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<DataType = DefaultDataType> = React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement> & {
|
||||
xAxis: ChartAxis<DataType>
|
||||
datasets: ChartDataset<DataType>[]
|
||||
data?: DataType[]
|
||||
domain?: Partial<ChartDomain>
|
||||
width?: number | string
|
||||
height?: number | string
|
||||
loading?: boolean
|
||||
offset?: Partial<ChartOffset>
|
||||
mode: 'horizontal' | 'vertical'
|
||||
animDurationMs?: number
|
||||
backgroundColor?: Property.Color
|
||||
ticks?: ChartTicks
|
||||
plugins?: {
|
||||
menu?: BasePluginSettings & D3ContextMenuSettings
|
||||
tooltip?: BasePluginSettings & D3TooltipSettings<DataType>
|
||||
cursor?: BasePluginSettings & D3CursorSettings
|
||||
}
|
||||
}
|
||||
|
||||
type Selection = d3.Selection<any, any, null, undefined>
|
||||
|
||||
type ChartRegistry<DataType = DefaultDataType> = ChartDataset<DataType> & {
|
||||
(): Selection
|
||||
y: (value: any) => number
|
||||
}
|
||||
|
||||
const defaultOffsets: ChartOffset = {
|
||||
top: 10,
|
||||
bottom: 30,
|
||||
left: 50,
|
||||
right: 10,
|
||||
}
|
||||
|
||||
const defaultXAxisConfig: ChartAxis<DefaultDataType> = {
|
||||
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 = <T extends Record<string, any>>(accessor: string | ((d: T) => any)) => {
|
||||
if (typeof accessor === 'function')
|
||||
return accessor
|
||||
return (d: T) => d[accessor]
|
||||
}
|
||||
|
||||
const createAxis = <DataType,>(config: ChartAxis<DataType>) => {
|
||||
if (config.type === 'time')
|
||||
return d3.scaleTime()
|
||||
return d3.scaleLinear()
|
||||
}
|
||||
|
||||
export const D3Chart = memo<D3ChartProps>(({
|
||||
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<SVGSVGElement | null>(null)
|
||||
const [xAxisRef, setXAxisRef] = useState<SVGGElement | null>(null)
|
||||
const [yAxisRef, setYAxisRef] = useState<SVGGElement | null>(null)
|
||||
const [chartAreaRef, setChartAreaRef] = useState<SVGGElement | null>(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<ChartRegistry[]>([])
|
||||
|
||||
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 (
|
||||
<LoaderPortal show={loading}>
|
||||
<div
|
||||
{...other}
|
||||
ref={rootRef}
|
||||
className={`asb-d3-chart ${className}`}
|
||||
style={{
|
||||
width: givenWidth,
|
||||
height: givenHeight,
|
||||
}}
|
||||
>
|
||||
{data ? (
|
||||
<D3ContextMenu {...plugins?.menu} svg={svgRef}>
|
||||
<svg ref={setSvgRef} width={'100%'} height={'100%'}>
|
||||
<g ref={setXAxisRef} className={'axis x'} transform={`translate(${offset.left}, ${height - offset.bottom})`} />
|
||||
<g ref={setYAxisRef} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
||||
<g ref={setChartAreaRef} className={'chart-area'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<rect width={width - offset.left - offset.right} height={height - offset.top - offset.bottom} fill={backgroundColor} />
|
||||
</g>
|
||||
<D3MouseZone width={width} height={height} offset={offset}>
|
||||
{(plugins?.tooltip?.enabled ?? true) && ( <D3Tooltip {...plugins?.tooltip} data={data} charts={charts} /> )}
|
||||
{(plugins?.cursor?.enabled ?? true) && ( <D3Cursor {...plugins?.cursor} /> )}
|
||||
</D3MouseZone>
|
||||
</svg>
|
||||
</D3ContextMenu>
|
||||
) : (
|
||||
<div className={'chart-empty'}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
)
|
||||
})
|
||||
|
||||
export default D3Chart
|
102
src/components/d3/D3MouseZone.tsx
Normal file
102
src/components/d3/D3MouseZone.tsx
Normal file
@ -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<any, any, null, undefined>) | null
|
||||
zoneRect: DOMRect | null
|
||||
}
|
||||
|
||||
export type D3MouseZoneProps = {
|
||||
width: number
|
||||
height: number
|
||||
offset: Record<string, number>
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export const D3MouseZoneContext = createContext<D3MouseZoneContext>({
|
||||
mouseState: {
|
||||
x: NaN,
|
||||
y: NaN,
|
||||
visible: false
|
||||
},
|
||||
zone: null,
|
||||
zoneRect: null
|
||||
})
|
||||
|
||||
export const useD3MouseZone = () => useContext(D3MouseZoneContext)
|
||||
|
||||
export const D3MouseZone = memo<D3MouseZoneProps>(({ width, height, offset, children }) => {
|
||||
const zoneRef = useRef<SVGGElement>(null)
|
||||
const rectRef = useRef<SVGRectElement>(null)
|
||||
|
||||
const [state, setState] = useState<D3MouseState>({ 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 (
|
||||
<g ref={zoneRef} className={'asb-d3-mouse-zone'} transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<rect
|
||||
ref={rectRef}
|
||||
pointerEvents={'all'}
|
||||
className={'event-zone'}
|
||||
width={width - offset.left - offset.right}
|
||||
height={height - offset.top - offset.bottom}
|
||||
fill={'none'}
|
||||
stroke={'none'}
|
||||
onMouseMove={onMouse}
|
||||
onMouseOver={onMouse}
|
||||
onMouseOut={onMouseOut}
|
||||
/>
|
||||
<D3MouseZoneContext.Provider value={childContext}>
|
||||
{children}
|
||||
</D3MouseZoneContext.Provider>
|
||||
</g>
|
||||
)
|
||||
})
|
||||
|
||||
export default D3MouseZone
|
@ -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<HTMLDivElement> & {
|
||||
targets: Selection<any, any, null, undefined>
|
||||
onTargetHover?: (e: MouseEvent) => void
|
||||
onTargetOut?: (e: MouseEvent) => void
|
||||
width?: number
|
||||
height?: number
|
||||
content: (data: unknown, target: Selection<any, any, null, undefined>) => ReactNode
|
||||
}
|
||||
|
||||
export const D3Tooltip = memo<D3TooltipProps>(({ targets, width, height, content, onTargetHover, onTargetOut, className, style, ...other }) => {
|
||||
const [target, setTarget] = useState<Selection<any, any, null, undefined>>()
|
||||
const [position, setMousePosition] = useState<{ x: number, y: number }>({ x: 0, y: 0 })
|
||||
const [visible, setVisible] = useState<boolean>(false)
|
||||
|
||||
const tooltipRef = useRef<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={tooltipRef}
|
||||
className={`asb-d3-tooltip bottom ${className ?? ''}`}
|
||||
style={tooltipStyle}
|
||||
{...other}
|
||||
>
|
||||
{data}
|
||||
</div>
|
||||
)
|
||||
})
|
@ -1,3 +1,2 @@
|
||||
export * from './D3Tooltip'
|
||||
|
||||
export type { D3TooltipProps } from './D3Tooltip'
|
||||
export * from './D3Chart'
|
||||
export type { D3ChartProps, ChartAxis, ChartDataset, ChartDomain, ChartOffset } from './D3Chart'
|
||||
|
59
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
59
src/components/d3/plugins/D3ContextMenu.tsx
Normal file
@ -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<ReactElement | null, [SVGSVGElement | null]>
|
||||
downloadFilename?: string
|
||||
onUpdate?: () => void
|
||||
additionalMenuItems?: ItemType[]
|
||||
trigger?: ('click' | 'hover' | 'contextMenu')[]
|
||||
}
|
||||
|
||||
export type D3ContextMenuProps = BasePluginSettings & D3ContextMenuSettings & {
|
||||
children: any
|
||||
svg: SVGSVGElement | null
|
||||
}
|
||||
|
||||
export const D3ContextMenu = memo<D3ContextMenuProps>(({
|
||||
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 && (
|
||||
<a href={svgToDataURL(svg)} download={`${downloadFilename}.svg`}>Сохранить</a>
|
||||
)},
|
||||
]
|
||||
|
||||
if (additionalMenuItems)
|
||||
menuItems.push(...additionalMenuItems)
|
||||
|
||||
return menuItems
|
||||
}, [svg, downloadFilename, onUpdate, additionalMenuItems])
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlay={overlay(svg) || ( <Menu items={menuItems} /> )}
|
||||
disabled={!enabled}
|
||||
trigger={trigger}
|
||||
>
|
||||
{children}
|
||||
</Dropdown>
|
||||
)
|
||||
})
|
||||
|
||||
export default D3ContextMenu
|
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
82
src/components/d3/plugins/D3Cursor.tsx
Normal file
@ -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<SVGGElement>
|
||||
}
|
||||
|
||||
const defaultLineStyle: SVGProps<SVGGElement> = {
|
||||
stroke: 'gray',
|
||||
strokeWidth: 1,
|
||||
strokeOpacity: 1,
|
||||
className: '',
|
||||
}
|
||||
|
||||
const defaultSettings: D3CursorSettings = {
|
||||
lineStyle: defaultLineStyle,
|
||||
}
|
||||
|
||||
export type D3CursorProps = D3CursorSettings
|
||||
|
||||
export const D3Cursor = wrapPlugin<D3CursorSettings>(((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 (
|
||||
<g
|
||||
{...lineStyle}
|
||||
ref={zoneRef}
|
||||
className={`cursor-zone ${lineStyle.className}`}
|
||||
/>
|
||||
)
|
||||
}), true)
|
||||
|
||||
export default D3Cursor
|
38
src/components/d3/plugins/D3Tooltip.tsx
Normal file
38
src/components/d3/plugins/D3Tooltip.tsx
Normal file
@ -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<DataType> = {
|
||||
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<DataType> = BaseTooltip<DataType> & (AccurateTooltip | NearestTooltip)
|
||||
|
||||
export type D3TooltipProps<DataType = Record<string, any>> = Partial<D3TooltipSettings<DataType>> & {
|
||||
charts: any[],
|
||||
data: DataType[]
|
||||
}
|
||||
|
||||
export const D3Tooltip = wrapPlugin<D3TooltipProps>(({ type = 'accurate', width, height, render, data, charts }) => {
|
||||
const { mouseState, zoneRect } = useD3MouseZone()
|
||||
|
||||
return (
|
||||
<foreignObject>
|
||||
{mouseState.visible && render && render(data)}
|
||||
</foreignObject>
|
||||
)
|
||||
})
|
||||
|
||||
export default D3Tooltip
|
15
src/components/d3/plugins/base.tsx
Normal file
15
src/components/d3/plugins/base.tsx
Normal file
@ -0,0 +1,15 @@
|
||||
import { FC, memo } from 'react'
|
||||
|
||||
export type BasePluginSettings = {
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export const wrapPlugin = <S,>(Component: FC<S>, defaultEnabled?: boolean) => {
|
||||
const wrappedComponent = memo<S & BasePluginSettings>(({ enabled, ...props }) => {
|
||||
if (!(enabled ?? defaultEnabled)) return <></>
|
||||
|
||||
return <Component {...(props as S)} />
|
||||
})
|
||||
|
||||
return wrappedComponent
|
||||
}
|
4
src/components/d3/plugins/index.ts
Normal file
4
src/components/d3/plugins/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export * from './base'
|
||||
export * from './D3ContextMenu'
|
||||
export * from './D3Cursor'
|
||||
export * from './D3Tooltip'
|
@ -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 (
|
||||
<div className={'page-left'} ref={setRef}>
|
||||
<svg width={width ?? '100%'} height={height ?? '100%'}>
|
||||
<g ref={axisX} className={'axis x'} transform={`translate(${offset.left}, ${h - offset.bottom})`} />
|
||||
<g ref={axisY} className={'axis y'} transform={`translate(${offset.left}, ${offset.top})`} />
|
||||
<g transform={`translate(${offset.left}, ${offset.top})`}>
|
||||
<g ref={chartBars} stroke={bar.color} strokeWidth={bar.width} />
|
||||
<path ref={targetPath} stroke={target.color} strokeWidth={target.width} fill={'none'} />
|
||||
</g>
|
||||
</svg>
|
||||
<D3Tooltip
|
||||
targets={bars}
|
||||
onTargetHover={(e) => d3.select(e.target).attr('stroke', bar.hover)}
|
||||
onTargetOut={(e) => d3.select(e.target).attr('stroke', bar.color)}
|
||||
content={(v) => (
|
||||
<Grid>
|
||||
<GridItem row={1} col={1}>Дата:</GridItem>
|
||||
<GridItem row={1} col={2}>{formatDate(v.date)}</GridItem>
|
||||
<GridItem row={2} col={1}>Ключевой параметр:</GridItem>
|
||||
<GridItem row={2} col={2}>{(v.value || 0).toFixed(2)}</GridItem>
|
||||
<GridItem row={3} col={1}>Целевой параметр:</GridItem>
|
||||
<GridItem row={3} col={2}>{(v.target || 0).toFixed(2)}</GridItem>
|
||||
</Grid>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<D3Chart
|
||||
xAxis={xAxis}
|
||||
domain={domain}
|
||||
datasets={chartDatasets}
|
||||
data={data}
|
||||
loading={isChartLoading}
|
||||
height={height}
|
||||
plugins={{
|
||||
tooltip: {
|
||||
enabled: true,
|
||||
},
|
||||
cursor: {
|
||||
enabled: true,
|
||||
},
|
||||
menu: {
|
||||
enabled: true,
|
||||
onUpdate: onChartUpdate,
|
||||
}
|
||||
}}
|
||||
ticks={{ color: '#F9F2ED', y: { visible: true }, x: { visible: true } }}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
|
@ -129,13 +129,7 @@ const Operations = memo(() => {
|
||||
</div>
|
||||
<LoaderPortal show={isLoading}>
|
||||
<div className={'page-main'}>
|
||||
{data.operations ? (
|
||||
<OperationsChart data={data.operations} height={'50vh'} yDomain={yDomain} />
|
||||
) : (
|
||||
<div style={{ display: 'flex', height: '50vh', width: '100%', justifyContent: 'center', alignItems: 'center' }}>
|
||||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
</div>
|
||||
)}
|
||||
<OperationsChart data={data.operations} height={'50vh'} yDomain={yDomain} />
|
||||
<OperationsTable data={data.stats} height={'20vh'} />
|
||||
</div>
|
||||
</LoaderPortal>
|
||||
|
@ -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%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user