From 6ddca1810a2746a20bd90edf190a999849441340 Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Wed, 15 Mar 2023 19:14:55 -0400 Subject: [PATCH] chore(deps): migrate from chartist to d3 (closes #1142) --- .github/actions/spelling/expect.txt | 1 - ARCHITECTURE.md | 2 - package.json | 1 - source/app/metrics/utils.mjs | 198 ++++++++++++++++++++++++++-- 4 files changed, 190 insertions(+), 12 deletions(-) diff --git a/.github/actions/spelling/expect.txt b/.github/actions/spelling/expect.txt index 43bd7c8c..0c3e2fbf 100644 --- a/.github/actions/spelling/expect.txt +++ b/.github/actions/spelling/expect.txt @@ -211,7 +211,6 @@ Nixinova NOASSERTION nocase nodeca -nodechartist nodejs notoken octicon diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16b018b9..218b4872 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -119,8 +119,6 @@ Below is a list of used packages. * To parse and handle emojis/[twemojis](https://github.com/twitter/twemoji) * [jshemas/openGraphScraper](https://github.com/jshemas/openGraphScraper) * To retrieve open graphs metadata -* [panosoft/node-chartist](https://github.com/panosoft/node-chartist) and [gionkunz/chartist-js](https://github.com/gionkunz/chartist-js) - * To display embed SVG charts * [rbren/rss-parser](https://github.com/rbren/rss-parser) * To parse RSS streams * [Nixinova/Linguist](https://github.com/Nixinova/Linguist) diff --git a/package.json b/package.json index fca3afba..ee6c4561 100644 --- a/package.json +++ b/package.json @@ -84,7 +84,6 @@ "color": "^4.2.3", "gifencoder": "^2.0.1", "libxmljs2": "^0.31.0", - "node-chartist": "^1.0.5", "rss": "^1.2.2", "yargs-parser": "^21.1.1" } diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 3e111bfa..621783d5 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -207,14 +207,6 @@ export function stripemojis(string) { return string.replace(/[^\p{L}\p{N}\p{P}\p{Z}^$\n]/gu, "") } -/**Chartist */ -export async function chartist() { - const css = `` - const {default: nodechartist} = await import(url.pathToFileURL(paths.join(__module(import.meta.url), "../../../node_modules", "/node-chartist/lib/index.js"))) - return (await nodechartist(...arguments)) - .replace(/class="ct-chart-line">/, `class="ct-chart-line">${css}`) -} - /**Language analyzer (single file) */ export async function language({filename, patch}) { console.debug(`metrics/language > ${filename}`) @@ -816,3 +808,193 @@ export class D3node { return this.element.select("svg").node()?.outerHTML || "" } } + +/** Graph utilities */ +export const Graph = { + /**Timeline graph */ + timeline() { + return this.graph("time", ...arguments) + }, + /**Line graph */ + line() { + return this.graph("line", ...arguments) + }, + /**Basic Graph */ + graph(type, data, {area = true, points = true, text = true, low = NaN, high = NaN, match = null, labels = null, width = 480, height = 315, ticks = 0} = {}) { + //Generate SVG + const margin = {top:10, left:10, right:10, bottom:45} + const d3n = new D3node() + const svg = d3n.createSVG(width, height) + + //Data + const X = data.map(({x}) => x) + const start = X.at(0) + const end = X.at(-1) + const Y = data.map(({y}) => y) + const extremum = Math.max(...Y) + high = !Number.isNaN(high) ? high : extremum + low = !Number.isNaN(low) ? low : 0 + const T = data.map(({text}, i) => text ?? Y[i]) + + //Time range + const x = (type === "time" ? d3.scaleTime() : d3.scaleLinear()) + .domain([start, end]) + .range([margin.top, width - margin.left - margin.right]) + let xticks = d3.axisBottom(x).tickSizeOuter(0) + if (labels) + xticks = xticks.tickFormat((_, i) => labels[i]) + if (ticks) + xticks = xticks.ticks(ticks) + svg.append("g") + .attr("transform", `translate(${margin.left},${height - margin.bottom})`) + .call(xticks) + .call(g => g.select(".domain").attr("stroke", "rgba(127, 127, 127, .8)")) + .call(g => g.selectAll(".tick line").attr("stroke-opacity", 0.5)) + .selectAll("text") + .attr("transform", "translate(-5,5) rotate(-45)") + .style("text-anchor", "end") + .style("font-size", 20) + .attr("fill", "rgba(127, 127, 127, .8)") + + //Data range + const y = d3.scaleLinear() + .domain([high, low]) + .range([margin.left, height - margin.top - margin.bottom]) + svg.append("g") + .attr("transform", `translate(${margin.left},${margin.top})`) + .call(d3.axisRight(y).ticks(Math.round(height/50)).tickSize(width - margin.left - margin.right)) + .call(g => g.select(".domain").remove()) + .call(g => g.selectAll(".tick line").attr("stroke-opacity", 0.5).attr("stroke-dasharray", "2,2")) + .call(g => g.selectAll(".tick text").attr("x", 0).attr("dy", -4)) + .selectAll("text") + .style("font-size", 20) + .attr("fill", "rgba(127, 127, 127, .8)") + + //Generate graph line + const datum = Y.map((y, i) => [X.at(i), y]) + const tdatum = Y.map((y, i) => [X.at(i), y, T[i]]) + const xticked = xticks.scale().ticks(xticks.ticks()[0]) + const yticked = match?.(tdatum, xticked) ?? tdatum + svg.append("path") + .datum(datum) + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr( + "d", + d3.line() + .curve(d3.curveLinear) + .x(d => x(d[0])) + .y(d => y(d[1])) + ) + .attr("fill", "transparent") + .attr("stroke", "#87ceeb") + .attr("stroke-width", 2) + + //Generate graph area + if (area) { + svg.append("path") + .datum(datum) + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr( + "d", + d3.area() + .curve(d3.curveLinear) + .x(d => x(d[0])) + .y0(d => y(d[1])) + .y1(() => y(low)), + ) + .attr("fill", "rgba(88, 166, 255, .1)") + } + + //Generate graph points + if (points) { + svg.append("g") + .selectAll("circle") + .data(yticked) + .join("circle") + .attr("transform", `translate(${margin.left},${margin.top})`) + .attr("cx", d => x(d[0])) + .attr("cy", d => y(d[1])) + .attr("r", 2) + .attr("fill", "#106cbc") + } + + //Generate graph text + if (text) { + svg.append("g") + .attr("fill", "currentColor") + .attr("text-anchor", "middle") + .attr("font-family", "sans-serif") + .attr("font-size", 10) + .attr("stroke", "white") + .attr("stroke-linejoin", "round") + .attr("stroke-width", 4) + .attr("paint-order", "stroke fill") + .selectAll("text") + .data(yticked) + .join("text") + .attr("transform", `translate(${margin.left},${margin.top-4})`) + .attr("x", d => x(d[0])) + .attr("y", d => y(d[1])) + .text(d => d[2] ? d[2] : "") + .attr("fill", "rgba(127, 127, 127, .8)") + } + + return d3n.svgString() + }, + /**Pie Graph */ + pie(data, {colors, width = 480, height = 315} = {}) { + //Generate SVG + const radius = Math.min(width, height) / 2 + const d3n = new D3node() + const svg = d3n.createSVG(width, height) + + //Data + const K = Object.keys(data) + const V = Object.values(data) + const I = d3.range(K.length).filter(i => !Number.isNaN(V[i])) + + //Construct arcs + const color = d3.scaleOrdinal(K, d3.schemeSpectral[K.length]) + const arcs = d3.pie().padAngle(1/radius).sort(null).value(i => V[i])(I) + const arc = d3.arc().innerRadius(0).outerRadius(radius) + const labels = d3.arc().innerRadius(radius/2).outerRadius(radius/2) + + svg.append("g") + .attr("transform", `translate(${width/2},${height/2})`) + .attr("stroke", "white") + .attr("stroke-width", 1) + .attr("stroke-linejoin", "round") + .selectAll("path") + .data(arcs) + .join("path") + .attr("fill", d => colors?.[K[d.data]] ?? color(K[d.data])) + .attr("d", arc) + .append("title") + .text(d => `${K[d.data]}\n${V[d.data]}`) + + svg.append("g") + .attr("transform", `translate(${width/2},${height/2})`) + .attr("font-family", "sans-serif") + .attr("font-size", 12) + .attr("text-anchor", "middle") + .attr("fill", "white") + .attr("stroke", "rbga(0,0,0,.9)") + .attr("paint-order", "stroke fill") + .selectAll("text") + .data(arcs) + .join("text") + .attr("transform", d => `translate(${labels.centroid(d)})`) + .selectAll("tspan") + .data(d => { + const lines = `${K[d.data]}\n${V[d.data]}`.split(/\n/) + return (d.endAngle - d.startAngle) > 0.25 ? lines : lines.slice(0, 1) + }) + .join("tspan") + .attr("x", 0) + .attr("y", (_, i) => `${i * 1.1}em`) + .attr("font-weight", (_, i) => i ? null : "bold") + .text(d => d) + + return d3n.svgString() + } +}