Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/platform/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"typeface-inter": "^3.3.0",
"typeface-roboto-mono": "^1.1.13",
"ui": "*",
"@thehyve/data-metrics-plugin": "*",
"uuid": "^9.0.0",
"xlsx": "^0.18.5"
},
Expand Down
3,190 changes: 3,190 additions & 0 deletions apps/platform/public/metrics_25-09.csv

Large diffs are not rendered by default.

817 changes: 817 additions & 0 deletions apps/platform/public/metrics_25-12.csv

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions apps/platform/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import CredibleSetPage from "./pages/CredibleSetPage";
import APIPage from "./pages/APIPage";
import NotFoundPage from "./pages/NotFoundPage";
import ProjectsPage from "./pages/ProjectsPage";
import { DataMetricsPage } from "@thehyve/data-metrics-plugin";

const config = getConfig();

Expand All @@ -34,6 +35,7 @@ function App(): ReactElement {
<Route path="/" element={<HomePage />} />
<Route path="/api" element={<APIPage />} />
<Route path="/search" element={<SearchPage />} />
<Route path="/data-metrics/*" element={<DataMetricsPage currentRelease="25.12" previousRelease="25.09" currentMetricsFile={`metrics_25-12.csv`} previousMetricsFile={`metrics_25-09.csv`} />} />
<Route path="/downloads/*" element={<DownloadsPage />} />
<Route path="/target/:ensgId/*" element={<TargetPage />} />
<Route path="/disease/:efoId/*" element={<DiseasePage />} />
Expand Down
75 changes: 75 additions & 0 deletions packages/data-metrics-plugin/DataMetricsPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { faMagnifyingGlass } from "@fortawesome/free-solid-svg-icons";
import { useEffect, useState } from "react";
import { BasePage, Header } from "ui";
import DataMetricsTotalCards from "./DataMetricsTotalCards";
import DownloadLink from "./DownloadLink";
import EvidenceDataMetricsSection from "./EvidenceDataMetricsSection";
import { DataMetricsPageProps, Metric } from "./types";

function parseCSV(text: string): Metric[] {
if (!text) return [];
const lines = text.trim().split(/\r?\n/);
const headers = lines[0].split(",");
return lines.slice(1).map((line) => {
const values = line.split(",");
const obj: Metric = {};
headers.forEach((h, i) => {
obj[h] = values[i];
});
return obj;
});
}

function DataMetricsPage({
currentRelease,
previousRelease,
currentMetricsFile,
previousMetricsFile,
}: DataMetricsPageProps) {
const [metrics, setMetrics] = useState<Metric[]>([]);
const [prevMetrics, setPrevMetrics] = useState<Metric[]>([]);

useEffect(() => {
fetch(`./${currentMetricsFile}`)
.then((response) => response.text())
.then((text) => {
const parsed = parseCSV(text);
setMetrics(parsed);
});
fetch(`./${previousMetricsFile}`)
.then((response) => response.text())
.then((text) => {
const parsed = parseCSV(text);
setPrevMetrics(parsed);
});
}, [currentMetricsFile, previousMetricsFile]);

return (
<BasePage>
<>
<Header
loading={false}
title={"Data Metrics"}
subtitle={`v${currentRelease}`}
Icon={faMagnifyingGlass}
externalLinks={
<>
<DownloadLink
title={`Download Metrics v${currentRelease}`}
file={currentMetricsFile}
/>
<DownloadLink
title={`Download Metrics v${previousRelease} (prev)`}
file={previousMetricsFile}
/>
</>
}
/>
<DataMetricsTotalCards metrics={metrics} prevMetrics={prevMetrics} />
<EvidenceDataMetricsSection metrics={metrics} prevMetrics={prevMetrics} />
</>
</BasePage>
);
}

export default DataMetricsPage;
182 changes: 182 additions & 0 deletions packages/data-metrics-plugin/DataMetricsPieChart.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: must be interactive */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: no keyboard interactions desired */
/** biome-ignore-all lint/a11y/useKeyWithMouseEvents: no keyboard interactions desired */
import { Box } from "@mui/material";
import * as d3 from "d3";
import { useRef, useState } from "react";
import { DataMetricsPieChartProps, Tooltip } from "./types";

// Pie chart for datasource metrics
function DataMetricsPieChart({ data, width = 520, height = 520 }: DataMetricsPieChartProps) {
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
const [tooltip, setTooltip] = useState<Tooltip | null>(null);
const [activeLabels, setActiveLabels] = useState<string[]>(data.map((d) => d.label));
const svgRef = useRef<SVGSVGElement>(null);

const pie = d3.pie<{ label: string; value: number }>().value((d) => d.value);
const arc = d3
.arc()
.innerRadius(0)
.outerRadius(Math.min(width, height) / 2 - 10);
// Use a fixed color palette and assign each label a color based on its index in the original data array
const colorPalette = d3.schemeCategory10;
const labelColorMap: Record<string, string> = {};
data.forEach((d, i) => {
labelColorMap[d.label] = colorPalette[i % colorPalette.length];
});
// Only include active datasources
const filteredData = data.filter((d) => activeLabels.includes(d.label));
const pieData = pie(filteredData);
function handleLegendClick(label: string) {
setActiveLabels((prev) =>
prev.includes(label) ? prev.filter((l) => l !== label) : [...prev, label]
);
}

function handleMouseOver(
d: d3.PieArcDatum<{ label: string; value: number }>,
i: number
) {
setHoveredIdx(i);
const [x, y] = arc.centroid(d as any);
setTooltip({
x: x + width / 2,
y: y + height / 2,
label: d.data.label,
value: d.data.value,
});
}

function handleMouseOut() {
setHoveredIdx(null);
setTooltip(null);
}

// Only show label for slices > 6% of total
const totalValue = filteredData.reduce((sum, d) => sum + d.value, 0);
const minLabelPercent = 0.06;

return (
<Box
sx={{
display: "flex",
flexDirection: "row",
justifyContent: "center",
alignItems: "center",
position: "relative",
}}
>
<Box sx={{ position: "relative" }}>
<svg ref={svgRef} width={width} height={height}>
<title>Data Metrics Pie Chart</title>
<g transform={`translate(${width / 2},${height / 2})`}>
{pieData.map((d, i) => {
const percent = d.data.value / totalValue;
const color = labelColorMap[d.data.label];
return (
<g key={d.data.label}>
<path
d={arc(d as any) ?? undefined}
fill={color}
stroke={hoveredIdx === i ? "#222" : "#fff"}
strokeWidth={hoveredIdx === i ? 3 : 1}
style={{
cursor: "pointer",
opacity: hoveredIdx === null || hoveredIdx === i ? 1 : 0.7,
}}
onMouseOver={() => handleMouseOver(d, i)}
onMouseOut={handleMouseOut}
/>
{/* Only show label if slice is large enough */}
{percent > minLabelPercent && (
<text
transform={`translate(${arc.centroid(d as any)})`}
textAnchor="middle"
alignmentBaseline="middle"
fontSize={14}
fill="#fff"
pointerEvents="none"
style={{ textShadow: "0 1px 4px #000" }}
>
{d.data.label}
</text>
)}
</g>
);
})}
</g>
</svg>
{tooltip && (
<Box
sx={{
position: "absolute",
left: tooltip.x,
top: tooltip.y,
background: "rgba(0,0,0,0.85)",
color: "#fff",
px: 2,
py: 1,
borderRadius: 1,
pointerEvents: "none",
fontSize: 14,
zIndex: 10,
transform: "translate(-50%, -120%)",
}}
>
<div>
<strong>{tooltip.label}</strong>
</div>
<div>{tooltip.value.toLocaleString()}</div>
</Box>
)}
</Box>
{/* Legend for all datasources */}
<Box sx={{ ml: 4, minWidth: 120 }}>
<ul style={{ listStyle: "none", padding: 0, margin: 0 }}>
{data.map((d) => {
const isActive = activeLabels.includes(d.label);
const color = labelColorMap[d.label];
// Highlight if hovered in pie (find index in filteredData)
const filteredIdx = filteredData.findIndex((fd) => fd.label === d.label);
return (
<li
key={d.label}
style={{
display: "flex",
alignItems: "center",
marginBottom: 8,
opacity: isActive ? 1 : 0.4,
cursor: "pointer",
}}
onClick={() => handleLegendClick(d.label)}
title={isActive ? "Click to exclude" : "Click to include"}
>
<span
style={{
width: 18,
height: 18,
background: color,
display: "inline-block",
marginRight: 8,
borderRadius: 4,
border: "1px solid #ccc",
}}
/>
<span
style={{
fontWeight: hoveredIdx === filteredIdx ? "bold" : "normal",
textDecoration: isActive ? "none" : "line-through",
}}
>
{d.label}
</span>
</li>
);
})}
</ul>
</Box>
</Box>
);
}

export default DataMetricsPieChart;
100 changes: 100 additions & 0 deletions packages/data-metrics-plugin/DataMetricsTable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import React from "react";
import { OtTable } from "ui";
import { DataMetricsTableProps, Row} from "./types";

function DataMetricsTable({ metrics, prevMetrics, variables, sources }: DataMetricsTableProps) {
// Define columns for OtTable
const columns = [
{
id: "datasource",
label: "Datasource",
enableHiding: false,
renderCell: (row: Row) => row.datasource.display,
exportLabel: "Datasource",
propertyPath: "datasource.value",
},
...variables.map((variable) => ({
id: variable,
label: variable,
align: "right",
renderCell: (row: Row) => row[variable].display,
exportLabel: variable,
propertyPath: `${variable}.value`,
})),
];

// Prepare rows for OtTable
const rows: Row[] = Array.from(sources)
.sort()
.map((ds) => {
const row: Row = {
datasource: {
display: ds,
value: ds,
},
};
for (const variable of variables) {
const metric = metrics.find((m) => m.datasourceId === ds && m.variable === variable);
const prevMetric = prevMetrics.find(
(pm) => pm.datasourceId === ds && pm.variable === variable
);
if (metric || prevMetric) {
const currValue = metric ? Number(metric.value) : 0;
const prevValue = prevMetric ? Number(prevMetric.value) : 0;
const diff = currValue - prevValue;
const pct = prevValue !== 0 ? (diff / prevValue) * 100 : null;
let display: React.ReactNode = currValue.toLocaleString();
if (prevMetric) {
display = (
<span>
{currValue.toLocaleString()}{" "}
<span
style={{
color: diff > 0 ? "green" : diff < 0 ? "red" : undefined,
fontSize: "0.9em",
}}
>
{diff > 0 ? "▲" : diff < 0 ? "▼" : ""}{" "}
{diff !== 0 ? Math.abs(diff).toLocaleString() : ""}{" "}
{pct !== null ? `(${pct.toFixed(1)}%)` : ""}
</span>
</span>
);
}
row[variable] = {
display,
value: currValue.toString(),
};
} else {
row[variable] = {
display: "-",
value: "-",
};
}
}
return row;
});

return (
<OtTable
showGlobalFilter={false} // This would require some refactoring to work well. The filter function in OtTable assumes a single value per cell, not an object with display and value. Just using a object would not work because the object is JSX (instead of a value) and cannot be searched.
columns={columns}
rows={rows}
dataDownloaderFileStem={"data-metrics-table"}
tableDataLoading={false}
verticalHeaders={false}
order={"desc"}
sortBy={""}
defaultSortObj={undefined}
dataDownloader={false}
query={null as any}
variables={{}}
showColumnVisibilityControl={false}
loading={false}
enableMultipleRowSelection={false}
getSelectedRows={null as any}
/>
);
}

export default DataMetricsTable;
Loading
Loading