diff --git a/api/.eslintrc.js b/api/.eslintrc.js index 0bb369474..d0d2ef1ee 100644 --- a/api/.eslintrc.js +++ b/api/.eslintrc.js @@ -451,7 +451,7 @@ module.exports = { "off" ], "generator-star-spacing": [ - "error" + "error", {"before": false, "after": true} ], "getter-return": [ "off" diff --git a/api/src/initServices.ts b/api/src/initServices.ts index 6cf9f0d0d..743079539 100644 --- a/api/src/initServices.ts +++ b/api/src/initServices.ts @@ -26,6 +26,8 @@ import { InfluxDBService } from "./services/stardust/influx/influxDbService"; import { NodeInfoService } from "./services/stardust/nodeInfoService"; import { StardustStatsService } from "./services/stardust/stats/stardustStatsService"; +const CURRENCY_UPDATE_INTERVAL_MS = 5 * 60000; + const isKnownProtocolVersion = (networkConfig: INetwork) => networkConfig.protocolVersion === LEGACY || networkConfig.protocolVersion === CHRYSALIS || @@ -96,7 +98,7 @@ export async function initServices(config: IConfiguration) { void currencyService.update(); }; - setInterval(update, 60000); + setInterval(update, CURRENCY_UPDATE_INTERVAL_MS); await update(); } diff --git a/client/.eslintrc.js b/client/.eslintrc.js index 7f8604475..e3ea6c3ff 100644 --- a/client/.eslintrc.js +++ b/client/.eslintrc.js @@ -463,7 +463,7 @@ module.exports = { "off" ], "generator-star-spacing": [ - "error" + "error", {"before": false, "after": true} ], "getter-return": [ "off" diff --git a/client/package-lock.json b/client/package-lock.json index cb0b5cdd2..b17d92592 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -24,11 +24,14 @@ "crypto": "npm:crypto-browserify", "d3-array": "^3.2.1", "d3-axis": "^3.0.0", + "d3-brush": "^3.0.0", "d3-format": "^3.1.0", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.1.0", + "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", + "d3-transition": "^3.0.1", "express": "^4.18.1", "jsonschema": "^1.4.1", "moment": "^2.29.4", @@ -54,11 +57,14 @@ "@types/classnames": "^2.3.1", "@types/d3-array": "^3.0.3", "@types/d3-axis": "^3.0.1", + "@types/d3-brush": "^3.0.2", "@types/d3-format": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-selection": "^3.0.3", "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", "@types/d3-time-format": "^4.0.0", + "@types/d3-transition": "^3.0.3", "@types/express": "^4.17.14", "@types/jest": "^29.0.0", "@types/node": "^16.10.3", @@ -4313,6 +4319,15 @@ "@types/d3-selection": "*" } }, + "node_modules/@types/d3-brush": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.2.tgz", + "integrity": "sha512-2TEm8KzUG3N7z0TrSKPmbxByBx54M+S9lHoP2J55QuLU0VSQ9mE96EJSAOVNEqd1bbynMjeTS9VHmz8/bSw8rA==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/d3-format": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", @@ -4361,6 +4376,15 @@ "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", "dev": true }, + "node_modules/@types/d3-transition": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.3.tgz", + "integrity": "sha512-/S90Od8Id1wgQNvIA8iFv9jRhCiZcGhPd2qX0bKF/PS+y0W5CrXKgIiELd2CvG1mlQrWK/qlYh3VxicqG1ZvgA==", + "dev": true, + "dependencies": { + "@types/d3-selection": "*" + } + }, "node_modules/@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -8016,6 +8040,21 @@ "node": ">=12" } }, + "node_modules/d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", @@ -8024,6 +8063,34 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -8086,9 +8153,9 @@ } }, "node_modules/d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "dependencies": { "d3-array": "2 - 3" }, @@ -8107,6 +8174,32 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -24657,6 +24750,15 @@ "@types/d3-selection": "*" } }, + "@types/d3-brush": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-brush/-/d3-brush-3.0.2.tgz", + "integrity": "sha512-2TEm8KzUG3N7z0TrSKPmbxByBx54M+S9lHoP2J55QuLU0VSQ9mE96EJSAOVNEqd1bbynMjeTS9VHmz8/bSw8rA==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, "@types/d3-format": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@types/d3-format/-/d3-format-3.0.1.tgz", @@ -24705,6 +24807,15 @@ "integrity": "sha512-yjfBUe6DJBsDin2BMIulhSHmr5qNR5Pxs17+oW4DoVPyVIXZ+m6bs7j1UVKP08Emv6jRmYrYqxYzO63mQxy1rw==", "dev": true }, + "@types/d3-transition": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.3.tgz", + "integrity": "sha512-/S90Od8Id1wgQNvIA8iFv9jRhCiZcGhPd2qX0bKF/PS+y0W5CrXKgIiELd2CvG1mlQrWK/qlYh3VxicqG1ZvgA==", + "dev": true, + "requires": { + "@types/d3-selection": "*" + } + }, "@types/eslint": { "version": "8.4.6", "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.4.6.tgz", @@ -27489,11 +27600,42 @@ "resolved": "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz", "integrity": "sha512-IH5tgjV4jE/GhHkRV0HiVYPDtvfjHQlQfJHs0usq7M30XcSBvOotpmH1IgkcXsO/5gEQZD43B//fc7SRT5S+xw==" }, + "d3-brush": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-brush/-/d3-brush-3.0.0.tgz", + "integrity": "sha512-ALnjWlVYkXsVIGlOsuWH1+3udkYFI48Ljihfnh8FZPF2QS9o+PzGLBslO0PjzVoHLZ2KCVgAM8NVkXPJB2aNnQ==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "3", + "d3-transition": "3" + } + }, "d3-color": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, "d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -27538,9 +27680,9 @@ } }, "d3-time": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", - "integrity": "sha512-zmV3lRnlaLI08y9IMRXSDshQb5Nj77smnfpnd2LrBa/2K281Jijactokeak14QacHs/kKq0AQ121nidNYlarbQ==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", "requires": { "d3-array": "2 - 3" } @@ -27553,6 +27695,23 @@ "d3-time": "1 - 3" } }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, "damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", diff --git a/client/package.json b/client/package.json index 1b57fd8b6..aca5cb1a6 100644 --- a/client/package.json +++ b/client/package.json @@ -32,11 +32,14 @@ "crypto": "npm:crypto-browserify", "d3-array": "^3.2.1", "d3-axis": "^3.0.0", + "d3-brush": "^3.0.0", "d3-format": "^3.1.0", "d3-scale": "^4.0.2", "d3-selection": "^3.0.0", "d3-shape": "^3.1.0", + "d3-time": "^3.1.0", "d3-time-format": "^4.1.0", + "d3-transition": "^3.0.1", "express": "^4.18.1", "jsonschema": "^1.4.1", "moment": "^2.29.4", @@ -62,11 +65,14 @@ "@types/classnames": "^2.3.1", "@types/d3-array": "^3.0.3", "@types/d3-axis": "^3.0.1", + "@types/d3-brush": "^3.0.2", "@types/d3-format": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-selection": "^3.0.3", "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", "@types/d3-time-format": "^4.0.0", + "@types/d3-transition": "^3.0.3", "@types/express": "^4.17.14", "@types/jest": "^29.0.0", "@types/node": "^16.10.3", diff --git a/client/src/app/components/stardust/statistics/ChartHeader.scss b/client/src/app/components/stardust/statistics/ChartHeader.scss index f69d643d8..19b25c8ec 100644 --- a/client/src/app/components/stardust/statistics/ChartHeader.scss +++ b/client/src/app/components/stardust/statistics/ChartHeader.scss @@ -32,37 +32,6 @@ } } - .chart-header__select { - display: flex; - justify-content: center; - align-items: center; - .select-wrapper { - position: relative; - margin-right: 20px; - - select { - cursor: pointer; - background: var(--statistics-select-bg); - color: var(--card-color); - border-color: var(--statistics-select-border); - height: 40px; - - @include phone-down { - height: 32px; - } - } - - .chevron { - top: 23%; - color: var(--card-color); - z-index: 1; - - @include phone-down { - top: 13%; - } - } - } - } .charts-css.legend { padding: 1rem; border: 1px solid var(--legend-border-color); diff --git a/client/src/app/components/stardust/statistics/ChartHeader.tsx b/client/src/app/components/stardust/statistics/ChartHeader.tsx index 93fa61859..f5f0bee1e 100644 --- a/client/src/app/components/stardust/statistics/ChartHeader.tsx +++ b/client/src/app/components/stardust/statistics/ChartHeader.tsx @@ -1,4 +1,3 @@ -import classNames from "classnames"; import React from "react"; import Modal from "../../Modal"; import { ModalData } from "../../ModalProps"; @@ -8,7 +7,6 @@ import "./ChartHeader.scss"; interface ChartHeaderProps { title?: string; info?: ModalData; - onTimespanSelected: (value: TimespanOption) => void; disabled?: boolean; legend?: { labels: string[]; @@ -16,9 +14,7 @@ interface ChartHeaderProps { }; } -export type TimespanOption = "7" | "30" | "90" | "all"; - -const ChartHeader: React.FC = ({ title, info, onTimespanSelected, disabled, legend }) => ( +const ChartHeader: React.FC = ({ title, info, disabled, legend }) => (
{title && ( @@ -29,29 +25,6 @@ const ChartHeader: React.FC = ({ title, info, onTimespanSelect )}
)} - - {!disabled && ( -
-
- - - expand_more - -
-
- )}
{!disabled && legend && ( diff --git a/client/src/app/components/stardust/statistics/ChartUtils.tsx b/client/src/app/components/stardust/statistics/ChartUtils.tsx index a0b3e2781..2d421bd6f 100644 --- a/client/src/app/components/stardust/statistics/ChartUtils.tsx +++ b/client/src/app/components/stardust/statistics/ChartUtils.tsx @@ -1,15 +1,24 @@ import { INodeInfoBaseToken } from "@iota/iota.js-stardust"; +import { Axis, axisBottom, axisLeft } from "d3-axis"; +import { format } from "d3-format"; +import { NumberValue, ScaleLinear, ScaleTime } from "d3-scale"; +import { timeDay, timeMonth, timeWeek, timeYear } from "d3-time"; +import { timeFormat } from "d3-time-format"; import moment from "moment"; import React, { useCallback, useEffect, useState } from "react"; import { formatAmount } from "../../../../helpers/stardust/valueFormatHelper"; import { IDistributionEntry } from "../../../../models/api/stardust/chronicle/ITokenDistributionResponse"; +export const TRANSITIONS_DURATION_MS = 750; + export const noDataView = () => (

No Data

); + +const formatToMagnituge = (val: number) => format(d3FormatSpecifier(val)); export const useSingleValueTooltip = ( data: { [name: string]: number; time: number }[], label?: string @@ -19,14 +28,13 @@ export const useSingleValueTooltip = (

${moment.unix(dataPoint.time).format("DD-MM-YYYY")}

${label ?? "count"}: - ${dataPoint.n} + ${formatToMagnituge(dataPoint.n)(dataPoint.n)}

` ), [data, label]); return buildTooltip; }; - export const useMultiValueTooltip = ( data: { [name: string]: number; time: number }[], subgroups: string[], @@ -39,7 +47,7 @@ export const useMultiValueTooltip = (

${groupLabels ? groupLabels[idx] : subgroup}: - ${dataPoint[subgroup]} + ${formatToMagnituge(dataPoint[subgroup])(dataPoint[subgroup])}

`)).join("")}` ), [data, subgroups, groupLabels, colors]); @@ -144,3 +152,69 @@ export const getSubunitThreshold = (tokenInfo: INodeInfoBaseToken) => ( Math.pow(10, tokenInfo.decimals) : null ); +const formatHidden = timeFormat(""); +const formatDay = timeFormat("%a %d"); +const formatWeek = timeFormat("%b %d"); +const formatMonth = timeFormat("%B"); +const formatYear = timeFormat("%Y"); + +const tickMultiFormat = (date: Date | NumberValue) => { + const theDate = date as Date; + if (timeDay(theDate) < theDate) { + return formatHidden(theDate); + } else if (timeMonth(theDate) < theDate) { + if (timeWeek(theDate) < theDate) { + return formatDay(theDate); + } + return formatWeek(theDate); + } else if (timeYear(theDate) < theDate) { + return formatMonth(theDate); + } + + return formatYear(theDate); +}; + +export const buildXAxis: (scale: ScaleTime) => Axis = scale => + axisBottom(scale).ticks(8).tickFormat(tickMultiFormat) as Axis; + +export const buildYAxis = (scale: ScaleLinear, theYMax: number) => + axisLeft(scale.nice()).tickFormat(format(d3FormatSpecifier(theYMax))); + +export const timestampToDate = (timestamp: number) => moment.unix(timestamp) + .hours(0) + .minutes(0) + .toDate(); + +export const computeDataIncludedInSelection = ( + scale: ScaleTime, + data: { + [name: string]: number; + time: number; + }[] +) => { + const selectedData: { [name: string]: number; time: number }[] = []; + + const from = scale.domain()[0]; + from.setHours(0, 0, 0, 0); + const to = scale.domain()[1]; + to.setHours(0, 0, 0, 0); + for (const d of data) { + const target = timestampToDate(d.time); + target.setHours(0, 0, 0, 0); + if (from <= target && target <= to) { + selectedData.push(d); + } + } + + return selectedData; +}; + +export const computeHalfLineWidth = ( + data: { [name: string]: number; time: number }[], + x: ScaleTime +) => ( + data.length > 1 ? + ((x(timestampToDate(data[1].time)) ?? 0) - (x(timestampToDate(data[0].time)) ?? 0)) / 2 : + 0 +); + diff --git a/client/src/app/components/stardust/statistics/InfluxChartsTab.tsx b/client/src/app/components/stardust/statistics/InfluxChartsTab.tsx index 69a27b82d..9c310ec76 100644 --- a/client/src/app/components/stardust/statistics/InfluxChartsTab.tsx +++ b/client/src/app/components/stardust/statistics/InfluxChartsTab.tsx @@ -1,6 +1,7 @@ import React, { useContext } from "react"; import graphMessages from "../../../../assets/modals/stardust/statistics/graphs.json"; import { useChartsState } from "../../../../helpers/hooks/useChartsState"; +import { idGenerator } from "../../../../helpers/stardust/statisticsUtils"; import { formatAmount } from "../../../../helpers/stardust/valueFormatHelper"; import NetworkContext from "../../../context/NetworkContext"; import { COMMAS_REGEX } from "../../../routes/stardust/landing/ShimmerClaimedUtils"; @@ -37,6 +38,8 @@ export const InfluxChartsTab: React.FC = () => { tokenInfo ).replace(COMMAS_REGEX, ",") ?? "-"; + const ids = idGenerator(); + return (
@@ -45,15 +48,17 @@ export const InfluxChartsTab: React.FC = () => {
- - {
{ data={outputs} /> {
{ data={addressesWithBalance} /> {
{
{ data={aliasActivity} /> {
{
{
{ data={unclaimedTokens} /> {
- { data={ledgerSize} /> = ({ title, info, data, label, color }) => { +const BarChart: React.FC = ({ chartId, title, info, data, label, color }) => { const [{ wrapperWidth, wrapperHeight }, setTheRef] = useChartWrapperSize(); const chartWrapperRef = useCallback((chartWrapper: HTMLDivElement) => { if (chartWrapper !== null) { @@ -39,7 +39,6 @@ const BarChart: React.FC = ({ title, info, data, label, color }) }, []); const theTooltip = useRef(null); const theSvg = useRef(null); - const [timespan, setTimespan] = useState("all"); const buildTooltip = useSingleValueTooltip(data, label); useTouchMoveEffect(mouseoutHandler); @@ -51,62 +50,121 @@ const BarChart: React.FC = ({ title, info, data, label, color }) // reset select(theSvg.current).select("*").remove(); - data = timespan !== "all" ? data.slice(-timespan) : data; - - const dataMaxY = max(data, d => d.n) ?? 1; - const leftMargin = determineGraphLeftPadding(dataMaxY); - + // chart dimensions + const yMax = max(data, d => d.n) ?? 1; + const leftMargin = determineGraphLeftPadding(yMax); const MARGIN = { top: 30, right: 20, bottom: 50, left: leftMargin }; const INNER_WIDTH = width - MARGIN.left - MARGIN.right; const INNER_HEIGHT = height - MARGIN.top - MARGIN.bottom; - const x = scaleBand().domain(data.map(d => moment.unix(d.time).format(DAY_LABEL_FORMAT))) - .range([0, INNER_WIDTH]) - .paddingInner(0.1); - - const y = scaleLinear().domain([0, dataMaxY]) - .range([INNER_HEIGHT, 0]); + const dates = data.map(d => timestampToDate(d.time)); + // SVG const svg = select(theSvg.current) .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none") .append("g") .attr("transform", `translate(${MARGIN.left}, ${MARGIN.top})`); - const yAxisGrid = axisLeft(y.nice()).tickFormat(format(d3FormatSpecifier(dataMaxY))); + // X + const x = scaleTime() + .domain([dates[0], dates[dates.length - 1]]) + .range([0, INNER_WIDTH]); + const xAxisSelection = svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(0, ${INNER_HEIGHT})`) + .call(buildXAxis(x)); - svg.append("g") + // Y + const y = scaleLinear().domain([0, yMax]) + .range([INNER_HEIGHT, 0]); + const yAxisSelection = svg.append("g") .attr("class", "axis axis--y") - .call(yAxisGrid); + .call(buildYAxis(y, yMax)); - svg.selectAll(".bar") + // clip path + svg.append("defs") + .append("clipPath") + .attr("id", `clip-${chartId}`) + .append("rect") + .attr("width", INNER_WIDTH) + .attr("height", height) + .attr("x", 0) + .attr("y", 0); + + // brushing + const brush = brushX() + .extent([[0, 0], [INNER_WIDTH, height]]) + .on("end", e => onBrushHandler(e as D3BrushEvent<{ [key: string]: number }>)); + + const brushSelection = svg.append("g") + .attr("class", "brush") + .call(brush); + + // bars + const barsSelection = svg.append("g") + .attr("class", "the-bars") + .attr("clip-path", `url(#clip-${chartId})`) + .selectAll("g") .data(data) .enter() .append("rect") .attr("class", "bar") - .attr("x", d => x(moment.unix(d.time).format(DAY_LABEL_FORMAT)) ?? 0) - .attr("width", x.bandwidth()) + .attr("x", d => x(timestampToDate(d.time)) - ((INNER_WIDTH / data.length) / 2)) .attr("y", d => y(d.n)) - .attr("height", d => INNER_HEIGHT - y(d.n)) .attr("fill", color) + .attr("rx", 2) .on("mouseover", mouseoverHandler) - .on("mouseout", mouseoutHandler); - - const tickValues = timespan === "7" ? - x.domain() : - // every third label - x.domain().filter((_, i) => !(i % 3)); - const xAxis = axisLabelRotate( - axisBottom(x).tickValues(tickValues) - ); - - svg.append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${INNER_HEIGHT})`) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .call(xAxis); + .on("mouseout", mouseoutHandler) + .attr("width", INNER_WIDTH / data.length) + .attr("height", d => INNER_HEIGHT - y(d.n)); + + const onBrushHandler = (event: D3BrushEvent<{ [key: string]: number }>) => { + if (!event.selection) { + return; + } + const extent = event.selection; + if (!extent) { + x.domain([dates[0], dates[dates.length - 1]]); + } else { + x.domain([x.invert(extent[0] as NumberValue), x.invert(extent[1] as NumberValue)]); + // eslint-disable-next-line @typescript-eslint/unbound-method + brushSelection.call(brush.move, null); + } + + const selectedData = computeDataIncludedInSelection(x, data); + + // to prevent infinite brushing + if (selectedData.length > 1) { + const yMaxUpdate = max(selectedData, d => d.n) ?? 1; + y.domain([0, yMaxUpdate]); + // Update axis + xAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildXAxis(x)); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMaxUpdate)); + + // Update bars + barsSelection.transition().duration(TRANSITIONS_DURATION_MS) + .attr("x", d => x(timestampToDate(d.time)) - ((INNER_WIDTH / selectedData.length) / 2)) + .attr("y", d => y(d.n)) + .attr("width", INNER_WIDTH / selectedData.length) + .attr("height", d => INNER_HEIGHT - y(d.n)); + } + }; + + // double click reset + svg.on("dblclick", () => { + x.domain([dates[0], dates[dates.length - 1]]); + xAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildXAxis(x)); + y.domain([0, yMax]); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMax)); + barsSelection.transition().duration(TRANSITIONS_DURATION_MS) + .attr("x", d => x(timestampToDate(d.time)) - ((INNER_WIDTH / data.length) / 2)) + .attr("y", d => y(d.n)) + .attr("width", INNER_WIDTH / data.length) + .attr("height", d => INNER_HEIGHT - y(d.n)); + }); } - }, [data, timespan, wrapperWidth, wrapperHeight]); + }, [data, wrapperWidth, wrapperHeight]); /** * Handles mouseover event of a bar "part" @@ -145,7 +203,6 @@ const BarChart: React.FC = ({ title, info, data, label, color }) setTimespan(value)} disabled={data.length === 0} /> {data.length === 0 ? ( diff --git a/client/src/app/components/stardust/statistics/charts/Chart.scss b/client/src/app/components/stardust/statistics/charts/Chart.scss index 21e6a9c49..f0a8515d8 100644 --- a/client/src/app/components/stardust/statistics/charts/Chart.scss +++ b/client/src/app/components/stardust/statistics/charts/Chart.scss @@ -14,7 +14,7 @@ &.chart-wrapper--no-data{ height: 350px; } - + .chart-wrapper__content { height: 350px; } @@ -22,8 +22,7 @@ svg.hook { rect { &.bar.active, &.stacked-bar.active { - stroke: $gray-5; - stroke-width: 1px; + opacity: 0.7; } } @@ -47,6 +46,10 @@ path { stroke: $gray-6; } + + .tick line { + color: transparent; + } } .axis--y path { diff --git a/client/src/app/components/stardust/statistics/charts/LineChart.tsx b/client/src/app/components/stardust/statistics/charts/LineChart.tsx index ae6c622ce..238b26774 100644 --- a/client/src/app/components/stardust/statistics/charts/LineChart.tsx +++ b/client/src/app/components/stardust/statistics/charts/LineChart.tsx @@ -1,21 +1,22 @@ -import { axisBottom, axisLabelRotate } from "@d3fc/d3fc-axis"; import classNames from "classnames"; import { max } from "d3-array"; -import { axisLeft } from "d3-axis"; -import { format } from "d3-format"; -import { scaleLinear, scaleTime } from "d3-scale"; +import { brushX, D3BrushEvent } from "d3-brush"; +import { NumberValue, scaleLinear, scaleTime } from "d3-scale"; import { BaseType, select } from "d3-selection"; import { line } from "d3-shape"; -import { timeFormat } from "d3-time-format"; -import moment from "moment"; -import React, { useCallback, useLayoutEffect, useRef, useState } from "react"; +import React, { useCallback, useLayoutEffect, useRef } from "react"; import { ModalData } from "../../../ModalProps"; -import ChartHeader, { TimespanOption } from "../ChartHeader"; +import ChartHeader from "../ChartHeader"; import ChartTooltip from "../ChartTooltip"; import { - d3FormatSpecifier, + buildXAxis, + buildYAxis, + computeDataIncludedInSelection, + computeHalfLineWidth, determineGraphLeftPadding, noDataView, + timestampToDate, + TRANSITIONS_DURATION_MS, useChartWrapperSize, useSingleValueTooltip, useTouchMoveEffect @@ -23,6 +24,7 @@ import { import "./Chart.scss"; interface LineChartProps { + chartId: string; title?: string; info?: ModalData; data: { [name: string]: number; time: number }[]; @@ -30,7 +32,7 @@ interface LineChartProps { color: string; } -const LineChart: React.FC = ({ title, info, data, label, color }) => { +const LineChart: React.FC = ({ chartId, title, info, data, label, color }) => { const [{ wrapperWidth, wrapperHeight }, setTheRef] = useChartWrapperSize(); const chartWrapperRef = useCallback((chartWrapper: HTMLDivElement) => { if (chartWrapper !== null) { @@ -39,7 +41,6 @@ const LineChart: React.FC = ({ title, info, data, label, color } }, []); const theSvg = useRef(null); const theTooltip = useRef(null); - const [timespan, setTimespan] = useState("all"); const buildTooltip = useSingleValueTooltip(data, label); useTouchMoveEffect(mouseoutHandler); @@ -51,106 +52,160 @@ const LineChart: React.FC = ({ title, info, data, label, color } // reset select(theSvg.current).select("*").remove(); - data = timespan !== "all" ? data.slice(-timespan) : data; - - const dataMaxY = max(data, d => d.n) ?? 1; - const leftMargin = determineGraphLeftPadding(dataMaxY); - + // chart dimensions + const yMax = max(data, d => d.n) ?? 1; + const leftMargin = determineGraphLeftPadding(yMax); const MARGIN = { top: 30, right: 20, bottom: 50, left: leftMargin }; const INNER_WIDTH = width - MARGIN.left - MARGIN.right; const INNER_HEIGHT = height - MARGIN.top - MARGIN.bottom; - const timestampToDate = (timestampInSec: number) => ( - moment.unix(timestampInSec) - .hours(0).minutes(0) - .toDate() - ); - - const domain = [ - data.length > 0 ? timestampToDate(data[0].time) : new Date(), - data.length > 0 ? timestampToDate(data[data.length - 1].time) : new Date() - ]; - - const x = scaleTime().domain(domain).range([0, INNER_WIDTH]) - .nice(); - - const y = scaleLinear().domain([0, dataMaxY]).range([INNER_HEIGHT, 0]); + const dates = data.map(d => timestampToDate(d.time)); + // SVG const svg = select(theSvg.current) .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none") .append("g") .attr("transform", `translate(${MARGIN.left}, ${MARGIN.top})`); - const yAxisGrid = axisLeft(y.nice()).tickFormat(format(d3FormatSpecifier(dataMaxY))); + // X + const x = scaleTime() + .domain([dates[0], dates[dates.length - 1]]) + .range([0, INNER_WIDTH]); + const xAxisSelection = svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(0, ${INNER_HEIGHT})`) + .call(buildXAxis(x)); - svg.append("g") + // Y + const y = scaleLinear().domain([0, yMax]).range([INNER_HEIGHT, 0]); + const yAxisSelection = svg.append("g") .attr("class", "axis axis--y") - .call(yAxisGrid); + .call(buildYAxis(y, yMax)); + + // clip path + svg.append("defs") + .append("clipPath") + .attr("id", `clip-${chartId}`) + .append("rect") + .attr("width", INNER_WIDTH) + .attr("height", height) + .attr("x", 0) + .attr("y", 0); + + // brushing + const brush = brushX() + .extent([[0, 0], [INNER_WIDTH, height]]) + .on("end", e => { + onBrushHandler(e as D3BrushEvent<{ [key: string]: number }>); + }); + + const brushSelection = svg.append("g") + .attr("class", "brush") + .call(brush); + + // line + const lineGen = line<{ [name: string]: number; time: number }>() + .x(d => x(timestampToDate(d.time)) ?? 0) + .y(d => y(d.n)); - svg.append("path") + const lineSelection = svg.append("g") + .attr("class", "the-line") + .attr("clip-path", `url(#clip-${chartId})`) + .append("path") .datum(data) .attr("fill", "none") .attr("stroke", color) .attr("stroke-width", 1.5) - .attr( - "d", - line<{ [name: string]: number; time: number }>() - .x(d => x(timestampToDate(d.time)) ?? 0) - .y(d => y(d.n)) - ); - - svg.selectAll("circle") - .data(data) - .enter() - .append("circle") - .attr("r", 1) - .attr("fill", color) - .style("stroke", color) - .style("stroke-width", 5) - .style("stroke-opacity", 0) - .attr("transform", d => `translate(${x(timestampToDate(d.time))}, ${y(d.n)})`) - .attr("class", (_, i) => `circle-${i}`); - - const xAxis = axisLabelRotate( - axisBottom(x).tickFormat(timeFormat("%d %b")) - ); + .attr("d", lineGen); - svg.append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${INNER_HEIGHT})`) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .call(xAxis); - - const halfLineWidth = data.length > 1 ? - ((x(timestampToDate(data[1].time)) ?? 0) - (x(timestampToDate(data[0].time)) ?? 0)) / 2 : - 18; - - svg.append("g") - .attr("class", "hover-lines") - .selectAll("g") - .data(data) - .enter() - .append("rect") - .attr("fill", "transparent") - .attr("x", (_, idx) => ( - idx === 0 ? 0 : (x(timestampToDate(data[idx].time)) ?? 0) - halfLineWidth - )) - .attr("y", 0) - .attr("class", (_, i) => `rect-${i}`) - .attr("height", INNER_HEIGHT) - .attr("width", (_, idx) => { - if (idx === 0) { - return halfLineWidth; - } else if (idx === data.length - 1) { - return halfLineWidth; - } - return halfLineWidth * 2; - }) - .on("mouseover", mouseoverHandler) - .on("mouseout", mouseoutHandler); + const attachPathAndCircles = () => { + svg.selectAll(".hover-circles").remove(); + svg.selectAll(".hover-lines").remove(); + + const halfLineWidth = computeHalfLineWidth(data, x); + svg.append("g") + .attr("class", "hover-circles") + .selectAll("g") + .data(data) + .enter() + .append("circle") + .attr("r", 0) + .attr("fill", color) + .style("stroke", color) + .style("stroke-width", 5) + .style("stroke-opacity", 0) + .attr("transform", d => `translate(${x(timestampToDate(d.time))}, ${y(d.n)})`) + .attr("class", (_, i) => `circle-${i}`); + + svg.append("g") + .attr("class", "hover-lines") + .selectAll("g") + .data(data) + .enter() + .append("rect") + .attr("fill", "transparent") + .attr("x", (_, idx) => ( + idx === 0 ? 0 : (x(timestampToDate(data[idx].time)) ?? 0) - halfLineWidth + )) + .attr("y", 0) + .attr("class", (_, i) => `rect-${i}`) + .attr("height", INNER_HEIGHT) + .attr("width", (_, idx) => { + if (idx === 0) { + return halfLineWidth; + } else if (idx === data.length - 1) { + return halfLineWidth; + } + return halfLineWidth * 2; + }) + .on("mouseover", mouseoverHandler) + .on("mouseout", mouseoutHandler); + }; + + attachPathAndCircles(); + + const onBrushHandler = (event: D3BrushEvent<{ [key: string]: number }>) => { + if (!event.selection) { + return; + } + const extent = event.selection; + if (!extent) { + x.domain([dates[0], dates[dates.length - 1]]); + } else { + x.domain([x.invert(extent[0] as NumberValue), x.invert(extent[1] as NumberValue)]); + // eslint-disable-next-line @typescript-eslint/unbound-method + brushSelection.call(brush.move, null); + } + + const selectedData = computeDataIncludedInSelection(x, data); + + // to prevent infinite brushing + if (selectedData.length > 1) { + const yMaxUpdate = max(selectedData, d => d.n) ?? 1; + y.domain([0, yMaxUpdate]); + + // Update axis and lines position + xAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildXAxis(x)); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMaxUpdate)); + lineSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", lineGen); + + // rebuild the hover activated lines & cicles + attachPathAndCircles(); + } + }; + + // double click reset + svg.on("dblclick", () => { + x.domain([dates[0], dates[dates.length - 1]]); + xAxisSelection.transition().call(buildXAxis(x)); + y.domain([0, yMax]); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMax)); + lineSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", lineGen); + attachPathAndCircles(); + }); } - }, [data, timespan, wrapperWidth, wrapperHeight]); + }, [data, wrapperWidth, wrapperHeight]); /** * Handles mouseover event. @@ -199,7 +254,7 @@ const LineChart: React.FC = ({ title, info, data, label, color } select(theSvg.current) .selectAll(`.circle-${idx}`) - .attr("r", 1) + .attr("r", 0) .style("stroke-opacity", 0); activeElement @@ -212,7 +267,6 @@ const LineChart: React.FC = ({ title, info, data, label, color } setTimespan(value)} disabled={data.length === 0} /> {data.length === 0 ? ( diff --git a/client/src/app/components/stardust/statistics/charts/StackedBarChart.tsx b/client/src/app/components/stardust/statistics/charts/StackedBarChart.tsx index e78d8d896..6fb6c0610 100644 --- a/client/src/app/components/stardust/statistics/charts/StackedBarChart.tsx +++ b/client/src/app/components/stardust/statistics/charts/StackedBarChart.tsx @@ -1,26 +1,28 @@ -import { axisBottom, axisLabelRotate } from "@d3fc/d3fc-axis"; import classNames from "classnames"; -import { axisLeft } from "d3-axis"; -import { format } from "d3-format"; -import { scaleBand, scaleLinear, scaleOrdinal } from "d3-scale"; +import { brushX, D3BrushEvent } from "d3-brush"; +import { NumberValue, scaleLinear, scaleOrdinal, scaleTime } from "d3-scale"; import { BaseType, select } from "d3-selection"; import { SeriesPoint, stack } from "d3-shape"; -import moment from "moment"; -import React, { useCallback, useLayoutEffect, useRef, useState } from "react"; +import React, { useCallback, useLayoutEffect, useRef } from "react"; import { ModalData } from "../../../ModalProps"; -import ChartHeader, { TimespanOption } from "../ChartHeader"; +import ChartHeader from "../ChartHeader"; import ChartTooltip from "../ChartTooltip"; import { useMultiValueTooltip, noDataView, useChartWrapperSize, determineGraphLeftPadding, - d3FormatSpecifier, - useTouchMoveEffect + useTouchMoveEffect, + timestampToDate, + buildXAxis, + buildYAxis, + computeDataIncludedInSelection, + TRANSITIONS_DURATION_MS } from "../ChartUtils"; import "./Chart.scss"; interface StackedBarChartProps { + chartId: string; title?: string; info?: ModalData; subgroups: string[]; @@ -29,9 +31,8 @@ interface StackedBarChartProps { data: { [name: string]: number; time: number }[]; } -const DAY_LABEL_FORMAT = "DD MMM"; - const StackedBarChart: React.FC = ({ + chartId, title, info, subgroups, @@ -47,7 +48,6 @@ const StackedBarChart: React.FC = ({ }, []); const theSvg = useRef(null); const theTooltip = useRef(null); - const [timespan, setTimespan] = useState("all"); const buildTooltip = useMultiValueTooltip(data, subgroups, colors, groupLabels); useTouchMoveEffect(mouseoutHandler); @@ -59,9 +59,8 @@ const StackedBarChart: React.FC = ({ // reset select(theSvg.current).select("*").remove(); - data = timespan !== "all" ? data.slice(-timespan) : data; - - const dataMaxY = Math.max( + // chart dimensions + const yMax = Math.max( ...data.map(d => { let sum = 0; for (const key of subgroups) { @@ -70,37 +69,63 @@ const StackedBarChart: React.FC = ({ return sum; }) ); - const leftMargin = determineGraphLeftPadding(dataMaxY); - + const leftMargin = determineGraphLeftPadding(yMax); const MARGIN = { top: 30, right: 20, bottom: 50, left: leftMargin }; const INNER_WIDTH = width - MARGIN.left - MARGIN.right; const INNER_HEIGHT = height - MARGIN.top - MARGIN.bottom; const color = scaleOrdinal().domain(subgroups).range(colors); - const groups = data.map(d => moment.unix(d.time).format(DAY_LABEL_FORMAT)); - const x = scaleBand().domain(groups) - .range([0, INNER_WIDTH]) - .paddingInner(0.1); - - const y = scaleLinear().domain([0, dataMaxY]) - .range([INNER_HEIGHT, 0]); + const groups = data.map( + d => timestampToDate(d.time) + ); + const stackedData = stack().keys(subgroups)(data); + // SVG const svg = select(theSvg.current) .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none") .append("g") .attr("transform", `translate(${MARGIN.left}, ${MARGIN.top})`); - const yAxisGrid = axisLeft(y.nice()).tickFormat(format(d3FormatSpecifier(dataMaxY))); + // X + const x = scaleTime() + .domain([groups[0], groups[groups.length - 1]]) + .range([0, INNER_WIDTH]); + const xAxisSelection = svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(0, ${INNER_HEIGHT})`) + .call(buildXAxis(x)); - svg.append("g") + // Y + const y = scaleLinear().domain([0, yMax]).range([INNER_HEIGHT, 0]); + const yAxisSelection = svg.append("g") .attr("class", "axis axis--y") - .call(yAxisGrid); + .call(buildYAxis(y, yMax)); - const stackedData = stack().keys(subgroups)(data); + // clip path + svg.append("defs") + .append("clipPath") + .attr("id", `clip-${chartId}`) + .append("rect") + .attr("width", INNER_WIDTH) + .attr("height", height) + .attr("x", 0) + .attr("y", 0); - svg.append("g") + // brushing + const brush = brushX() + .extent([[0, 0], [INNER_WIDTH, height]]) + .on("end", e => onBrushHandler(e as D3BrushEvent<{ [key: string]: number }>)); + + const brushSelection = svg.append("g") + .attr("class", "brush") + .call(brush); + + // bars + const barsSelection = svg.append("g") + .attr("class", "stacked-bars") + .attr("clip-path", `url(#clip-${chartId})`) .selectAll("g") .data(stackedData) .join("g") @@ -108,29 +133,69 @@ const StackedBarChart: React.FC = ({ .selectAll("rect") .data(d => d) .join("rect") - .attr("x", d => x(moment.unix(d.data.time).format(DAY_LABEL_FORMAT)) ?? 0) + .attr("x", d => x(timestampToDate(d.data.time)) - ((INNER_WIDTH / data.length) / 2)) .attr("y", d => y(d[1])) + .attr("rx", 2) .attr("class", (_, i) => `stacked-bar rect-${i}`) .on("mouseover", mouseoverHandler) .on("mouseout", mouseoutHandler) .attr("height", d => y(d[0]) - y(d[1])) - .attr("width", x.bandwidth()); - - const tickValues = timespan === "7" ? - x.domain() : - // every third label - x.domain().filter((_, i) => !(i % 3)); - const xAxis = axisLabelRotate( - axisBottom(x).tickValues(tickValues) - ); + .attr("width", INNER_WIDTH / data.length); - svg.append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${INNER_HEIGHT})`) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .call(xAxis); + const onBrushHandler = (event: D3BrushEvent<{ [key: string]: number }>) => { + if (!event.selection) { + return; + } + const extent = event.selection; + if (!extent) { + x.domain([groups[0], groups[groups.length - 1]]); + } else { + x.domain([x.invert(extent[0] as NumberValue), x.invert(extent[1] as NumberValue)]); + // eslint-disable-next-line @typescript-eslint/unbound-method + brushSelection.call(brush.move, null); + } + + const selectedData = computeDataIncludedInSelection(x, data); + + // to prevent infinite brushing + if (selectedData.length > 1) { + const yMaxUpdate = Math.max( + ...selectedData.map(d => { + let sum = 0; + for (const key of subgroups) { + sum += d[key]; + } + return sum; + }) + ); + y.domain([0, yMaxUpdate]); + // Update axis + xAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildXAxis(x)); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMaxUpdate)); + + // Update bars + barsSelection.transition().duration(TRANSITIONS_DURATION_MS) + .attr("x", d => x(timestampToDate(d.data.time)) - ((INNER_WIDTH / selectedData.length) / 2)) + .attr("y", d => y(d[1])) + .attr("height", d => y(d[0]) - y(d[1])) + .attr("width", INNER_WIDTH / selectedData.length); + } + }; + + // double click reset + svg.on("dblclick", () => { + x.domain([groups[0], groups[groups.length - 1]]); + xAxisSelection.transition().call(buildXAxis(x)); + y.domain([0, yMax]); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMax)); + barsSelection.transition().duration(TRANSITIONS_DURATION_MS) + .attr("x", d => x(timestampToDate(d.data.time)) - ((INNER_WIDTH / data.length) / 2)) + .attr("y", d => y(d[1])) + .attr("height", d => y(d[0]) - y(d[1])) + .attr("width", INNER_WIDTH / data.length); + }); } - }, [data, timespan, wrapperWidth, wrapperHeight]); + }, [data, wrapperWidth, wrapperHeight]); /** * Handles mouseover event of a bar "part" @@ -173,7 +238,6 @@ const StackedBarChart: React.FC = ({ labels: groupLabels ?? subgroups, colors }} - onTimespanSelected={value => setTimespan(value)} disabled={data.length === 0} /> {data.length === 0 ? ( diff --git a/client/src/app/components/stardust/statistics/charts/StackedLineChart.tsx b/client/src/app/components/stardust/statistics/charts/StackedLineChart.tsx index 1639134b7..fa05b06cd 100644 --- a/client/src/app/components/stardust/statistics/charts/StackedLineChart.tsx +++ b/client/src/app/components/stardust/statistics/charts/StackedLineChart.tsx @@ -1,20 +1,21 @@ -import { axisBottom, axisLabelRotate } from "@d3fc/d3fc-axis"; import classNames from "classnames"; -import { axisLeft } from "d3-axis"; -import { format } from "d3-format"; -import { scaleTime, scaleLinear, scaleOrdinal } from "d3-scale"; +import { brushX, D3BrushEvent } from "d3-brush"; +import { scaleTime, scaleLinear, scaleOrdinal, NumberValue } from "d3-scale"; import { BaseType, select } from "d3-selection"; import { area, line, SeriesPoint, stack } from "d3-shape"; -import { timeFormat } from "d3-time-format"; -import moment from "moment"; -import React, { useCallback, useLayoutEffect, useRef, useState } from "react"; +import React, { useCallback, useLayoutEffect, useRef } from "react"; import { ModalData } from "../../../ModalProps"; -import ChartHeader, { TimespanOption } from "../ChartHeader"; +import ChartHeader from "../ChartHeader"; import ChartTooltip from "../ChartTooltip"; import { - d3FormatSpecifier, + buildXAxis, + buildYAxis, + computeDataIncludedInSelection, + computeHalfLineWidth, determineGraphLeftPadding, noDataView, + timestampToDate, + TRANSITIONS_DURATION_MS, useChartWrapperSize, useMultiValueTooltip, useTouchMoveEffect @@ -22,6 +23,7 @@ import { import "./Chart.scss"; interface StackedLineChartProps { + chartId: string; title?: string; info?: ModalData; subgroups: string[]; @@ -31,6 +33,7 @@ interface StackedLineChartProps { } const StackedLineChart: React.FC = ({ + chartId, title, info, subgroups, @@ -46,7 +49,6 @@ const StackedLineChart: React.FC = ({ }, []); const theSvg = useRef(null); const theTooltip = useRef(null); - const [timespan, setTimespan] = useState("all"); const buildTootip = useMultiValueTooltip(data, subgroups, colors, groupLabels); useTouchMoveEffect(mouseoutHandler); @@ -58,64 +60,62 @@ const StackedLineChart: React.FC = ({ // reset select(theSvg.current).selectAll("*").remove(); - data = timespan !== "all" ? data.slice(-timespan) : data; - - const dataMaxY = Math.max(...data.map(d => Math.max(...subgroups.map(key => d[key])))); - const leftMargin = determineGraphLeftPadding(dataMaxY); - + // chart dimensions + const yMax = Math.max(...data.map(d => Math.max(...subgroups.map(key => d[key])))); + const leftMargin = determineGraphLeftPadding(yMax); const MARGIN = { top: 30, right: 20, bottom: 50, left: leftMargin }; const INNER_WIDTH = width - MARGIN.left - MARGIN.right; const INNER_HEIGHT = height - MARGIN.top - MARGIN.bottom; - const timestampToDate = (timestamp: number) => moment.unix(timestamp) - .hours(0) - .minutes(0) - .toDate(); - const color = scaleOrdinal().domain(subgroups).range(colors); const stackedData = stack().keys(subgroups)(data); + const groups = data.map(d => timestampToDate(d.time)); - const groups = data.map( - d => timestampToDate(d.time) - ); - + // SVG const svg = select(theSvg.current) .attr("viewBox", `0 0 ${width} ${height}`) .attr("preserveAspectRatio", "none") .append("g") .attr("transform", `translate(${MARGIN.left}, ${MARGIN.top})`); + // X const x = scaleTime() .domain([groups[0], groups[groups.length - 1]]) .range([0, INNER_WIDTH]); - const y = scaleLinear().domain([0, dataMaxY]) - .range([INNER_HEIGHT, 0]); - - const yAxisGrid = axisLeft(y.nice()).tickFormat(format(d3FormatSpecifier(dataMaxY))); + const xAxisSelection = svg.append("g") + .attr("class", "axis axis--x") + .attr("transform", `translate(0, ${INNER_HEIGHT})`) + .call(buildXAxis(x)); - svg.append("g") + // Y + const y = scaleLinear().domain([0, yMax]).range([INNER_HEIGHT, 0]); + const yAxisSelection = svg.append("g") .attr("class", "axis axis--y") - .call(yAxisGrid); - - const xAxis = axisLabelRotate( - axisBottom(x).tickFormat(timeFormat("%d %b")) - ); + .call(buildYAxis(y, yMax)); - svg.append("g") - .attr("class", "axis axis--x") - .attr("transform", `translate(0, ${INNER_HEIGHT})`) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - .call(xAxis); + // clip path + svg.append("defs") + .append("clipPath") + .attr("id", `clip-${chartId}`) + .append("rect") + .attr("width", INNER_WIDTH) + .attr("height", height) + .attr("x", 0) + .attr("y", 0); + // area fill const areaGen = area>() .x(d => x(timestampToDate(d.data.time)) ?? 0) .y0(_ => y(0)) .y1(d => y(d[1] - d[0])); - svg.append("g") - .selectAll("g") + const theArea = svg.append("g") + .attr("class", "areas") + .attr("clip-path", `url(#clip-${chartId})`); + + const areaSelection = theArea.selectAll("g") .data(stackedData) .join("path") .style("fill", d => getGradient(d.key, color(d.key))) @@ -123,65 +123,131 @@ const StackedLineChart: React.FC = ({ .attr("class", "area") .attr("d", areaGen); + // area lines path const lineGen = line>() .x(d => x(timestampToDate(d.data.time)) ?? 0) .y(d => y(d[1] - d[0])); - svg.append("g") + const lineSelection = svg.append("g") + .attr("class", "lines") + .attr("clip-path", `url(#clip-${chartId})`) .selectAll("g") .data(stackedData) .join("path") .attr("fill", "none") .attr("stroke", d => color(d.key)) .attr("stroke-width", 2) + .attr("class", "line") .attr("d", lineGen); - for (const dataStack of stackedData) { + const attachOnHoverLinesAndCircles = () => { + svg.selectAll(".hover-circles").remove(); + svg.selectAll(".hover-lines").remove(); + + const halfLineWidth = computeHalfLineWidth(data, x); + for (const dataStack of stackedData) { + svg.append("g") + .attr("class", "hover-circles") + .attr("clip-path", `url(#clip-${chartId})`) + .selectAll("g") + .data(dataStack) + .enter() + .append("circle") + .attr("fill", color(dataStack.key)) + .style("stroke", color(dataStack.key)) + .style("stroke-width", 5) + .style("stroke-opacity", 0) + .attr("cx", d => x(timestampToDate(d.data.time)) ?? 0) + .attr("cy", d => y(d[1] - d[0])) + .attr("r", 0) + .attr("class", (_, i) => `circle-${i}`); + } + + // hover lines for tooltip svg.append("g") + .attr("class", "hover-lines") + .attr("clip-path", `url(#clip-${chartId})`) .selectAll("g") - .data(dataStack) + .data(data) .enter() - .append("circle") - .attr("fill", color(dataStack.key)) - .style("stroke", color(dataStack.key)) - .style("stroke-width", 5) - .style("stroke-opacity", 0) - .attr("cx", d => x(timestampToDate(d.data.time)) ?? 0) - .attr("cy", d => y(d[1] - d[0])) - .attr("r", 1) - .attr("class", (_, i) => `circle-${i}`); - } - - const halfLineWidth = data.length > 1 ? - ((x(timestampToDate(data[1].time)) ?? 0) - (x(timestampToDate(data[0].time)) ?? 0)) / 2 : - 18; - - svg.append("g") - .attr("class", "hover-lines") - .selectAll("g") - .data(data) - .enter() - .append("rect") - .attr("fill", "transparent") - .attr("x", (_, idx) => ( - idx === 0 ? 0 : (x(timestampToDate(data[idx].time)) ?? 0) - halfLineWidth - )) - .attr("y", 0) - .attr("class", (_, i) => `rect-${i}`) - .attr("height", INNER_HEIGHT) - .attr("width", (_, idx) => { - if (idx === 0) { - return halfLineWidth; - } else if (idx === data.length - 1) { - return halfLineWidth; + .append("rect") + .attr("fill", "transparent") + .attr("x", (_, idx) => ( + idx === 0 ? 0 : (x(timestampToDate(data[idx].time)) ?? 0) - halfLineWidth + )) + .attr("y", 0) + .attr("class", (_, i) => `rect-${i}`) + .attr("height", INNER_HEIGHT) + .attr("width", (_, idx) => ( + (idx === 0 || idx === data.length - 1) ? + halfLineWidth : halfLineWidth * 2 + )) + .on("mouseover", mouseoverHandler) + .on("mouseout", mouseoutHandler); + }; + + // brushing + const brush = brushX() + .extent([[0, 0], [INNER_WIDTH, height]]) + .on("end", e => onBrushHandler(e as D3BrushEvent<{ [key: string]: number }>)); + + const brushSelection = svg.append("g") + .attr("class", "brush") + .call(brush); + + let idleTimeout: NodeJS.Timer | null = null; + const idled = () => { + idleTimeout = null; + }; + const onBrushHandler = (event: D3BrushEvent<{ [key: string]: number }>) => { + if (!event.selection) { + return; + } + const extent = event.selection; + if (!extent) { + if (!idleTimeout) { + idleTimeout = setTimeout(idled, 350); + return idleTimeout; } - - return halfLineWidth * 2; - }) - .on("mouseover", mouseoverHandler) - .on("mouseout", mouseoutHandler); + x.domain([groups[0], groups[groups.length - 1]]); + } else { + x.domain([x.invert(extent[0] as NumberValue), x.invert(extent[1] as NumberValue)]); + // eslint-disable-next-line @typescript-eslint/unbound-method + brushSelection.call(brush.move, null); + } + + const selectedData = computeDataIncludedInSelection(x, data); + + // to prevent infinite brushing + if (selectedData.length > 1) { + const yMaxUpdate = Math.max(...selectedData.map(d => Math.max(...subgroups.map(key => d[key])))); + y.domain([0, yMaxUpdate]); + // Update axis + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMaxUpdate)); + xAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildXAxis(x)); + // Update area and lines + areaSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", areaGen); + lineSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", lineGen); + // rebuild the hover activated lines & cicles + attachOnHoverLinesAndCircles(); + } + }; + + // double click reset + svg.on("dblclick", () => { + x.domain([groups[0], groups[groups.length - 1]]); + xAxisSelection.transition().call(buildXAxis(x)); + y.domain([0, yMax]); + yAxisSelection.transition().duration(TRANSITIONS_DURATION_MS).call(buildYAxis(y, yMax)); + areaSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", areaGen); + lineSelection.transition().duration(TRANSITIONS_DURATION_MS).attr("d", lineGen); + + attachOnHoverLinesAndCircles(); + }); + + attachOnHoverLinesAndCircles(); } - }, [data, timespan, wrapperWidth, wrapperHeight]); + }, [data, wrapperWidth, wrapperHeight]); /** * Get linear gradient for selected color @@ -258,7 +324,7 @@ const StackedLineChart: React.FC = ({ select(theSvg.current) .selectAll(`.circle-${idx}`) - .attr("r", 1) + .attr("r", 0) .style("stroke-opacity", 0); activeElement @@ -271,7 +337,6 @@ const StackedLineChart: React.FC = ({ setTimespan(value)} legend={{ labels: groupLabels ?? subgroups, colors diff --git a/client/src/app/routes.tsx b/client/src/app/routes.tsx index 765215e43..c3ac14a0a 100644 --- a/client/src/app/routes.tsx +++ b/client/src/app/routes.tsx @@ -47,7 +47,7 @@ import { VisualizerRouteProps } from "./routes/VisualizerRouteProps"; * @yields The next value. * @returns The iterator. */ -function *keyGenerator(count: number): IterableIterator { +function* keyGenerator(count: number): IterableIterator { while (true) { yield count++; } diff --git a/client/src/helpers/stardust/statisticsUtils.ts b/client/src/helpers/stardust/statisticsUtils.ts index c61063ef8..b53eb2486 100644 --- a/client/src/helpers/stardust/statisticsUtils.ts +++ b/client/src/helpers/stardust/statisticsUtils.ts @@ -114,3 +114,14 @@ export function mapDailyStatsToGraphsData(data: IInfluxDailyResponse): IStatisti }; } +/** + * Generator function to yield incrementing string ids. + * @yields next id. + */ +export function* idGenerator(): Generator { + let id: number = 0; + while (true) { + yield (id++).toString(); + } +} + diff --git a/client/src/models/api/stardust/feed/IFeedBlockData.ts b/client/src/models/api/stardust/feed/IFeedBlockData.ts index fd557c088..031e792e4 100644 --- a/client/src/models/api/stardust/feed/IFeedBlockData.ts +++ b/client/src/models/api/stardust/feed/IFeedBlockData.ts @@ -1,7 +1,7 @@ import { HexEncodedString, IMilestonePayload } from "@iota/iota.js-stardust"; import { IFeedBlockMetadata } from "./IFeedBlockMetadata"; -export interface IFeedBlockProperties { +interface IFeedBlockProperties { index?: number; tag?: HexEncodedString; timestamp?: number;