Newer
Older
CrypticOreWallet / src / pages / dashboard / BlockDifficultyCard.tsx
@Drew Lemmy Drew Lemmy on 24 Feb 2021 7 KB fix: responsiveness tweaks on dashboard
// Copyright (c) 2020-2021 Drew Lemmy
// This file is part of KristWeb 2 under GPL-3.0.
// Full details: https://github.com/tmpim/KristWeb2/blob/master/LICENSE.txt
import React, { useState, useEffect, useMemo } from "react";
import { Card, Skeleton, Empty, Row, Col, Tooltip, Select } from "antd";

import { useSelector, shallowEqual } from "react-redux";
import { RootState } from "../../store";
import { useTranslation, Trans } from "react-i18next";

import { Line } from "react-chartjs-2";

import { APIResponse } from "../../krist/api/types";
import { throttle } from "lodash-es";
import { estimateHashRate } from "../../utils/currency";

import { SmallResult } from "../../components/SmallResult";
import { Statistic } from "./Statistic";

import Debug from "debug";
const debug = Debug("kristweb:block-difficulty-card");

const DATA_FETCH_THROTTLE = 300;

// =============================================================================
// Chart.JS theming options
// =============================================================================
const CHART_FILL_COLOUR = "#6495ed33";
const CHART_LINE_COLOUR = "#6495ed";
const CHART_GRID_COLOUR = "#434a6b";
const CHART_FONT_COLOUR = "#8991ab";

const CHART_HEIGHT = 180;

const CHART_OPTIONS_BASE = {
  maintainAspectRatio: false,

  animation: { duration: 0 },
  hover: { animationDuration: 0 },
  responsiveAnimationDuration: 0,

  legend: {
    display: false
  },

  elements: {
    line: { tension: 0 }
  },

  tooltips: {
    mode: "index",
    intersect: false,
    displayColors: false,
    position: "nearest"
  }
};

const CHART_DATASET_OPTIONS = {
  backgroundColor: CHART_FILL_COLOUR,
  borderColor: CHART_LINE_COLOUR,
  borderWidth: 2,
  pointRadius: 0
};

const CHART_OPTIONS_X_AXIS = {
  type: "time",
  bounds: "data",

  ticks: {
    maxTicksLimit: 12,
    fontColor: CHART_FONT_COLOUR,
    fontSize: 11,
    padding: 8,
    lineHeight: 0.5 // Push the tick text to the bottom
  },

  gridLines: {
    drawBorder: true,
    drawTicks: true,
    color: CHART_GRID_COLOUR,
    tickMarkLength: 1,
    zeroLineWidth: 1,
    zeroLineColor: CHART_GRID_COLOUR
  }
};

const CHART_OPTIONS_Y_AXIS = {
  type: "linear",

  ticks: {
    maxTicksLimit: 6,
    fontColor: CHART_FONT_COLOUR,
    fontSize: 11,
    suggestedMin: 100,
    suggestedMax: 100000,
    beginAtZero: true,
    padding: 8
  },

  gridLines: {
    drawBorder: true,
    drawTicks: true,
    color: CHART_GRID_COLOUR,
    tickMarkLength: 1,
    zeroLineWidth: 1,
    zeroLineColor: CHART_GRID_COLOUR
  }
};

export function BlockDifficultyCard(): JSX.Element {
  const { t } = useTranslation();

  const syncNode = useSelector((s: RootState) => s.node.syncNode);
  const lastBlockID = useSelector((s: RootState) => s.node.lastBlockID);
  const work = useSelector((s: RootState) => s.node.detailedWork?.work);
  const constants = useSelector((s: RootState) => s.node.constants, shallowEqual);

  const [workOverTime, setWorkOverTime] = useState<{ x: Date; y: number }[] | undefined>();
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | undefined>();

  const [chartMode, setChartMode] = useState<"linear" | "logarithmic">("linear");

  async function _fetchWorkOverTime(): Promise<void> {
    try {
      debug("fetching work over time");

      const res = await fetch(syncNode + "/work/day");
      if (!res.ok || res.status !== 200) throw new Error(res.statusText);

      const data: APIResponse<{ work: number[] }> = await res.json();
      if (!data.ok || data.error) throw new Error(data.error);

      // Convert the array indices to Dates, based on the fact that the array
      // should contain one block per secondsPerBlock (typically 1440 elements,
      // one per minute). This can be passed directly into Chart.JS.
      const processedWork = data.work.map((work, i, arr) => ({
        x: new Date(Date.now() - ((arr.length - i) * (constants.seconds_per_block * 1000))),
        y: work
      }));

      setWorkOverTime(processedWork);
    } catch (err) {
      console.error(err);
      setError(err);
    } finally {
      setLoading(false);
    }
  }

  const fetchWorkOverTime = useMemo(() =>
    throttle(_fetchWorkOverTime, DATA_FETCH_THROTTLE, { leading: false, trailing: true }), []);

  // Fetch the new work data whenever the sync node, block ID, or node constants
  // change. This is usually only going to be triggered by the block ID
  // changing, which is handled by WebsocketService.
  useEffect(() => {
    if (!syncNode) return;
    fetchWorkOverTime();
  }, [syncNode, lastBlockID, constants, constants.seconds_per_block]);

  function chart(): JSX.Element {
    return <Line
      height={CHART_HEIGHT}

      data={{
        datasets: [{
          ...CHART_DATASET_OPTIONS,

          label: t("dashboard.blockDifficultyChartWork"),
          data: workOverTime
        }]
      }}

      options={{
        ...CHART_OPTIONS_BASE,

        scales: {
          xAxes: [CHART_OPTIONS_X_AXIS],
          yAxes: [{
            ...CHART_OPTIONS_Y_AXIS,
            type: chartMode,
            ticks: {
              ...CHART_OPTIONS_Y_AXIS.ticks,
              suggestedMin: constants.min_work,
              suggestedMax: constants.max_work
            }
          }]
        }
      }}
    />;
  }

  /* In its own component to work with i18next Trans */
  function HashRate(): JSX.Element {
    return <span className="difficulty-hash-rate-rate">
      {estimateHashRate(work || 0, constants.seconds_per_block)}
    </span>;
  }

  function display(): JSX.Element | null {
    if (!work || !workOverTime) return null;

    return <Row>
      <Col span={24} md={4} className="left-col">
        {/* Current work */}
        <Statistic value={work.toLocaleString()} />

        {/* Approximate network hash rate */}
        <Tooltip title={t("dashboard.blockDifficultyHashRateTooltip")}>
          <div className="difficulty-hash-rate">
            <Trans t={t} i18nKey="dashboard.blockDifficultyHashRate">
              Approx. <HashRate />
            </Trans>
          </div>
        </Tooltip>

        {/* Spacer to push the dropdown to the bottom */}
        <div className="spacer" />

        {/* Chart Y-axis scale dropdown */}
        <Select
          value={chartMode}
          className="chart-mode-dropdown"
          onSelect={value => setChartMode(value)}
        >
          <Select.Option value="linear">{t("dashboard.blockDifficultyChartLinear")}</Select.Option>
          <Select.Option value="logarithmic">{t("dashboard.blockDifficultyChartLog")}</Select.Option>
        </Select>
      </Col>

      {/* Work over time chart */}
      <Col span={24} md={20}>{chart()}</Col>
    </Row>;
  }

  const isEmpty = !loading && error;

  return <Card title={t("dashboard.blockDifficultyCardTitle")} className={"dashboard-card dashboard-card-block-difficulty " + (isEmpty ? "empty" : "")}>
    <Skeleton paragraph={{ rows: 2 }} title={false} active loading={loading}>
      {error
        ? <SmallResult status="error" title={t("error")} subTitle={t("dashboard.blockDifficultyError")} />
        : (work && workOverTime
          ? display()
          : <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
        )}
    </Skeleton>
  </Card>;
}