import React from 'react'; import { withRouter, Link } from 'react-router-dom' import Chart from 'chart.js/auto'; import { navigateToQuery } from '../../query' import numberFormatter, {durationFormatter} from '../../util/number-formatter' import * as api from '../../api' import * as storage from '../../util/storage' import LazyLoader from '../../components/lazy-loader' import {GraphTooltip, buildDataSet, dateFormatter} from './graph-util'; import TopStats from './top-stats'; import * as url from '../../util/url' export const METRIC_MAPPING = { 'Unique visitors (last 30 min)': 'visitors', 'Pageviews (last 30 min)': 'pageviews', 'Unique visitors': 'visitors', 'Visit duration': 'visit_duration', 'Total pageviews': 'pageviews', 'Bounce rate': 'bounce_rate', 'Unique conversions': 'conversions', // 'Time on Page': 'time', // 'Conversion rate': 'conversion_rate', // 'Total conversions': 't_conversions', } export const METRIC_LABELS = { 'visitors': 'Visitors', 'pageviews': 'Pageviews', 'bounce_rate': 'Bounce Rate', 'visit_duration': 'Visit Duration', 'conversions': 'Converted Visitors', // 'time': 'Time on Page', // 'conversion_rate': 'Conversion Rate', // 't_conversions': 'Total Conversions' } export const METRIC_FORMATTER = { 'visitors': numberFormatter, 'pageviews': numberFormatter, 'bounce_rate': (number) => (`${number}%`), 'visit_duration': durationFormatter, 'conversions': numberFormatter, // 'time': durationFormatter, // 'conversion_rate': (number) => (`${Math.max(number, 100)}%`), // 't_conversions': numberFormatter } class LineGraph extends React.Component { constructor(props) { super(props); this.regenerateChart = this.regenerateChart.bind(this); this.updateWindowDimensions = this.updateWindowDimensions.bind(this); this.state = { exported: false }; } regenerateChart() { const { graphData, metric } = this.props const graphEl = document.getElementById("main-graph-canvas") this.ctx = graphEl.getContext('2d'); const dataSet = buildDataSet(graphData.plot, graphData.present_index, this.ctx, METRIC_LABELS[metric]) // const prev_dataSet = graphData.prev_plot && buildDataSet(graphData.prev_plot, false, this.ctx, METRIC_LABELS[metric], true) // const combinedDataSets = comparison.enabled && prev_dataSet ? [...dataSet, ...prev_dataSet] : dataSet; return new Chart(this.ctx, { type: 'line', data: { labels: graphData.labels, datasets: dataSet }, options: { animation: false, plugins: { legend: { display: false }, tooltip: { enabled: false, mode: 'index', intersect: false, position: 'average', external: GraphTooltip(graphData, metric) }, }, responsive: true, onResize: this.updateWindowDimensions, elements: { line: { tension: 0 }, point: { radius: 0 } }, onClick: this.onClick.bind(this), scales: { y: { beginAtZero: true, ticks: { callback: METRIC_FORMATTER[metric], maxTicksLimit: 8, color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined }, grid: { zeroLineColor: 'transparent', drawBorder: false, } }, x: { grid: { display: false }, ticks: { maxTicksLimit: 8, callback: function(val, _index, _ticks) { return dateFormatter(graphData.interval)(this.getLabelForValue(val)) }, color: this.props.darkTheme ? 'rgb(243, 244, 246)' : undefined } } }, interaction: { mode: 'index', intersect: false, } } }); } repositionTooltip(e) { const tooltipEl = document.getElementById('chartjs-tooltip'); if (tooltipEl && window.innerWidth >= 768) { if (e.clientX > 0.66 * window.innerWidth) { tooltipEl.style.right = (window.innerWidth - e.clientX) + window.pageXOffset + 'px' tooltipEl.style.left = null; } else { tooltipEl.style.right = null; tooltipEl.style.left = e.clientX + window.pageXOffset + 'px' } tooltipEl.style.top = e.clientY + window.pageYOffset + 'px' tooltipEl.style.opacity = 1; } } componentDidMount() { if (this.props.metric && this.props.graphData) { this.chart = this.regenerateChart(); } window.addEventListener('mousemove', this.repositionTooltip); } componentDidUpdate(prevProps) { const { graphData, metric, darkTheme } = this.props; const tooltip = document.getElementById('chartjs-tooltip'); if (metric && graphData && ( graphData !== prevProps.graphData || darkTheme !== prevProps.darkTheme )) { if (this.chart) { this.chart.destroy(); } this.chart = this.regenerateChart(); this.chart.update(); if (tooltip) { tooltip.style.display = 'none'; } } if (!graphData || !metric) { if (this.chart) { this.chart.destroy(); } if (tooltip) { tooltip.style.display = 'none'; } } } componentWillUnmount() { // Ensure that the tooltip doesn't hang around when we are loading more data const tooltip = document.getElementById('chartjs-tooltip'); if (tooltip) { tooltip.style.opacity = 0; tooltip.style.display = 'none'; } window.removeEventListener('mousemove', this.repositionTooltip) } /** * The current ticks' limits are set to treat iPad (regular/Mini/Pro) as a regular screen. * @param {*} chart - The chart instance. * @param {*} dimensions - An object containing the new dimensions *of the chart.* */ updateWindowDimensions(chart, dimensions) { chart.options.scales.x.ticks.maxTicksLimit = dimensions.width < 720 ? 5 : 8 chart.options.scales.y.ticks.maxTicksLimit = dimensions.height < 233 ? 3 : 8 } onClick(e) { const element = this.chart.getElementsAtEventForMode(e, 'index', { intersect: false })[0] const date = this.chart.data.labels[element.index] if (this.props.graphData.interval === 'month') { navigateToQuery( this.props.history, this.props.query, { period: 'month', date, } ) } else if (this.props.graphData.interval === 'date') { navigateToQuery( this.props.history, this.props.query, { period: 'day', date, } ) } } pollExportReady() { if (document.cookie.includes('exporting')) { setTimeout(this.pollExportReady.bind(this), 1000); } else { this.setState({exported: false}) } } downloadSpinner() { this.setState({exported: true}); document.cookie = "exporting="; setTimeout(this.pollExportReady.bind(this), 1000); } downloadLink() { if (this.props.query.period !== 'realtime') { if (this.state.exported) { return (
) } else { const endpoint = `/${encodeURIComponent(this.props.site.domain)}/export${api.serializeQuery(this.props.query)}` return ( ) } } } samplingNotice() { const samplePercent = this.props.topStatData && this.props.topStatData.sample_percent if (samplePercent < 100) { return (
) } } importedNotice() { const source = this.props.topStatData.imported_source; if (source) { const withImported = this.props.topStatData.with_imported; const strike = withImported ? "" : " line-through" const target = url.setQuery('with_imported', !withImported) const tip = withImported ? "" : "do not "; return (
{ source[0].toUpperCase() }
) } } render() { const { updateMetric, metric, topStatData, query } = this.props const extraClass = this.props.graphData && this.props.graphData.interval === 'hour' ? '' : 'cursor-pointer' return (
{this.downloadLink()} {this.samplingNotice()} { this.importedNotice() }
) } } const LineGraphWithRouter = withRouter(LineGraph) export default class VisitorGraph extends React.Component { constructor(props) { super(props) this.state = { loading: 2, metric: storage.getItem(`metric__${this.props.site.domain}`) || 'visitors' } this.onVisible = this.onVisible.bind(this) this.updateMetric = this.updateMetric.bind(this) this.fetchTopStatData = this.fetchTopStatData.bind(this) this.fetchGraphData = this.fetchGraphData.bind(this) } onVisible() { this.fetchGraphData() this.fetchTopStatData() if (this.props.timer) { this.props.timer.onTick(this.fetchGraphData) this.props.timer.onTick(this.fetchTopStatData) } } componentDidUpdate(prevProps, prevState) { const { metric, topStatData } = this.state; if (this.props.query !== prevProps.query) { this.setState({ loading: 3, graphData: null, topStatData: null }) this.fetchGraphData() this.fetchTopStatData() } if (metric !== prevState.metric) { this.setState({loading: 1, graphData: null}) this.fetchGraphData() } const savedMetric = storage.getItem(`metric__${this.props.site.domain}`) const topStatLabels = topStatData && topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name) const prevTopStatLabels = prevState.topStatData && prevState.topStatData.top_stats.map(({ name }) => METRIC_MAPPING[name]).filter(name => name) if (topStatLabels && `${topStatLabels}` !== `${prevTopStatLabels}`) { if (!topStatLabels.includes(savedMetric) && savedMetric !== "") { if (this.props.query.filters.goal && metric !== 'conversions') { this.setState({ metric: 'conversions' }) } else { this.setState({ metric: topStatLabels[0] }) } } else { this.setState({ metric: savedMetric }) } } } updateMetric(newMetric) { if (newMetric === this.state.metric) { storage.setItem(`metric__${this.props.site.domain}`, "") this.setState({ metric: "" }) } else { storage.setItem(`metric__${this.props.site.domain}`, newMetric) this.setState({ metric: newMetric }) } } fetchGraphData() { if (this.state.metric) { api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/main-graph`, this.props.query, {metric: this.state.metric || 'none'}) .then((res) => { this.setState((state) => ({ loading: state.loading-2, graphData: res })) return res }) } else { this.setState((state) => ({ loading: state.loading-2, graphData: null })) } } fetchTopStatData() { api.get(`/api/stats/${encodeURIComponent(this.props.site.domain)}/top-stats`, this.props.query) .then((res) => { this.setState((state) => ({ loading: state.loading-1, topStatData: res })) return res }) } renderInner() { const { query, site } = this.props; const { graphData, metric, topStatData, loading } = this.state; const theme = document.querySelector('html').classList.contains('dark') || false if ((loading <= 1 && topStatData) || (topStatData && graphData)) { return ( ) } } render() { const {metric, topStatData, graphData} = this.state return (
{this.state.loading > 0 &&
} {this.renderInner()}
) } }