Commit 22c0157d authored by Vasily Belolapotkov's avatar Vasily Belolapotkov

add plot inspector

parent d745a6d3
......@@ -33,8 +33,24 @@ export interface IChartState {
yScale: number;
yOffset: number;
yLabels: number[];
inspectorIsActive: boolean;
inspectorValues: IInspectorValues;
setXViewRange: (xScale: number, xOffset: number) => void;
toggleDataSetVisibility: (dataSet: IDataSet) => void;
setInspectorVisibility: (isActive: boolean) => void;
moveInspector: (relativeXOffset: number) => void;
}
export interface IInspectorValues {
xValue: Timestamp;
yValues: IInspectorValue[];
}
export interface IInspectorValue {
name: string;
color: string;
value: number;
isVisible: boolean;
}
export function makeChartState(chartData: IChartData): IChartState {
......@@ -44,6 +60,8 @@ export function makeChartState(chartData: IChartData): IChartState {
const yTotalRange = getYTotalRange();
const yLabels = getYLabels(yTotalRange);
let inspectorIndex = 0;
const state = {
xAxis,
dataSets,
......@@ -52,6 +70,10 @@ export function makeChartState(chartData: IChartData): IChartState {
yLabels,
setXViewRange,
toggleDataSetVisibility,
setInspectorVisibility,
moveInspector,
inspectorIsActive: false,
inspectorValues: getInspectorValues(),
xScale: 1,
yScale: 1,
xOffset: 0,
......@@ -171,6 +193,32 @@ export function makeChartState(chartData: IChartData): IChartState {
state.yOffset = yViewRange.start;
state.yLabels = getYLabels(yViewRange);
}
function setInspectorVisibility(isActive: boolean) {
state.inspectorIsActive = isActive;
}
function moveInspector(relativeXOffset: number) {
const maxIndex = xAxis.length - 1;
inspectorIndex = Math.round(
state.xOffset * maxIndex + relativeXOffset * maxIndex * state.xScale,
);
inspectorIndex = Math.max(0, inspectorIndex);
inspectorIndex = Math.min(inspectorIndex, xAxis.length - 1);
state.inspectorValues = getInspectorValues();
}
function getInspectorValues(): IInspectorValues {
const xValue = xAxis[inspectorIndex];
const yValues: IInspectorValue[] = dataSets.map(dataSet => ({
name: dataSet.name,
color: dataSet.color,
value: dataSet.values[inspectorIndex],
isVisible: dataSet.isVisible,
}));
return { xValue, yValues };
}
}
export type ChartEventListener = (chartState: IChartState) => void;
......@@ -181,6 +229,9 @@ export interface IChartStateController {
toggleDataSetVisibility: (dataSet: IDataSet) => void;
addListener: (eventName: string, listener: ChartEventListener) => void;
removeListener: (eventName: string, listener: ChartEventListener) => void;
showInspector: () => void;
hideInspector: () => void;
moveInspector: (xOffset: number) => void;
}
export function makeChartStateController(
......@@ -195,6 +246,9 @@ export function makeChartStateController(
toggleDataSetVisibility,
addListener,
removeListener,
showInspector,
hideInspector,
moveInspector,
});
function getState() {
......@@ -247,4 +301,19 @@ export function makeChartStateController(
fireEvent('yViewRangeChanged');
fireEvent('dataSetVisibilityChanged');
}
function showInspector() {
state.setInspectorVisibility(true);
fireEvent('inspectorVisibilityChanged');
}
function hideInspector() {
state.setInspectorVisibility(false);
fireEvent('inspectorVisibilityChanged');
}
function moveInspector(xOffset: number) {
state.moveInspector(xOffset);
fireEvent('moveInspector');
}
}
$bodyBgLight: #fff;
$grey: rgb(165, 175, 182);
$lightgrey: rgb(221, 234,242);
$fontMain: rgb(67,72,75);
$lightgrey: rgb(221, 234, 242);
$fontMain: rgb(67, 72, 75);
body {
font-family: Helvetica, sans-serif;
......@@ -164,8 +165,8 @@ body {
left: $controlVPadding - $controlBorderR;
border: 1px solid $grey;
color: #fff;
background-color: #fff;
color: $bodyBgLight;
background-color: $bodyBgLight;
transition: background-color 0.3s ease;
}
......@@ -186,3 +187,49 @@ body {
line-height: 1rem;
padding: 4px;
}
.plots-inspector {
.inspector-line {
shape-rendering: crispEdges;
stroke: $lightgrey;
}
.inspector-point {
stroke-width: 2px;
fill: $bodyBgLight;
}
}
.inspector-tooltip {
box-sizing: border-box;
white-space: nowrap;
position: absolute;
top: 1px;
margin: 1px;
background: $bodyBgLight;
padding: 8px 12px;
border-radius: 8px;
box-shadow: 0 0 1px 0 $grey;
.values {
white-space: nowrap;
margin: 0.5rem 0;
.set {
display: inline-block;
margin-right: 1rem;
&:last-child {
margin-right: 0;
}
&.hidden {
display: none;
}
.name {
font-size: 0.8rem;
font-weight: 100;
}
}
}
}
import { ChartComponentFactory, DOMContainerElement } from './chart-component';
import { renderPlots } from './plot';
import { ISize, renderPlots } from './plot';
import { makeHtmlElement } from './ui-utils';
interface IPlotAreaProps {
......@@ -11,6 +11,8 @@ export const makePlotArea: ChartComponentFactory<IPlotAreaProps> = function(
chartStateController,
) {
const stateController = chartStateController;
let plotSize: ISize;
return {
render,
};
......@@ -18,13 +20,16 @@ export const makePlotArea: ChartComponentFactory<IPlotAreaProps> = function(
function render(container: DOMContainerElement, props: IPlotAreaProps): void {
const { plotWidth: width, plotHeight: height } = props;
const plotAreaContainer = makePlotAreaContainer();
const plotSize = { width, height };
plotSize = { width, height };
renderPlots(plotAreaContainer, stateController, {
plotSize,
zoom: { x: true, y: true },
axes: { x: true, y: true },
enableInspector: true,
});
container.appendChild(plotAreaContainer);
attachInspectorHandlers(plotAreaContainer);
}
function makePlotAreaContainer(): HTMLElement {
......@@ -32,4 +37,23 @@ export const makePlotArea: ChartComponentFactory<IPlotAreaProps> = function(
classList: ['plot-area-container'],
});
}
function attachInspectorHandlers(container: HTMLElement) {
container.addEventListener('mouseenter', handleMouseEnter);
container.addEventListener('mouseleave', handleMouseLeave);
container.addEventListener('mousemove', handleMouseMove);
}
function handleMouseEnter() {
stateController.showInspector();
}
function handleMouseLeave() {
stateController.hideInspector();
}
function handleMouseMove(evt: MouseEvent) {
const xOffset = evt.offsetX / plotSize.width;
stateController.moveInspector(xOffset);
}
};
......@@ -14,6 +14,7 @@ import {
} from './chart-state';
import { makeYAxis } from './y-axis';
import { makeXAxis } from './x-axis';
import { makePlotsInspector } from './plots-inspector';
interface IPlotContainerAttributes extends IAttributesMap {
width: string;
......@@ -32,14 +33,16 @@ interface IPlotsProps {
}
export interface IPlotContainer extends IRenderable<ISize> {
renderPlots: (xAxis: Timestamp[], dataSets: IDataSet[]) => void;
renderPlots: () => void;
renderAxes: () => void;
renderInspector: () => void;
}
export interface IPlotConfig {
export interface IPlotsConfig {
plotSize: ISize;
zoom?: IZoomConfig;
axes?: IAxesConfig;
enableInspector?: boolean;
}
export interface ISize {
......@@ -65,23 +68,26 @@ export interface ICoordinates {
export function renderPlots(
htmlContainer: HTMLElement,
chartStateController: IChartStateController,
config: IPlotConfig,
config: IPlotsConfig,
): IPlotContainer {
const chartState = chartStateController.getState();
// TODO: make plots renderer composable from different components
const plotContainer = makePlotContainer(chartStateController, config);
const { plotSize } = config;
plotContainer.render(htmlContainer, plotSize);
plotContainer.renderAxes();
plotContainer.renderPlots(chartState.xAxis, chartState.dataSets);
plotContainer.renderPlots();
if (config.enableInspector) {
plotContainer.renderInspector();
}
return plotContainer;
}
function makePlotContainer(
chartStateController: IChartStateController,
config: IPlotConfig,
config: IPlotsConfig,
): IPlotContainer {
const { zoom: zoomConfig, axes: axesConfig } = config;
const stateController = chartStateController;
......@@ -93,14 +99,17 @@ function makePlotContainer(
let width = 0;
let height = 0;
let svg: SVGElement;
let htmlContainer: HTMLElement;
return Object.freeze({
render,
renderPlots,
renderAxes,
renderPlots,
renderInspector,
});
function render(container: DOMContainerElement, plotSize: ISize): void {
function render(container: HTMLElement, plotSize: ISize): void {
htmlContainer = container;
width = plotSize.width;
height = plotSize.height;
......@@ -112,21 +121,6 @@ function makePlotContainer(
container.appendChild(svg);
}
function renderPlots(xAxis: Timestamp[], dataSets: IDataSet[]) {
const plots = makePlots(stateController);
const plotSize = getPlotsSize();
plots.render(svg, { plotSize, zoomConfig: plotZoomConfig });
plots.renderDataSets(xAxis, dataSets);
}
function getPlotsSize(): ISize {
const plotsHeight = plotAxesConfig.x ? height - xAxisHeight : height;
return {
width,
height: plotsHeight,
};
}
function renderAxes() {
if (!plotAxesConfig.x && !plotAxesConfig.y) {
return;
......@@ -149,6 +143,20 @@ function makePlotContainer(
}
}
function renderPlots() {
const { xAxis, dataSets } = stateController.getState();
const plots = makePlots(stateController);
const plotSize = getPlotsSize();
plots.render(svg, { plotSize, zoomConfig: plotZoomConfig });
plots.renderDataSets(xAxis, dataSets);
}
function renderInspector() {
const plotsInspector = makePlotsInspector(stateController);
const plotSize = getPlotsSize();
plotsInspector.render(svg, { plotSize, tooltipContainer: htmlContainer });
}
function getAttributes(): IPlotContainerAttributes {
const plotSize = getPlotsSize();
......@@ -159,6 +167,14 @@ function makePlotContainer(
viewBox: `0 ${-plotSize.height} ${width} ${height}`,
};
}
function getPlotsSize(): ISize {
const plotsHeight = plotAxesConfig.x ? height - xAxisHeight : height;
return {
width,
height: plotsHeight,
};
}
}
function makePlots(chartStateController: IChartStateController): IPlots {
......
import { IChartStateController, IInspectorValue } from './chart-state';
import { DOMContainerElement, IRenderable } from './chart-component';
import { ISize } from './plot';
import {
applyElementConfig,
IAttributesMap,
makeHtmlElement,
makeSvgElement,
} from './ui-utils';
interface IPlotsInspectorProps {
plotSize: ISize;
tooltipContainer: HTMLElement;
}
export function makePlotsInspector(
chartStateController: IChartStateController,
): IRenderable<IPlotsInspectorProps> {
const stateController = chartStateController;
const svgPointRadius = '5';
let svgInspector: SVGElement;
let svgInspectorLine: SVGElement;
const svgPoints: SVGElement[] = [];
let tooltipContainer: HTMLElement;
let tooltipElement: HTMLElement;
let plotSize: ISize;
let xCoordsScale: number;
let yCoordsScale: number;
let inspectorXCoordinate: number;
return Object.freeze({
render,
});
function render(container: DOMContainerElement, props: IPlotsInspectorProps) {
initInspectorProps(props);
renderInspector(container);
attachEventHandlers();
}
function initInspectorProps(props: IPlotsInspectorProps) {
plotSize = props.plotSize;
tooltipContainer = props.tooltipContainer;
const { xTotalRange, yTotalRange } = stateController.getState();
xCoordsScale = plotSize.width / xTotalRange.distance;
yCoordsScale = plotSize.height / yTotalRange.distance;
updateInspectorXCoordinate();
}
function renderInspector(container: DOMContainerElement) {
renderSvgInspector(container);
renderSvgInspectorLine();
renderSvgInspectorPoints();
renderSvgInspectorTooltip();
}
function renderSvgInspector(container: DOMContainerElement) {
svgInspector = makeSvgElement('g', {
classList: ['plots-inspector'],
style: { opacity: getInspectorOpacity() },
attributes: {
width: `${plotSize.width}`,
height: `${plotSize.height}`,
},
});
container.appendChild(svgInspector);
}
function renderSvgInspectorLine() {
svgInspectorLine = makeSvgElement('line', {
classList: ['inspector-line'],
attributes: getInspectorLineAttributes(),
});
svgInspector.appendChild(svgInspectorLine);
}
function renderSvgInspectorPoints() {
const {
inspectorValues: { yValues },
} = stateController.getState();
yValues.forEach(yValue => {
const svgPoint = makeSvgElement('circle', {
classList: ['inspector-point'],
attributes: getSvgPointAttributes(yValue),
style: getSvgPointStyle(yValue),
});
svgPoints.push(svgPoint);
svgInspector.appendChild(svgPoint);
});
}
function renderSvgInspectorTooltip() {
tooltipElement = makeHtmlElement('div', {
classList: ['inspector-tooltip'],
});
updateTooltip();
tooltipContainer.appendChild(tooltipElement);
}
function getInspectorOpacity() {
const { inspectorIsActive } = stateController.getState();
return inspectorIsActive ? '1' : '0';
}
function updateInspectorXCoordinate() {
const {
inspectorValues,
xScale,
xOffset,
xTotalRange,
} = stateController.getState();
const xOffsetValue = xTotalRange.start + xOffset * xTotalRange.distance;
// TODO: consider moving formula into utils
inspectorXCoordinate =
((inspectorValues.xValue - xOffsetValue) * xCoordsScale) / xScale;
}
function getInspectorLineAttributes(): IAttributesMap {
return {
x1: `${inspectorXCoordinate}`,
x2: `${inspectorXCoordinate}`,
y1: '0',
y2: `${-plotSize.height}`,
};
}
function getSvgPointAttributes(yValue: IInspectorValue): IAttributesMap {
const { yOffset, yScale } = stateController.getState();
// TODO: consider moving formula into utils
const yCoordinate = ((yValue.value - yOffset) * yCoordsScale) / yScale;
return {
cx: `${inspectorXCoordinate}`,
cy: `${-yCoordinate}`,
r: svgPointRadius,
stroke: yValue.color,
'data-name': yValue.name,
};
}
function getSvgPointStyle(yValue: IInspectorValue): IAttributesMap {
return { opacity: yValue.isVisible ? '1' : '0' };
}
function attachEventHandlers() {
stateController.addListener(
'inspectorVisibilityChanged',
updateInspectorVisibility,
);
stateController.addListener('moveInspector', updateInspector);
}
function updateInspectorVisibility() {
updateInspectorLineVisibility();
updateTooltip();
}
function updateInspectorLineVisibility() {
applyElementConfig(svgInspector, {
style: { opacity: getInspectorOpacity() },
});
}
function updateInspector() {
updateInspectorXCoordinate();
updateInspectorLine();
updateInspectorPoints();
updateTooltip();
}
function updateInspectorLine() {
applyElementConfig(svgInspectorLine, {
attributes: getInspectorLineAttributes(),
});
}
function updateInspectorPoints() {
const { inspectorValues } = stateController.getState();
inspectorValues.yValues.forEach((yValue, index) =>
applyElementConfig(svgPoints[index], {
attributes: getSvgPointAttributes(yValue),
style: getSvgPointStyle(yValue),
}),
);
}
function updateTooltip() {
updateTooltipStyle();
updateTooltipValues();
}
function updateTooltipStyle() {
const style = getTooltipStyle();
applyElementConfig(tooltipElement, { style });
}
function getTooltipStyle() {
const { inspectorIsActive } = stateController.getState();
if (!inspectorIsActive) {
return { opacity: '0', left: '0' };
}
const { offsetWidth: tooltipWidth } = tooltipElement;
const tooltipShift = 20;
const minLeft = 2;
let tooltipLeft = Math.max(
Math.floor(inspectorXCoordinate - tooltipShift),
minLeft,
);
if (tooltipLeft + tooltipWidth >= plotSize.width) {
tooltipLeft = Math.floor(plotSize.width - minLeft - tooltipWidth);
}
return {
left: `${tooltipLeft}px`,
opacity: '1',
};
}
function updateTooltipValues() {
const {
inspectorValues: { xValue, yValues },
} = stateController.getState();
const formattedDate = new Date(xValue).toLocaleString('en-US', {
month: 'short',
day: '2-digit',
weekday: 'short',
});
tooltipElement.innerHTML = `