mirror of https://github.com/grafana/grafana
GraphNG: Using new VizLayout, moving Legend into GraphNG and some other refactorings (#28913)
* Graph refactorings * Move legend to GraphNG and use new VizLayout * Things are working * remove unused things * Update * Fixed ng test dashboard * Update * More refactoring * Removed plugin * Upgrade uplot * Auto size axis * Axis scaling * Fixed tests * updated * minor simplification * Fixed selection color * Fixed story * Minor story fix * Improve x-axis formatting * Tweaks * Update * Updated * Updates to handle timezone * Updated * Fixing types * Update * Fixed type * Updatedpull/28969/head
parent
76f4c11430
commit
71fffcb17c
@ -0,0 +1,53 @@ |
||||
import { FieldColorModeId, toDataFrame } from '@grafana/data'; |
||||
import React from 'react'; |
||||
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; |
||||
import { GraphNG } from './GraphNG'; |
||||
import { dateTime } from '@grafana/data'; |
||||
import { LegendDisplayMode } from '../Legend/Legend'; |
||||
import { prepDataForStorybook } from '../../utils/storybook/data'; |
||||
import { useTheme } from '../../themes'; |
||||
|
||||
export default { |
||||
title: 'Visualizations/GraphNG', |
||||
component: GraphNG, |
||||
decorators: [withCenteredStory], |
||||
parameters: { |
||||
docs: {}, |
||||
}, |
||||
}; |
||||
|
||||
export const Lines: React.FC = () => { |
||||
const theme = useTheme(); |
||||
const seriesA = toDataFrame({ |
||||
target: 'SeriesA', |
||||
datapoints: [ |
||||
[10, 1546372800000], |
||||
[20, 1546376400000], |
||||
[10, 1546380000000], |
||||
], |
||||
}); |
||||
|
||||
seriesA.fields[1].config.custom = { line: { show: true } }; |
||||
seriesA.fields[1].config.color = { mode: FieldColorModeId.PaletteClassic }; |
||||
seriesA.fields[1].config.unit = 'degree'; |
||||
|
||||
const data = prepDataForStorybook([seriesA], theme); |
||||
|
||||
return ( |
||||
<GraphNG |
||||
data={data} |
||||
width={600} |
||||
height={400} |
||||
timeRange={{ |
||||
from: dateTime(1546372800000), |
||||
to: dateTime(1546380000000), |
||||
raw: { |
||||
from: dateTime(1546372800000), |
||||
to: dateTime(1546380000000), |
||||
}, |
||||
}} |
||||
legend={{ isVisible: true, displayMode: LegendDisplayMode.List, placement: 'bottom' }} |
||||
timeZone="browser" |
||||
></GraphNG> |
||||
); |
||||
}; |
@ -1,16 +0,0 @@ |
||||
import React from 'react'; |
||||
import { usePlotContext } from './context'; |
||||
|
||||
interface CanvasProps { |
||||
width?: number; |
||||
height?: number; |
||||
} |
||||
|
||||
// Ref element to render the uPlot canvas to
|
||||
// This is a required child of Plot component!
|
||||
export const Canvas: React.FC<CanvasProps> = () => { |
||||
const plotCtx = usePlotContext(); |
||||
return <div ref={plotCtx.canvasRef} />; |
||||
}; |
||||
|
||||
Canvas.displayName = 'Canvas'; |
@ -1,2 +1,10 @@ |
||||
// importing the uPlot css so it will be bundled with the rest of the styling. |
||||
@import '../../node_modules/uplot/dist/uPlot.min.css'; |
||||
|
||||
.uplot { |
||||
font-family: inherit; |
||||
} |
||||
|
||||
.u-select { |
||||
background: rgba(120, 120, 130, 0.2); |
||||
} |
||||
|
@ -1,49 +0,0 @@ |
||||
import React from 'react'; |
||||
import { GraphCustomFieldConfig, GraphLegend, LegendDisplayMode, LegendItem } from '../..'; |
||||
import { usePlotData } from '../context'; |
||||
import { FieldType, getColorForTheme, getFieldDisplayName } from '@grafana/data'; |
||||
import { colors } from '../../../utils'; |
||||
import { useTheme } from '../../../themes'; |
||||
|
||||
export type LegendPlacement = 'top' | 'bottom' | 'left' | 'right'; |
||||
|
||||
interface LegendPluginProps { |
||||
placement: LegendPlacement; |
||||
displayMode?: LegendDisplayMode; |
||||
} |
||||
|
||||
export const LegendPlugin: React.FC<LegendPluginProps> = ({ placement, displayMode = LegendDisplayMode.List }) => { |
||||
const { data } = usePlotData(); |
||||
const theme = useTheme(); |
||||
|
||||
const legendItems: LegendItem[] = []; |
||||
|
||||
let seriesIdx = 0; |
||||
|
||||
for (let i = 0; i < data.fields.length; i++) { |
||||
const field = data.fields[i]; |
||||
|
||||
if (field.type === FieldType.time) { |
||||
continue; |
||||
} |
||||
legendItems.push({ |
||||
color: |
||||
field.config.color && field.config.color.fixedColor |
||||
? getColorForTheme(field.config.color.fixedColor, theme) |
||||
: colors[seriesIdx], |
||||
label: getFieldDisplayName(field, data), |
||||
isVisible: true, |
||||
//flot vs uPlot differences
|
||||
yAxis: (field.config.custom as GraphCustomFieldConfig)?.axis?.side === 1 ? 3 : 1, |
||||
}); |
||||
seriesIdx++; |
||||
} |
||||
|
||||
return ( |
||||
<GraphLegend |
||||
placement={placement === 'top' || placement === 'bottom' ? 'under' : 'right'} |
||||
items={legendItems} |
||||
displayMode={displayMode} |
||||
/> |
||||
); |
||||
}; |
@ -0,0 +1,14 @@ |
||||
import { applyFieldOverrides, DataFrame, GrafanaTheme } from '@grafana/data'; |
||||
|
||||
export function prepDataForStorybook(data: DataFrame[], theme: GrafanaTheme) { |
||||
return applyFieldOverrides({ |
||||
data: data, |
||||
fieldConfig: { |
||||
overrides: [], |
||||
defaults: {}, |
||||
}, |
||||
theme, |
||||
replaceVariables: (value: string) => value, |
||||
getDataSourceSettingsByUid: (value: string) => ({} as any), |
||||
}); |
||||
} |
@ -1,221 +0,0 @@ |
||||
import React, { useCallback, useLayoutEffect, useMemo, useState } from 'react'; |
||||
import { css } from 'emotion'; |
||||
import { useMeasure } from './useMeasure'; |
||||
import { LayoutBuilder, LayoutRendererComponent } from './LayoutBuilder'; |
||||
import { CustomScrollbar } from '@grafana/ui'; |
||||
|
||||
type UseMeasureRect = Pick<DOMRectReadOnly, 'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width'>; |
||||
|
||||
const RESET_DIMENSIONS: UseMeasureRect = { |
||||
x: 0, |
||||
y: 0, |
||||
height: 0, |
||||
width: 0, |
||||
top: 0, |
||||
bottom: 0, |
||||
left: 0, |
||||
right: 0, |
||||
}; |
||||
|
||||
const DEFAULT_VIZ_LAYOUT_STATE = { |
||||
isReady: false, |
||||
top: RESET_DIMENSIONS, |
||||
bottom: RESET_DIMENSIONS, |
||||
right: RESET_DIMENSIONS, |
||||
left: RESET_DIMENSIONS, |
||||
canvas: RESET_DIMENSIONS, |
||||
}; |
||||
|
||||
export type VizLayoutSlots = 'top' | 'bottom' | 'left' | 'right' | 'canvas'; |
||||
|
||||
export interface VizLayoutState extends Record<VizLayoutSlots, UseMeasureRect> { |
||||
isReady: boolean; |
||||
} |
||||
|
||||
interface VizLayoutAPI { |
||||
builder: LayoutBuilder<VizLayoutSlots>; |
||||
getLayout: () => VizLayoutState; |
||||
} |
||||
|
||||
interface VizLayoutProps { |
||||
width: number; |
||||
height: number; |
||||
children: (api: VizLayoutAPI) => React.ReactNode; |
||||
} |
||||
|
||||
/** |
||||
* Graph viz layout. Consists of 5 slots: top(T), bottom(B), left(L), right(R), canvas: |
||||
* |
||||
* +-----------------------------------------------+ |
||||
* | T | |
||||
* ----|---------------------------------------|---- |
||||
* | | | | |
||||
* | | | | |
||||
* | L | CANVAS SLOT | R | |
||||
* | | | | |
||||
* | | | | |
||||
* ----|---------------------------------------|---- |
||||
* | B | |
||||
* +-----------------------------------------------+ |
||||
* |
||||
*/ |
||||
const VizLayoutRenderer: LayoutRendererComponent<VizLayoutSlots> = ({ slots, refs, width, height }) => { |
||||
return ( |
||||
<div |
||||
className={css` |
||||
height: ${height}px; |
||||
width: ${width}px; |
||||
display: flex; |
||||
flex-grow: 1; |
||||
flex-direction: column; |
||||
`}
|
||||
> |
||||
{slots.top && ( |
||||
<div |
||||
ref={refs.top} |
||||
className={css` |
||||
width: 100%; |
||||
max-height: 35%; |
||||
align-self: top; |
||||
`}
|
||||
> |
||||
<CustomScrollbar>{slots.top}</CustomScrollbar> |
||||
</div> |
||||
)} |
||||
|
||||
{(slots.left || slots.right || slots.canvas) && ( |
||||
<div |
||||
className={css` |
||||
label: INNER; |
||||
display: flex; |
||||
flex-direction: row; |
||||
width: 100%; |
||||
height: 100%; |
||||
`}
|
||||
> |
||||
{slots.left && ( |
||||
<div |
||||
ref={refs.left} |
||||
className={css` |
||||
max-height: 100%; |
||||
`}
|
||||
> |
||||
<CustomScrollbar>{slots.left}</CustomScrollbar> |
||||
</div> |
||||
)} |
||||
{slots.canvas && <div>{slots.canvas}</div>} |
||||
{slots.right && ( |
||||
<div |
||||
ref={refs.right} |
||||
className={css` |
||||
max-height: 100%; |
||||
`}
|
||||
> |
||||
<CustomScrollbar>{slots.right}</CustomScrollbar> |
||||
</div> |
||||
)} |
||||
</div> |
||||
)} |
||||
{slots.bottom && ( |
||||
<div |
||||
ref={refs.bottom} |
||||
className={css` |
||||
width: 100%; |
||||
max-height: 35%; |
||||
`}
|
||||
> |
||||
<CustomScrollbar>{slots.bottom}</CustomScrollbar> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
export const VizLayout: React.FC<VizLayoutProps> = ({ children, width, height }) => { |
||||
/** |
||||
* Layout slots refs & bboxes |
||||
* Refs are passed down to the renderer component by layout builder |
||||
* It's up to the renderer to assign refs to correct slots(which are underlying DOM elements) |
||||
* */ |
||||
const [bottomSlotRef, bottomSlotBBox] = useMeasure(); |
||||
const [topSlotRef, topSlotBBox] = useMeasure(); |
||||
const [leftSlotRef, leftSlotBBox] = useMeasure(); |
||||
const [rightSlotRef, rightSlotBBox] = useMeasure(); |
||||
const [canvasSlotRef, canvasSlotBBox] = useMeasure(); |
||||
|
||||
// public fluent API exposed via render prop to build the layout
|
||||
const builder = useMemo( |
||||
() => |
||||
new LayoutBuilder( |
||||
VizLayoutRenderer, |
||||
{ |
||||
top: topSlotRef, |
||||
bottom: bottomSlotRef, |
||||
left: leftSlotRef, |
||||
right: rightSlotRef, |
||||
canvas: canvasSlotRef, |
||||
}, |
||||
width, |
||||
height |
||||
), |
||||
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height] |
||||
); |
||||
|
||||
// memoized map of layout slot bboxes, used for exposing correct bboxes when the layout is ready
|
||||
const bboxMap = useMemo( |
||||
() => ({ |
||||
top: topSlotBBox, |
||||
bottom: bottomSlotBBox, |
||||
left: leftSlotBBox, |
||||
right: rightSlotBBox, |
||||
canvas: canvasSlotBBox, |
||||
}), |
||||
[bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox] |
||||
); |
||||
|
||||
const [dimensions, setDimensions] = useState<VizLayoutState>(DEFAULT_VIZ_LAYOUT_STATE); |
||||
|
||||
// when DOM settles we set the layout to be ready to get measurements downstream
|
||||
useLayoutEffect(() => { |
||||
// layout is ready by now
|
||||
const currentLayout = builder.getLayout(); |
||||
|
||||
// map active layout slots to corresponding bboxes
|
||||
let nextDimensions: Partial<Record<VizLayoutSlots, UseMeasureRect>> = {}; |
||||
for (const key of Object.keys(currentLayout)) { |
||||
nextDimensions[key as VizLayoutSlots] = bboxMap[key as VizLayoutSlots]; |
||||
} |
||||
|
||||
const nextState = { |
||||
// first, reset all bboxes to defaults
|
||||
...DEFAULT_VIZ_LAYOUT_STATE, |
||||
// set layout to ready
|
||||
isReady: true, |
||||
// update state with active slot bboxes
|
||||
...nextDimensions, |
||||
}; |
||||
|
||||
setDimensions(nextState); |
||||
}, [bottomSlotBBox, topSlotBBox, leftSlotBBox, rightSlotBBox, width, height]); |
||||
|
||||
// returns current state of the layout, bounding rects of all slots to be rendered
|
||||
const getLayout = useCallback(() => { |
||||
return dimensions; |
||||
}, [dimensions]); |
||||
|
||||
return ( |
||||
<div |
||||
className={css` |
||||
label: PanelVizLayout; |
||||
width: ${width}px; |
||||
height: ${height}px; |
||||
overflow: hidden; |
||||
`}
|
||||
> |
||||
{children({ |
||||
builder: builder, |
||||
getLayout, |
||||
})} |
||||
</div> |
||||
); |
||||
}; |
@ -1,49 +0,0 @@ |
||||
import { useState, useMemo } from 'react'; |
||||
import { useIsomorphicLayoutEffect } from 'react-use'; |
||||
|
||||
export type UseMeasureRect = Pick< |
||||
DOMRectReadOnly, |
||||
'x' | 'y' | 'top' | 'left' | 'right' | 'bottom' | 'height' | 'width' |
||||
>; |
||||
export type UseMeasureRef<E extends HTMLElement = HTMLElement> = (element: E) => void; |
||||
export type UseMeasureResult<E extends HTMLElement = HTMLElement> = [UseMeasureRef<E>, UseMeasureRect]; |
||||
|
||||
const defaultState: UseMeasureRect = { |
||||
x: 0, |
||||
y: 0, |
||||
width: 0, |
||||
height: 0, |
||||
top: 0, |
||||
left: 0, |
||||
bottom: 0, |
||||
right: 0, |
||||
}; |
||||
|
||||
export const useMeasure = <E extends HTMLElement = HTMLElement>(): UseMeasureResult<E> => { |
||||
const [element, ref] = useState<E | null>(null); |
||||
const [rect, setRect] = useState<UseMeasureRect>(defaultState); |
||||
|
||||
const observer = useMemo( |
||||
() => |
||||
new (window as any).ResizeObserver((entries: any) => { |
||||
if (entries[0]) { |
||||
const { x, y, width, height, top, left, bottom, right } = entries[0].contentRect; |
||||
setRect({ x, y, width, height, top, left, bottom, right }); |
||||
} |
||||
}), |
||||
[] |
||||
); |
||||
|
||||
useIsomorphicLayoutEffect(() => { |
||||
if (!element) { |
||||
setRect(defaultState); |
||||
return; |
||||
} |
||||
observer.observe(element); |
||||
return () => { |
||||
observer.disconnect(); |
||||
}; |
||||
}, [element]); |
||||
|
||||
return [ref, rect]; |
||||
}; |
Loading…
Reference in new issue