From 20a0ccfd9a9c42b8bb754f0a919e18808221a218 Mon Sep 17 00:00:00 2001 From: Arild Matsson Date: Tue, 28 May 2024 20:07:48 +0200 Subject: [PATCH] refactor: Replace Raphael with Chart.js for pie chart --- CHANGELOG.md | 6 + app/index.js | 1 - .../components/corpus-distribution-chart.ts | 65 ++++ .../corpus_chooser/corpus-time-graph.ts | 4 +- app/scripts/components/statistics.js | 106 ++----- app/scripts/pie-widget.js | 290 ------------------ app/translations/locale-eng.json | 5 +- app/translations/locale-swe.json | 5 +- package.json | 2 - yarn.lock | 17 - 10 files changed, 99 insertions(+), 402 deletions(-) create mode 100644 app/scripts/components/corpus-distribution-chart.ts delete mode 100644 app/scripts/pie-widget.js diff --git a/CHANGELOG.md b/CHANGELOG.md index f431a7541..eab7df77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## [Unreleased] + +### Changed + +- Replaced Raphael library with Chart.js, used in the pie chart over corpus distribution in statistics + ## [9.6.0] - 2024-05-27 ### Added diff --git a/app/index.js b/app/index.js index 97d47787f..2f057c617 100644 --- a/app/index.js +++ b/app/index.js @@ -56,7 +56,6 @@ require("angular-filter/index.js") require("./lib/jquery.tooltip.pack.js") -require("./scripts/pie-widget.js") require("./scripts/widgets.js") require("./scripts/main.js") require("./scripts/app.js") diff --git a/app/scripts/components/corpus-distribution-chart.ts b/app/scripts/components/corpus-distribution-chart.ts new file mode 100644 index 000000000..b72613988 --- /dev/null +++ b/app/scripts/components/corpus-distribution-chart.ts @@ -0,0 +1,65 @@ +/** @format */ +import angular, { IController, IRootScopeService } from "angular" +import { Chart } from "chart.js" +import { html } from "@/util" + +const defaultMode: Mode = "relative" + +angular.module("korpApp").component("corpusDistributionChart", { + template: html` +
+ + +
+ `, + bindings: { + row: "<", + }, + controller: [ + "$rootScope", + function ($rootScope: IRootScopeService) { + const $ctrl = this as CorpusDistributionChartController + let chart: Chart<"pie"> + + const getValues = (mode: Mode) => $ctrl.row.map((corpus) => corpus.values[mode == "relative" ? 1 : 0]) + + $ctrl.$onInit = () => { + chart = new Chart("distribution-chart", { + type: "pie", + data: { + labels: $ctrl.row.map((corpus) => corpus.title), + datasets: [{ data: getValues(defaultMode) }], + }, + options: { + locale: $rootScope["lang"], + plugins: { + legend: { + display: false, + }, + }, + }, + }) + + setTimeout(() => { + const radioList = ($("#statistics_switch") as any).radioList({ + selected: defaultMode, + change: () => { + const mode = radioList.radioList("getSelected").attr("data-mode") + chart.data.datasets[0].data = getValues(mode) + chart.update() + }, + }) + }) + } + }, + ], +}) + +type CorpusDistributionChartController = IController & { + row: { title: string; values: [number, number] }[] +} + +type Mode = "relative" | "absolute" diff --git a/app/scripts/components/corpus_chooser/corpus-time-graph.ts b/app/scripts/components/corpus_chooser/corpus-time-graph.ts index 3ce8c65a2..266589920 100644 --- a/app/scripts/components/corpus_chooser/corpus-time-graph.ts +++ b/app/scripts/components/corpus_chooser/corpus-time-graph.ts @@ -2,7 +2,7 @@ import angular, { IRootScopeService } from "angular" import range from "lodash/range" import { Chart } from "chart.js/auto" -import { getLang, loc } from "@/i18n" +import { loc } from "@/i18n" import { calculateYearTicks, getSeries, @@ -94,7 +94,7 @@ angular.module("korpApp").component("corpusTimeGraph", { minBarLength: 2, }, }, - locale: getLang(), + locale: $rootScope["lang"], plugins: { legend: { display: false, diff --git a/app/scripts/components/statistics.js b/app/scripts/components/statistics.js index 59f0262d8..dc34af6ba 100644 --- a/app/scripts/components/statistics.js +++ b/app/scripts/components/statistics.js @@ -1,12 +1,12 @@ /** @format */ import angular from "angular" import _ from "lodash" -import "components-jqueryui/ui/widgets/dialog.js" import settings from "@/settings" -import { formatRelativeHits, html } from "@/util" +import { html } from "@/util" import { loc, locObj } from "@/i18n" import { getCqp } from "../../config/statistics_config.js" import { expandOperators } from "@/cqp_parser/cqp.js" +import "@/components/corpus-distribution-chart" angular.module("korpApp").component("statistics", { template: html` @@ -166,15 +166,18 @@ angular.module("korpApp").component("statistics", { }, controller: [ "$rootScope", + "$scope", + "$uibModal", "searches", "backend", - function ($rootScope, searches, backend) { + function ($rootScope, $scope, $uibModal, searches, backend) { const $ctrl = this $ctrl.noRowsError = false $ctrl.doSort = true $ctrl.sortColumn = null $ctrl.mapRelative = true + $scope.row = null $ctrl.$onInit = () => { $(window).resize( @@ -446,88 +449,27 @@ angular.module("korpApp").component("statistics", { } function showPieChart(rowId) { - let statsSwitchInstance - const pieChartCurrentRowId = rowId + const row = $ctrl.data.find((row) => row.rowId == rowId) - const getDataItems = (rowId, valueType) => { - const dataItems = [] - if (valueType === "relative") { - valueType = 1 - } else { - valueType = 0 - } - for (let row of $ctrl.data) { - if (row.rowId === rowId) { - for (let corpus of $ctrl.searchParams.corpora) { - const freq = row[corpus + "_value"][valueType] - const freqStr = formatRelativeHits(freq.toString(), $rootScope.lang) - const title = locObj(settings.corpora[corpus.toLowerCase()]["title"]) - dataItems.push({ - value: freq, - caption: `${title}: ${freqStr}`, - shape_id: rowId, - }) - } - break - } - } - return dataItems - } + $scope.rowData = $ctrl.searchParams.corpora.map((corpus) => ({ + title: locObj(settings.corpora[corpus.toLowerCase()]["title"]), + values: row[corpus + "_value"], // [absolute, relative] + })) - $("#dialog").remove() - - const relHitsString = loc("statstable_relfigures_hits") - $("
") - .appendTo("body") - .append( - html`
- -
-

- ${relHitsString} -

-
` - ) - .dialog({ - width: 400, - height: 500, - close() { - return $("#pieDiv").remove() - }, - }) - .css("opacity", 0) - .parent() - .find(".ui-dialog-title") - .localeKey("statstable_hitsheader_lemgram") - - $("#dialog").fadeTo(400, 1) - $("#dialog").find("a").blur() // Prevents the focus of the first link in the "dialog" - - const stats2Instance = $("#chartFrame").pie_widget({ - container_id: "chartFrame", - data_items: getDataItems(rowId, "relative"), - }) - statsSwitchInstance = $("#statistics_switch").radioList({ - change: () => { - let loc - const typestring = statsSwitchInstance.radioList("getSelected").attr("data-mode") - stats2Instance.pie_widget("newData", getDataItems(pieChartCurrentRowId, typestring)) - if (typestring === "absolute") { - loc = "statstable_absfigures_hits" - } else { - loc = "statstable_relfigures_hits" - } - return $("#hitsDescription").localeKey(loc) - }, - selected: "relative", + const modal = $uibModal.open({ + template: html` + + + `, + scope: $scope, + windowClass: "!text-base", }) + // Ignore rejection from closing the modal + modal.result.catch(() => {}) } $ctrl.resizeGrid = (resizeColumns) => { diff --git a/app/scripts/pie-widget.js b/app/scripts/pie-widget.js deleted file mode 100644 index e20fda80c..000000000 --- a/app/scripts/pie-widget.js +++ /dev/null @@ -1,290 +0,0 @@ -/** @format */ -import Raphael from "raphael" - -const pie_widget = { - options: { - container_id: "", - data_items: "", - diameter: 300, - sort_desc: true, - offset_x: 0, - offset_y: 0, - }, - - shapes: [], - canvas: null, - _create() { - this.shapes = this.initDiagram(this.options.data_items) - }, - - resizeDiagram(newDiameter) { - if (newDiameter >= 150) { - $(this.container_id).width(newDiameter + 60) - $(this.container_id).height(newDiameter + 60) - this.options.diameter = newDiameter - this.newData(this.options.data_items, false) - } - }, - - newData(data_items) { - this.canvas.remove() - this.options.data_items = data_items - this.shapes = this.initDiagram(data_items) - }, - - _constructSVGPath(highlight, circleTrack, continueArc, offsetX, offsetY, radius, part) { - let str = `M${offsetX + radius},${offsetY + radius}` - if (part === 1.0) { - // Special case, make two arc halves - str += `\nm -${radius}, 0\n` - str += `a ${radius},${radius} 0 1,0 ${radius * 2},0` - str += `a ${radius},${radius} 0 1,0 -${radius * 2},0` - str += " Z" - return str - } else { - let lineToArcX, lineToArcY - const radians = (part + circleTrack["accumulatedArc"]) * 2 * Math.PI - str += " L" - if (continueArc) { - lineToArcX = circleTrack["lastArcX"] - lineToArcY = circleTrack["lastArcY"] - } else { - lineToArcX = offsetX + radius - lineToArcY = offsetY - } - if (highlight) { - // make piece stand out - let newX, newY - const degree = Math.acos((lineToArcY - offsetY - radius) / radius) - if (lineToArcX - offsetX - radius < 0) { - newX = radius * 1.1 * Math.sin(degree) - newY = radius * 1.1 * Math.cos(degree) - } else { - newX = -(radius * 1.1) * Math.sin(degree) - newY = radius * 1.1 * Math.cos(degree) - } - lineToArcX = offsetX + radius - newX - lineToArcY = offsetY + radius + newY - } - str += lineToArcX + "," + lineToArcY - if (highlight) { - str += ` A${radius * 1.1},${radius * 1.1}` - } else { - str += ` A${radius},${radius}` - } - str += " 0 " - if (part > 0.5) { - // Makes the arc always go the long way instead of taking a shortcut - str += "1" - } else { - str += "0" - } - str += ",1 " - let x2 = offsetX + radius + Math.sin(radians) * radius - let y2 = offsetY + radius - Math.cos(radians) * radius - if (!highlight) { - circleTrack["lastArcX"] = x2 - circleTrack["lastArcY"] = y2 - } - if (highlight) { - const endDegree = Math.acos((y2 - offsetY - radius) / radius) - if (x2 < offsetX + radius) { - x2 = offsetX + radius - radius * 1.1 * Math.sin(endDegree) - y2 = offsetX + radius + radius * 1.1 * Math.cos(endDegree) - } else { - x2 = offsetX + radius + radius * 1.1 * Math.sin(endDegree) - y2 = offsetX + radius + radius * 1.1 * Math.cos(endDegree) - } - } - str += x2 + "," + y2 - if (!highlight) { - if (continueArc) { - circleTrack["accumulatedArc"] += part - } else { - circleTrack["accumulatedArc"] = part - } - } - str += " Z" - return str - } - }, - - _makeSVGPie(pieparts, radius) { - const nowthis = this - const mouseEnter = function (event) { - this.attr({ - opacity: 0.7, - cursor: "move", - }) - nowthis._highlight(this) - // Fire callback "enteredArc": - const callback = nowthis.options.enteredArc - if ($.isFunction(callback)) { - callback(nowthis.eventArc(this)) - } - } - - const mouseExit = function (event) { - nowthis._deHighlight(this) - // Fire callback "exitedArc": - const callback = nowthis.options.exitedArc - if ($.isFunction(callback)) { - callback(nowthis.eventArc(this)) - } - } - - const r = Raphael(this.options.container_id) - this.canvas = r - const pieTrack = [] - pieTrack["accumulatedArc"] = 0 - pieTrack["lastArcX"] = 0 - pieTrack["lastArcY"] = 0 - const SVGArcObjects = [] - let first = true - for (let fvalue of pieparts) { - const partOfTotal = fvalue["share"] - if (partOfTotal !== 0) { - const bufferPieTrack = [] - bufferPieTrack["accumulatedArc"] = pieTrack["accumulatedArc"] - bufferPieTrack["lastArcX"] = pieTrack["lastArcX"] - bufferPieTrack["lastArcY"] = pieTrack["lastArcY"] - const origPath = nowthis._constructSVGPath(false, pieTrack, !first, 30, 30, radius, partOfTotal) - const newPiece = r.path(origPath) - const newPieceDOMNode = newPiece.node - newPieceDOMNode["continue"] = !first - newPieceDOMNode["offsetX"] = 30 - newPieceDOMNode["offsetY"] = 30 - newPieceDOMNode["radius"] = radius - newPieceDOMNode["shape_id"] = fvalue["shape_id"] - newPieceDOMNode["caption"] = fvalue["caption"] - newPieceDOMNode["part"] = partOfTotal - newPieceDOMNode["track"] = bufferPieTrack - newPieceDOMNode["origpath"] = origPath - $(newPieceDOMNode).tooltip({ - delay: 80, - bodyHandler() { - return this.caption || "" - }, - }) - - newPiece.mouseover(mouseEnter) - newPiece.mouseout(mouseExit) - newPiece.click(function (event) { - // Fire callback "clickedArc": - const callback = nowthis.options.clickedArc - if ($.isFunction(callback)) { - callback(nowthis.eventArc(this)) - } - }) - - newPiece.attr({ fill: fvalue["color"] }) - newPiece.attr({ stroke: "white" }) - newPiece.attr({ opacity: 0.7 }) - newPiece.attr({ "stroke-linejoin": "miter" }) - SVGArcObjects.push(newPiece) - if (first) { - first = false - } - } - } - - return SVGArcObjects - }, - - _sortDataDescending(indata) { - const sortedData = indata.slice(0) - return sortedData.sort((a, b) => b["value"] - a["value"]) - }, - - initDiagram(indata) { - // Creates the diagram from the data in <> formatting like <>, returns array of the SVG arc objects - // <> is an array with "value","id" and "caption" - // "value" is the numeric value of the item, "id" is to connect the SVG arc item to other stuff, and "caption" is to add tooltip etc. - let fvalue - const defaultOptions = { - colors: [ - "90-#C0C7E0-#D0D7F0:50-#D0D7F0", - "90-#E7C1D4-#F7D1E4:50-#F7D1E4", - "90-#DDECC5-#EDFCD5:50-#EDFCD5", - "90-#EFE3C8-#FFF3D8:50-#FFF3D8", - "90-#BADED8-#CAEEE8:50-#CAEEE8", - "90-#EFCDC8-#FFDDD8:50-#FFDDD8", - ], - } - - const sortedData = this.options.sort_desc ? this._sortDataDescending(indata) : indata - - // Calculate the sum of the array - let total = 0 - for (fvalue of sortedData) { - total += fvalue["value"] - } - - // Piece of cake! - const piePieceDefinitions = [] - let acc = 0 - let colorCount = 0 - for (fvalue of sortedData) { - const relative = fvalue["value"] / total - acc += fvalue["value"] - const itemID = fvalue["shape_id"] - const itemCaption = fvalue["caption"] - piePieceDefinitions.push({ - share: relative, - color: defaultOptions["colors"][colorCount], - shape_id: itemID, - caption: itemCaption, - }) - colorCount = (colorCount + 1) % defaultOptions["colors"].length - } - return this._makeSVGPie(piePieceDefinitions, this.options.diameter * 0.5) - }, - - _highlight(item) { - const n = item.node - const newpath = this._constructSVGPath( - true, - n["track"], - n["continue"], - n["offsetX"], - n["offsetY"], - n["radius"], - n["part"] - ) - return item.attr({ path: newpath }) - }, - - _deHighlight(item) { - const n = item.node - return item.animate({ path: n["origpath"] }, 400, "elastic") - }, - - highlightArc(itemID) { - for (let shape in this.shapes) { - const n = this.shapes[shape].node - if ((n && n.shape_id) === itemID) { - // Highlight the arc - this._highlight(this.shapes[shape]) - return true - } - } - }, - - deHighlightArc(itemID) { - for (let shape in this.shapes) { - const n = this.shapes[shape].node - if ((n && n.shape_id) === itemID) { - // Highlight the arc - this._deHighlight(this.shapes[shape]) - return true - } - } - }, - eventArc(item) { - // Return the clicked arc's ID - return item.node["shape_id"] - }, -} - -let widget = require("components-jqueryui/ui/widget") -widget("hp.pie_widget", pie_widget) // create the widget diff --git a/app/translations/locale-eng.json b/app/translations/locale-eng.json index 4e0671c25..3bb388238 100644 --- a/app/translations/locale-eng.json +++ b/app/translations/locale-eng.json @@ -292,10 +292,7 @@ "statstable_absfreq": "absolute frequency", "statstable_absfigures": "Absolute frequencies", "statstable_relfigures": "Relative frequencies", - "statstable_absfigures_hits": "Hits per corpus, absolute frequencies", - "statstable_relfigures_hits": "Hits per corpus, relative frequencies.", - "statstable_hitsheader": "Hits for ", - "statstable_hitsheader_lemgram": "Hits", + "statstable_distribution": "Hits per corpus", "statstable_exp_csv": "CSV (semicolon separated values)", "statstable_exp_tsv": "TSV (tab separated values)", "statstable_export": "Export", diff --git a/app/translations/locale-swe.json b/app/translations/locale-swe.json index 1b3704d26..12af4da5a 100644 --- a/app/translations/locale-swe.json +++ b/app/translations/locale-swe.json @@ -292,10 +292,7 @@ "statstable_absfreq": "absolut frekvens", "statstable_absfigures": "Absoluta frekvenser", "statstable_relfigures": "Relativa frekvenser", - "statstable_absfigures_hits": "Träffar per korpus, absoluta frekvenser.", - "statstable_relfigures_hits": "Träffar per korpus, relativa frekvenser.", - "statstable_hitsheader": "Träffar för ", - "statstable_hitsheader_lemgram": "Träffar", + "statstable_distribution": "Träffar per korpus", "statstable_exp_csv": "CSV (semikolonseparerade värden)", "statstable_exp_tsv": "TSV (tabseparerade värden)", "statstable_export": "Exportera", diff --git a/package.json b/package.json index 7c1b68dae..de7e6a834 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "leaflet.markercluster": "^1.5.3", "lodash": "^4.17.21", "moment": "2.29.4", - "raphael": "2.3.0", "rickshaw": "1.7.1", "slickgrid": "3.0.3", "tailwindcss": "3.2.4", @@ -33,7 +32,6 @@ "@types/jquery": "^3.5.29", "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.118", - "@types/raphael": "^2.3.9", "@types/rickshaw": "^0.0.31", "autoprefixer": "^10.2.4", "chromedriver": "^122.0.4", diff --git a/yarn.lock b/yarn.lock index e8da67a21..e16cffe4a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -305,11 +305,6 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/raphael@^2.3.9": - version "2.3.9" - resolved "https://registry.yarnpkg.com/@types/raphael/-/raphael-2.3.9.tgz#d53bb8930431524f42987a8a19815c0d42a61eb5" - integrity sha512-K1dZwoLNvEN+mvleFU/t2swG9Z4SE5Vub7dA5wDYojH0bVTQ8ZAP+lNsl91t1njdu/B+roSEL4QXC67I7Hpiag== - "@types/retry@0.12.0": version "0.12.0" resolved "https://registry.yarnpkg.com/@types/retry/-/retry-0.12.0.tgz#2b35eccfcee7d38cd72ad99232fbd58bffb3c84d" @@ -1653,11 +1648,6 @@ etag@~1.8.1: resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -eve-raphael@0.5.0: - version "0.5.0" - resolved "https://registry.yarnpkg.com/eve-raphael/-/eve-raphael-0.5.0.tgz#17c754b792beef3fa6684d79cf5a47c63c4cda30" - integrity sha512-jrxnPsCGqng1UZuEp9DecX/AuSyAszATSjf4oEcRxvfxa1Oux4KkIPKBAAWWnpdwfARtr+Q0o9aPYWjsROD7ug== - eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f" @@ -3460,13 +3450,6 @@ range-parser@^1.2.1, range-parser@~1.2.1: resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== -raphael@2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/raphael/-/raphael-2.3.0.tgz#eabeb09dba861a1d4cee077eaafb8c53f3131f89" - integrity sha512-w2yIenZAQnp257XUWGni4bLMVxpUpcIl7qgxEgDIXtmSypYtlNxfXWpOBxs7LBTps5sDwhRnrToJrMUrivqNTQ== - dependencies: - eve-raphael "0.5.0" - raw-body@2.5.1: version "2.5.1" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857"