diff --git a/app/package.json b/app/package.json index b1d6009..dad57a1 100644 --- a/app/package.json +++ b/app/package.json @@ -41,10 +41,11 @@ "@mui/x-date-pickers": "^6.16.2", "@react-spring/web": "^9.7.3", "@reduxjs/toolkit": "1.9.7", + "@sgratzl/chartjs-chart-boxplot": "^4.2.8", "@svgr/webpack": "8.1.0", "axios": "^1.6.1", "axios-mock-adapter": "1.22.0", - "chart.js": "4.4.0", + "chart.js": "^4.4.0", "date-fns": "2.30.0", "deepmerge": "4.3.1", "formik": "2.4.5", diff --git a/app/src/components/charts/HorizontalBoxPlot.js b/app/src/components/charts/HorizontalBoxPlot.js new file mode 100644 index 0000000..bb3bde8 --- /dev/null +++ b/app/src/components/charts/HorizontalBoxPlot.js @@ -0,0 +1,89 @@ +import React, { useEffect } from "react"; +import { + BoxPlotController, + BoxAndWiskers, +} from "@sgratzl/chartjs-chart-boxplot"; +import { Chart, registerables } from "chart.js"; +import { externalTooltipHandler } from "../charts/helpers"; +Chart.register(...registerables, BoxPlotController, BoxAndWiskers); + +const HorizontalBoxPlot = (props) => { + const { data, chartId } = props; + + useEffect(() => { + if (!data) { + return; + } + + const ctx = document.getElementById(chartId).getContext("2d"); + + // Calculate box plot data + // Simplified. Server should return median and quantiles for correct results + const boxplotData = { + min: data.min, + whiskerMin: data.min, + q1: data.mean - data.stdev, + median: data.mean, + mean: data.mean, + q3: data.mean + data.stdev, + max: data.max, + whiskerMax: data.max, + }; + + const chartData = { + labels: [""], + datasets: [ + { + label: "", + data: [boxplotData], + }, + ], + }; + + const myChart = new Chart(ctx, { + type: "boxplot", + data: chartData, + options: { + indexAxis: "y", + minStats: "whiskerMin", + maxStats: "whiskerMax", + responsive: true, + maintainAspectRatio: false, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + position: "nearest", + external: externalTooltipHandler, + }, + }, + scales: { + x: { + min: data.min, + max: data.max, + ticks: { + font: { + size: 10, + }, + }, + }, + y: { + grid: { + display: false, + }, + }, + }, + }, + }); + + return () => { + myChart.destroy(); + }; + }, [data]); + + return ; +}; + +export default HorizontalBoxPlot; diff --git a/app/src/components/charts/StackedBarChart.js b/app/src/components/charts/StackedBarChart.js new file mode 100644 index 0000000..a30653e --- /dev/null +++ b/app/src/components/charts/StackedBarChart.js @@ -0,0 +1,78 @@ +import React, { useEffect } from "react"; +import { + BoxPlotController, + BoxAndWiskers, +} from "@sgratzl/chartjs-chart-boxplot"; +import { Chart, registerables } from "chart.js"; +import { externalTooltipHandler } from "./helpers"; +Chart.register(...registerables, BoxPlotController, BoxAndWiskers); + +const StackedBarChart = (props) => { + const { data, chartId, showX, targets } = props; + useEffect(() => { + if (!data || data.length === 0) { + return; + } + const ctx = document.getElementById(chartId).getContext("2d"); + + // Assuming data is in the format: [categories, [class1Data, class2Data, ...]] + const categories = data[0]; + const classData = data[1]; + + // Transpose classData to get data per category + const transposedData = categories.map((_, ci) => + classData.map((row) => row[ci]), + ); + + const datasets = transposedData.map((data, index) => ({ + label: targets[index], + data: data, + borderWidth: 1, + })); + + const myChart = new Chart(ctx, { + type: "bar", + data: { + labels: categories, + datasets: datasets, + }, + options: { + scales: { + x: { + stacked: true, + display: showX || categories.length < 5, + ticks: { + font: { + size: 10, + }, + }, + }, + y: { + stacked: true, + display: false, + }, + }, + plugins: { + legend: { + display: false, + }, + tooltip: { + enabled: false, + position: "nearest", + external: externalTooltipHandler, + }, + }, + responsive: true, + maintainAspectRatio: false, + }, + }); + + return () => { + myChart.destroy(); + }; + }, [data]); + + return ; +}; + +export default StackedBarChart; diff --git a/app/src/components/charts/helpers.js b/app/src/components/charts/helpers.js new file mode 100644 index 0000000..0028d44 --- /dev/null +++ b/app/src/components/charts/helpers.js @@ -0,0 +1,121 @@ +// External tooltip handler +// Based on https://www.chartjs.org/docs/latest/samples/tooltip/html.html +const getOrCreateTooltip = (chart) => { + let tooltipEl = chart.canvas.parentNode.querySelector("div"); + + if (!tooltipEl) { + tooltipEl = document.createElement("div"); + tooltipEl.style.background = "rgba(0, 0, 0, 0.7)"; + tooltipEl.style.borderRadius = "3px"; + tooltipEl.style.color = "white"; + tooltipEl.style.opacity = 1; + tooltipEl.style.pointerEvents = "none"; + tooltipEl.style.position = "absolute"; + tooltipEl.style.transform = "translate(-50%, 0)"; + tooltipEl.style.transition = "all .1s ease"; + + const table = document.createElement("table"); + table.style.margin = "0px"; + + tooltipEl.appendChild(table); + chart.canvas.parentNode.appendChild(tooltipEl); + } + + return tooltipEl; +}; + +export const externalTooltipHandler = (context) => { + // Tooltip Element + const { chart, tooltip } = context; + const tooltipEl = getOrCreateTooltip(chart); + + // Hide if no tooltip + if (tooltip.opacity === 0) { + tooltipEl.style.opacity = 0; + return; + } + + // Set Text + if (tooltip.body) { + const titleLines = tooltip.title || []; + const bodyLines = tooltip.body.map((b) => b.lines); + + const tableHead = document.createElement("thead"); + + titleLines.forEach((title) => { + const tr = document.createElement("tr"); + tr.style.borderWidth = 0; + + const th = document.createElement("th"); + th.style.borderWidth = 0; + const text = document.createTextNode(title); + + th.appendChild(text); + tr.appendChild(th); + tableHead.appendChild(tr); + }); + + const tableBody = document.createElement("tbody"); + bodyLines.forEach((body, i) => { + const colors = tooltip.labelColors[i]; + + const span = document.createElement("span"); + span.style.background = colors.backgroundColor; + span.style.borderColor = colors.borderColor; + span.style.borderWidth = "2px"; + span.style.marginRight = "10px"; + span.style.height = "10px"; + span.style.width = "10px"; + span.style.display = "inline-block"; + + const tr = document.createElement("tr"); + tr.style.backgroundColor = "inherit"; + tr.style.borderWidth = 0; + + const td = document.createElement("td"); + td.style.borderWidth = 0; + + let textLine = body[0]; + + // Boxplot data + if (textLine && textLine.startsWith("(")) { + textLine = textLine.substring(1, textLine.length - 1); + textLine.split(", ").forEach((item, index) => { + if (index > 0) { + td.appendChild(document.createElement("br")); + } + td.appendChild(document.createTextNode(item)); + }); + } else { + // Other data + const text = document.createTextNode(textLine); + td.appendChild(text); + } + + td.insertBefore(span, td.firstChild); + tr.appendChild(td); + tableBody.appendChild(tr); + }); + + const tableRoot = tooltipEl.querySelector("table"); + + // Remove old children + while (tableRoot.firstChild) { + tableRoot.firstChild.remove(); + } + + // Add new children + tableRoot.appendChild(tableHead); + tableRoot.appendChild(tableBody); + } + + const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas; + + // Display, position, and set styles for font + tooltipEl.style.opacity = 1; + tooltipEl.style.left = positionX + tooltip.caretX + "px"; + tooltipEl.style.top = positionY + tooltip.caretY + "px"; + tooltipEl.style.font = tooltip.options.bodyFont.string; + tooltipEl.style.padding = + tooltip.options.padding + "px " + tooltip.options.padding + "px"; +}; diff --git a/app/src/components/data/FeatureTable.js b/app/src/components/data/FeatureTable.js index 77f992a..beff6e4 100644 --- a/app/src/components/data/FeatureTable.js +++ b/app/src/components/data/FeatureTable.js @@ -1,18 +1,18 @@ -import { useEffect, React } from "react"; +import React from "react"; import Box from "@mui/material/Box"; import { DataGrid as MuiDataGrid } from "@mui/x-data-grid"; import { Card, CardContent, Typography } from "@mui/material"; import styled from "@emotion/styled"; -import { Chart, registerables } from "chart.js"; -Chart.register(...registerables); +import StackedBarChart from "../charts/StackedBarChart"; +import HorizontalBoxPlot from "../charts/HorizontalBoxPlot"; const CellContent = styled.span` font-weight: ${(props) => (props.isBold ? "bold" : "normal")}; `; const ChartBox = styled.div` - width: 200px; + width: 275px; height: 50px; `; @@ -22,254 +22,13 @@ const DataGrid = styled(MuiDataGrid)` } `; -// External tooltip handler -// See https://www.chartjs.org/docs/latest/samples/tooltip/html.html -const getOrCreateTooltip = (chart) => { - let tooltipEl = chart.canvas.parentNode.querySelector("div"); - - if (!tooltipEl) { - tooltipEl = document.createElement("div"); - tooltipEl.style.background = "rgba(0, 0, 0, 0.7)"; - tooltipEl.style.borderRadius = "3px"; - tooltipEl.style.color = "white"; - tooltipEl.style.opacity = 1; - tooltipEl.style.pointerEvents = "none"; - tooltipEl.style.position = "absolute"; - tooltipEl.style.transform = "translate(-50%, 0)"; - tooltipEl.style.transition = "all .1s ease"; - - const table = document.createElement("table"); - table.style.margin = "0px"; - - tooltipEl.appendChild(table); - chart.canvas.parentNode.appendChild(tooltipEl); - } - - return tooltipEl; -}; - -const externalTooltipHandler = (context) => { - // Tooltip Element - const { chart, tooltip } = context; - const tooltipEl = getOrCreateTooltip(chart); - - // Hide if no tooltip - if (tooltip.opacity === 0) { - tooltipEl.style.opacity = 0; - return; - } - - // Set Text - if (tooltip.body) { - const titleLines = tooltip.title || []; - const bodyLines = tooltip.body.map((b) => b.lines); - - const tableHead = document.createElement("thead"); - - titleLines.forEach((title) => { - const tr = document.createElement("tr"); - tr.style.borderWidth = 0; - - const th = document.createElement("th"); - th.style.borderWidth = 0; - const text = document.createTextNode(title); - - th.appendChild(text); - tr.appendChild(th); - tableHead.appendChild(tr); - }); - - const tableBody = document.createElement("tbody"); - bodyLines.forEach((body, i) => { - const colors = tooltip.labelColors[i]; - - const span = document.createElement("span"); - span.style.background = colors.backgroundColor; - span.style.borderColor = colors.borderColor; - span.style.borderWidth = "2px"; - span.style.marginRight = "10px"; - span.style.height = "10px"; - span.style.width = "10px"; - span.style.display = "inline-block"; - - const tr = document.createElement("tr"); - tr.style.backgroundColor = "inherit"; - tr.style.borderWidth = 0; - - const td = document.createElement("td"); - td.style.borderWidth = 0; - - const text = document.createTextNode(body); - - td.appendChild(span); - td.appendChild(text); - tr.appendChild(td); - tableBody.appendChild(tr); - }); - - const tableRoot = tooltipEl.querySelector("table"); - - // Remove old children - while (tableRoot.firstChild) { - tableRoot.firstChild.remove(); - } - - // Add new children - tableRoot.appendChild(tableHead); - tableRoot.appendChild(tableBody); - } - - const { offsetLeft: positionX, offsetTop: positionY } = chart.canvas; - - // Display, position, and set styles for font - tooltipEl.style.opacity = 1; - tooltipEl.style.left = positionX + tooltip.caretX + "px"; - tooltipEl.style.top = positionY + tooltip.caretY + "px"; - tooltipEl.style.font = tooltip.options.bodyFont.string; - tooltipEl.style.padding = - tooltip.options.padding + "px " + tooltip.options.padding + "px"; -}; - -function StackedBarChart({ data, chartId, showX, targets }) { - useEffect(() => { - if (!data) { - return; - } - const ctx = document.getElementById(chartId).getContext("2d"); - - // Assuming data is in the format: [categories, [class1Data, class2Data, ...]] - const categories = data[0]; - const classData = data[1]; - - // Transpose classData to get data per category - const transposedData = categories.map((_, ci) => - classData.map((row) => row[ci]), - ); - - const datasets = transposedData.map((data, index) => ({ - label: targets[index], - data: data, - borderWidth: 1, - })); - - const myChart = new Chart(ctx, { - type: "bar", - data: { - labels: categories, - datasets: datasets, - }, - options: { - scales: { - x: { - stacked: true, - display: showX, - }, - y: { - stacked: true, - display: false, - }, - }, - plugins: { - legend: { - display: false, - }, - tooltip: { - enabled: false, - position: "nearest", - external: externalTooltipHandler, - }, - }, - responsive: true, - maintainAspectRatio: false, - }, - }); - - return () => { - myChart.destroy(); - }; - }, [data]); - - return ; -} - -function randomValues(count, min, max) { - const delta = max - min; - return Array.from({ length: count }).map(() => Math.random() * delta + min); -} - -function HorizontalBoxPlot({ data, chartId }) { - useEffect(() => { - if (!data) { - return; - } - - const ctx = document.getElementById(chartId).getContext("2d"); - - // Unpack your data. Assuming data is in the format { min, max, mean, stdev } - const { min, max, mean, stdev } = data; - - // Calculate box plot data - const lowerQuartile = mean - stdev; // This is a simplification - const upperQuartile = mean + stdev; // This is a simplification - - const chartData = { - labels: ["Stats"], - datasets: [ - { - label: "Box Plot", - data: [randomValues(100, min, max)], - barPercentage: 0.5, - barThickness: 50, - maxBarThickness: 100, - minBarLength: 2, - errorBars: { - Stats: { plus: max - mean, minus: mean - min }, - }, - }, - ], - }; - - const myChart = new Chart(ctx, { - type: "bar", - data: chartData, - options: { - indexAxis: "y", - scales: { - x: { - display: false, - }, - y: { - display: true, - }, - }, - plugins: { - legend: { - display: false, - }, - tooltip: { - enabled: false, - }, - }, - responsive: true, - maintainAspectRatio: false, - }, - }); - - return () => { - myChart.destroy(); - }; - }, [data]); - - return ; -} - const FeatureTable = ({ data }) => { // Check for targets let targets = []; // Define the rows for the grid const rows = data.map((feature) => { const id = feature.index; // Rename index to id - if (feature.target === "1") { + if (feature.target === "1" && feature.distr) { targets = feature.distr[0]; } return { @@ -282,7 +41,7 @@ const FeatureTable = ({ data }) => { }); const columns = [ - { field: "id", headerName: "Index", type: "number", width: 90 }, + //{ field: "id", headerName: "Index", type: "number", width: 90 }, { field: "name", headerName: "Feature Name", @@ -295,37 +54,17 @@ const FeatureTable = ({ data }) => { return {params.value}; }, }, - { - field: "type", - headerName: "Type", - width: 100, - editable: true, - }, - { - field: "distinct", - headerName: "Distinct values", - type: "number", - width: 110, - editable: true, - }, - { - field: "missing", - headerName: "Missing values", - type: "number", - width: 110, - editable: true, - }, { field: "distr", headerName: "Distribution", - width: 200, + width: 280, renderCell: (params) => { const chartId = `chart-${params.row.id}`; // Assuming each row has a unique 'id' const stats = { - min: params.row.min, - max: params.row.max, - mean: params.row.mean, - stdev: params.row.stdev, + min: parseFloat(params.row.min), + max: parseFloat(params.row.max), + mean: parseFloat(params.row.mean), + stdev: parseFloat(params.row.stdev), }; return ( @@ -343,6 +82,27 @@ const FeatureTable = ({ data }) => { ); }, }, + { + field: "type", + headerName: "Type", + width: 90, + editable: true, + align: "right", + }, + { + field: "distinct", + headerName: "Distinct values", + type: "number", + width: 110, + editable: true, + }, + { + field: "missing", + headerName: "Missing values", + type: "number", + width: 110, + editable: true, + }, ]; return ( diff --git a/app/src/pages/d/[dataId].js b/app/src/pages/d/[dataId].js index bacc558..641303d 100644 --- a/app/src/pages/d/[dataId].js +++ b/app/src/pages/d/[dataId].js @@ -196,22 +196,6 @@ function Dataset({ data, error }) { value: "v." + data.version, icon: faCodeBranch, }, - { - label: "Data status", - value: data.status === "active" ? "verified" : data.status, - color: - data.status === "active" - ? "green" - : data.status === "deactivated" - ? "red" - : "orange", - icon: - data.status === "active" - ? faCheckCircle - : data.status === "deactivated" - ? faTimes - : faWrench, - }, { label: "Data format", value: data.format, @@ -236,6 +220,22 @@ function Dataset({ data, error }) { url: `/u/${data.uploader_id}`, avatar: {data.uploader ? data.uploader.charAt(0) : "X"}, }, + { + label: "Data status", + value: data.status === "active" ? "verified" : data.status, + color: + data.status === "active" + ? "green" + : data.status === "deactivated" + ? "red" + : "orange", + icon: + data.status === "active" + ? faCheckCircle + : data.status === "deactivated" + ? faTimes + : faWrench, + }, { label: "Data likes", value: data.nr_of_likes + " likes", @@ -261,7 +261,7 @@ function Dataset({ data, error }) { - + - + - + - +