From 52ab9c4212a744211052628e610c4307ee3e94e6 Mon Sep 17 00:00:00 2001 From: casesolved-co-uk Date: Sat, 13 May 2023 23:49:04 +0100 Subject: [PATCH] feat: initial radar chart fix: first rendering version without axis feat: radial axis working but buggy, and tidy feat: radar theta axis fix: radar bugs and tidy fix: missing state variable --- .gitignore | 1 + src/css/charts.scss | 3 +- src/js/chart.js | 11 +- src/js/charts/RadarChart.js | 243 ++++++++++++++++++++++++++++++ src/js/objects/ChartComponents.js | 57 +++++++ src/js/utils/draw.js | 143 +++++++++++++++++- 6 files changed, 454 insertions(+), 4 deletions(-) create mode 100644 src/js/charts/RadarChart.js diff --git a/.gitignore b/.gitignore index f7c1288b..6fb77956 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ docs docs/assets/ .DS_Store +*~ diff --git a/src/css/charts.scss b/src/css/charts.scss index ba629683..a9c406ca 100644 --- a/src/css/charts.scss +++ b/src/css/charts.scss @@ -57,6 +57,7 @@ } } + circle.dashed, line.dashed { stroke-dasharray: 5, 3; } @@ -189,4 +190,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/js/chart.js b/src/js/chart.js index 53696abd..74c8daf6 100644 --- a/src/js/chart.js +++ b/src/js/chart.js @@ -5,6 +5,7 @@ import PieChart from "./charts/PieChart"; import Heatmap from "./charts/Heatmap"; import AxisChart from "./charts/AxisChart"; import DonutChart from "./charts/DonutChart"; +import RadarChart from "./charts/RadarChart"; const chartTypes = { bar: AxisChart, @@ -13,6 +14,7 @@ const chartTypes = { heatmap: Heatmap, pie: PieChart, donut: DonutChart, + radar: RadarChart, }; function getChartByType(chartType = "line", parent, options) { @@ -31,8 +33,13 @@ function getChartByType(chartType = "line", parent, options) { class Chart { constructor(parent, options) { - return getChartByType(options.type, parent, options); + const chart = getChartByType(options.type, parent, options); + if (!frappe.charts) { + frappe.charts = []; + } + frappe.charts.push(chart); + return chart; } } -export { Chart, PercentageChart, PieChart, Heatmap, AxisChart }; +export { Chart, PercentageChart, PieChart, DonutChart, Heatmap, AxisChart, RadarChart }; diff --git a/src/js/charts/RadarChart.js b/src/js/charts/RadarChart.js new file mode 100644 index 00000000..56f45ce1 --- /dev/null +++ b/src/js/charts/RadarChart.js @@ -0,0 +1,243 @@ +import BaseChart from "./BaseChart"; +import { + dataPrep, + zeroDataPrep, + getShortenedLabels, +} from "../utils/axis-chart-utils"; +import { getComponent } from "../objects/ChartComponents"; +import { getOffset, fire } from "../utils/dom"; +import { + calcChartIntervals, + getIntervalSize, + getValueRange, + getZeroIndex, + scale, + getClosestInArray, +} from "../utils/intervals"; +import { getPositionByAngle } from "../utils/helpers"; +import { legendDot, makeOverlay, updateOverlay } from "../utils/draw"; +import { + getTopOffset, + getLeftOffset, + LINE_CHART_DOT_SIZE, + LEGEND_ITEM_WIDTH, +} from "../utils/constants"; + +export default class RadarChart extends BaseChart { + constructor(parent, options) { + super(parent, options); + this.type = options.type || "radar"; + this.init = 1; + this.setup(); + } + + configure(options) { + super.configure(options); + this.mouseMove = this.mouseMove.bind(this); + this.mouseLeave = this.mouseLeave.bind(this); + // angles are always with up = 0 degrees + this.config.radarOptions = { + hasStroke: false, + opacity: 0.8, + clockWise: true, + startAngle: 0, + }; + Object.assign(this.config.radarOptions, options.radarOptions); + this.config.rAxisOptions = { + // always clockwise + axisAngle: 90, + alignment: "outside", + className: "", + }; + Object.assign(this.config.rAxisOptions, options.rAxisOptions); + this.config.thetaAxisOptions = { + radius: 4, + color: "#98A1A9", //gray500 + }; + Object.assign(this.config.thetaAxisOptions, options.thetaAxisOptions); + this.config.legendRowHeight = 60; + } + + calc(onlyWidthChange = false) { + // x is right, y is down, getPositionByAngle: 0deg is down and anticlockwise + super.calc(onlyWidthChange); + let s = this.state; + s.center = {x: this.width / 2, y: this.height / 2}; + s.radius = this.height > this.width ? s.center.x : s.center.y; + s.datasetLength = this.data.labels.length; + const segments = this.data.labels.length; + let angleIncrement = 360 / segments; + angleIncrement = this.config.radarOptions.clockWise ? angleIncrement : -angleIncrement; + + let minValue = 0; + s.maxValue = 0; + for (let dataset of this.data.datasets) { + for (let value of dataset.values) { + minValue = Math.min(minValue, value); + s.maxValue = Math.max(s.maxValue, value); + } + } + if (minValue < 0) console.warn("RadarChart: Found negative value: %s", minValue); + if (s.maxValue === 0) console.warn("RadarChart: Maximum value not greater than zero"); + const range = s.maxValue - minValue; + + s.radars = []; + s.theta = []; + let labelPoint; + let theta_done = false; + let point; + let points; + let angle; + for (let dataset of this.data.datasets) { + if (dataset.values.length !== segments) console.error("RadarChart: Dataset %s not the same length as labels", dataset.name); + angle = this.config.radarOptions.startAngle; + points = []; + for (let value of dataset.values) { + point = getPositionByAngle(angle, ((value - minValue) / range) * s.radius); + // Convert angle by negating y value + point.y = -point.y; + point.x += s.center.x; + point.y += s.center.y; + points.push(point); + if (!theta_done) { + labelPoint = getPositionByAngle(angle, s.radius); + labelPoint.y = -labelPoint.y; + labelPoint.x += s.center.x; + labelPoint.y += s.center.y; + s.theta.push(labelPoint); + } + angle += angleIncrement; + } + theta_done = true; + s.radars.push({points: points}); + } + + this.init = 0; + } + + setupComponents() { + // getComponent takes: name, constants & getData function + let componentConfigs = this.state.radars.map((data, index) => { + return [ + "radarChart", + { + index: index, + hasStroke: this.config.radarOptions.hasStroke, + colour: this.colors[index], + opacity: this.config.radarOptions.opacity, + }, + function getData() { + return this.state.radars[index]; + }.bind(this) + ] + }); + + let rAxisConstants = { + radius: this.state.radius, + center: this.state.center, + }; + Object.assign(rAxisConstants, this.config.rAxisOptions); + componentConfigs.push([ + "rAxis", + rAxisConstants, + function getData() { + return { + maxValue: this.state.maxValue, + }; + }.bind(this) + ]); + + let thetaAxisConstants = { + center: this.state.center, + }; + Object.assign(thetaAxisConstants, this.config.thetaAxisOptions); + componentConfigs.push([ + "thetaAxis", + thetaAxisConstants, + function getData() { + return { + points: this.state.theta, + labels: this.data.labels, + }; + }.bind(this) + ]); + + this.components = new Map( + componentConfigs.map((args) => { + const component = getComponent(...args); + let key = args[0]; + key += args[1].index !== undefined ? args[1].index : ""; + return [key, component]; + }) + ); + } + + renderLegend() { + super.renderLegend(this.data.datasets); + } + makeLegend(data, index, x_pos, y_pos) { + // requires this.config.legendRowHeight + return legendDot( + x_pos, + y_pos, + 12, // size + 3, // dot radius + this.colors[index], // fill + data.name || `Dataset${index}`, // label + null, // value + null, // base_font_size + this.config.truncateLegends // truncate_legends + ); + } + + bindTooltip() {} + mouseMove(e) {} + mouseLeave() {} + + // API + // TODO + makeOverlay() {} + updateOverlay() {} + bindOverlay() {} + bindUnits() {} + + onLeftArrow() {} + onRightArrow() {} + onUpArrow() {} + onDownArrow() {} + onEnterKey() {} + + getDataPoint() {} + setCurrentDataPoint() {} + + updateDataset() {} + addDataPoint(label, datasetValues, index = this.state.datasetLength) { + super.addDataPoint(label, datasetValues, index); + this.data.labels.splice(index, 0, label); + this.data.datasets.map((d, i) => { + d.values.splice(index, 0, datasetValues[i]); + }); + this.update(this.data); + } + + removeDataPoint(index = this.state.datasetLength - 1) { + if (this.data.labels.length <= 1) { + return; + } + super.removeDataPoint(index); + this.data.labels.splice(index, 1); + this.data.datasets.map((d) => { + d.values.splice(index, 1); + }); + this.update(this.data); + } + + updateDatasets(datasets) { + this.data.datasets.map((d, i) => { + if (datasets[i]) { + d.values = datasets[i]; + } + }); + this.update(this.data); + } +} diff --git a/src/js/objects/ChartComponents.js b/src/js/objects/ChartComponents.js index 5bb94772..4bbfc64b 100644 --- a/src/js/objects/ChartComponents.js +++ b/src/js/objects/ChartComponents.js @@ -2,9 +2,12 @@ import { makeSVGGroup } from "../utils/draw"; import { makeText, makePath, + makePolygon, xLine, yLine, generateAxisLabel, + rAxis, + thetaAxis, yMarker, yRegion, datasetBar, @@ -304,6 +307,36 @@ let componentConfigs = { }, }, + rAxis: { + layerClass: "r axis", + makeElements(data) { + return [rAxis( + this.constants.radius, + data.maxValue, + this.constants.center, + this.constants + )]; + }, + animateElements(newData) { + if (newData) return []; + }, + }, + + thetaAxis: { + layerClass: "theta axis", + makeElements(data) { + return [thetaAxis( + data.points, + data.labels, + this.constants.center, + this.constants + )]; + }, + animateElements(newData) { + if (newData) return []; + }, + }, + yMarkers: { layerClass: "y-markers", makeElements(data) { @@ -390,6 +423,30 @@ let componentConfigs = { }, }, +// ChartComponent constructed with: layerClass, layerTransform, +// constants, getData, makeElements, animateElements + radarChart: { + layerClass: function () { + return "radar-chart radar-" + this.constants.index; + }, + makeElements(data) { + const { index, hasStroke, colour, opacity } = this.constants; + let elements = []; + elements.push(makePolygon( + data.points.map(point => `${point.x},${point.y}`).join(" "), + "radar-area", + hasStroke ? colour : "none", + colour, + opacity + )); + + return elements; + }, + animateElements(newData) { + if (newData) return []; + }, + }, + heatDomain: { layerClass: function () { return "heat-domain domain-" + this.constants.index; diff --git a/src/js/utils/draw.js b/src/js/utils/draw.js index 1bedb0bf..f1c415a3 100644 --- a/src/js/utils/draw.js +++ b/src/js/utils/draw.js @@ -4,7 +4,7 @@ import { shortenLargeNumber, getSplineCurvePointsStr, } from "./draw-utils"; -import { getStringWidth, isValidNumber, round } from "./helpers"; +import { getStringWidth, isValidNumber, round, getPositionByAngle } from "./helpers"; import { DOT_OVERLAY_SIZE_INCR, @@ -127,6 +127,25 @@ export function makePath( }); } +// the polygon will automatically close +export function makePolygon( + points, // e.g. "0,100 50,25 50,75 100,0" + className = "", + stroke = "none", + fill = "none", + opacity = 1, + strokeWidth = 2 +) { + return createSVG("polygon", { + className: className, + points: points, + stroke: stroke, + fill: fill, + opacity: opacity, + "stroke-width": strokeWidth, + }); +} + export function makeArcPathStr( startPosition, endPosition, @@ -486,6 +505,66 @@ function makeHoriLine(y, label, x1, x2, options = {}) { return line; } +function makeCircleLine(r, label, center, options = {}) { + if (!options.stroke) options.stroke = BASE_LINE_COLOR; + if (!options.lineType) options.lineType = ""; + // our 0 degrees is up + if (!options.axisAngle) options.axisAngle = 90; + if (!options.alignment) options.alignment = "outside"; + if (options.shortenNumbers) label = shortenLargeNumber(label); + + let className = + "line-circle " + + options.className + + (options.lineType.toLowerCase() === "dashed" ? " dashed" : ""); + + const labelRadius = options.alignment === "outside" ? r + LABEL_MARGIN : r - LABEL_MARGIN; + + let labelPoint = getPositionByAngle(options.axisAngle, labelRadius); + // convert to 0 degrees = up + labelPoint.y = -labelPoint.y; + + const labelAnchor = + options.alignment === "outside" + ? (labelPoint.x >= 0) + ? "start" + : "end" + : (labelPoint.x >= 0) + ? "end" + : "start"; + + labelPoint.x += center.x; + labelPoint.y += center.y; + + let line = createSVG("circle", { + className: className, + styles: { + stroke: options.stroke, + }, + cx: center.x, + cy: center.y, + r: r, + fill: "none", + }); + + let text = createSVG("text", { + x: labelPoint.x, + y: labelPoint.y, + "font-size": FONT_SIZE + "px", + "text-anchor": labelAnchor, + innerHTML: label + "", + }); + + let group = createSVG("g", { + "stroke-opacity": 1, + }); + + group.appendChild(line); + group.appendChild(text); + + return group; +} + export function generateAxisLabel(options) { if (!options.title) return; @@ -600,6 +679,68 @@ export function xLine(x, label, height, options = {}) { }); } +export function rAxis(radius, maxValue, center, options = {}) { + if (!options.stroke) options.stroke = BASE_LINE_COLOR; + // our 0 degrees is up + if (!options.axisAngle) options.axisAngle = 90; + if (!options.alignment) options.alignment = "outside"; + if (!options.className) options.className = ""; + + let group = createSVG("g", {}); + + const full = makeCircleLine(radius, maxValue.toString(), center, { + stroke: options.stroke, + axisAngle: options.axisAngle, + alignment: options.alignment, + className: options.className, + }); + + const half = makeCircleLine(radius/2, (maxValue / 2).toString(), center, { + stroke: options.stroke, + axisAngle: options.axisAngle, + alignment: options.alignment, + className: options.className, + lineType: "dashed", + }); + + group.appendChild(full); + group.appendChild(half); + + return group; +} + +export function thetaAxis(points, labels, center, options = {}) { + if (!options.color) options.color = BASE_LINE_COLOR; + if (!options.radius) options.radius = 4; + + let group = createSVG("g", {}); + + let dot; + points.map((point, index) => { + dot = datasetDot( + point.x, + point.y, + options.radius, + options.color, + labels[index], + index + ); + group.appendChild(dot); + }); + + dot = datasetDot( + center.x, + center.y, + options.radius, + options.color, + "", + -1 + ); + group.appendChild(dot); + + return group; +} + export function yMarker(y, label, width, options = {}) { if (!isValidNumber(y)) y = 0;