diff --git a/MotionMark/resources/debug-runner/tests.js b/MotionMark/resources/debug-runner/tests.js
index f48d0b1..1203d4c 100644
--- a/MotionMark/resources/debug-runner/tests.js
+++ b/MotionMark/resources/debug-runner/tests.js
@@ -456,3 +456,12 @@ Suites.push(new Suite("Basic canvas path suite",
}
]
));
+
+Suites.push(new Suite("Dev suite",
+ [
+ {
+ url: "dev/radial-chart/radial-chart.html",
+ name: "Canvas Radial Chart"
+ }
+ ]
+));
diff --git a/MotionMark/resources/extensions.js b/MotionMark/resources/extensions.js
index 2b1ee01..5b052b2 100644
--- a/MotionMark/resources/extensions.js
+++ b/MotionMark/resources/extensions.js
@@ -298,12 +298,12 @@ class Point {
length()
{
- return Math.sqrt(this.x * this.x + this.y * this.y);
+ return Math.hypot(this.x, this.y);
}
normalize()
{
- var l = Math.sqrt(this.x * this.x + this.y * this.y);
+ const l = this.length();
this.x /= l;
this.y /= l;
return this;
diff --git a/MotionMark/tests/dev/radial-chart/Description.md b/MotionMark/tests/dev/radial-chart/Description.md
new file mode 100644
index 0000000..0f25747
--- /dev/null
+++ b/MotionMark/tests/dev/radial-chart/Description.md
@@ -0,0 +1,45 @@
+# Canvas Radial Chart
+
+Goals
+-----
+
+A single canvas test that exercises much of the canvas 2D API, replacing `Paths`, `Arcs` and `Lines`.
+
+
+Design
+------
+
+A radial chart. Unit of work is a chart "segment". With higher complexities, more rings of segments are created.
+
+
+Features tested
+---------------
+
+* text drawing
+* image drawing
+* lines, arcs, curves
+* dashed lines
+* clipping to a path
+* gradients
+* text with shadowBlur
+
+
+Work per measured frame
+----------------------
+
+Redraw of the entire canvas
+
+
+Licensing requirements
+----------------------
+
+French departements images, e.g. https://commons.wikimedia.org/wiki/File:Blason_département_fr_Ain.svg
+Creative Commons, requires attribution.
+
+
+Remaining work
+--------------
+
+* Add images for the rest of the departements. Maybe use a sprite image.
+* Add some more canvas features?
+* Revisit whether the concentric rings are best for high complexity. Perhaps have multiple, smaller non-overlapping rings.
diff --git a/MotionMark/tests/dev/radial-chart/radial-chart.html b/MotionMark/tests/dev/radial-chart/radial-chart.html
new file mode 100644
index 0000000..8a85e35
--- /dev/null
+++ b/MotionMark/tests/dev/radial-chart/radial-chart.html
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MotionMark/tests/dev/radial-chart/resources/departements-region.json b/MotionMark/tests/dev/radial-chart/resources/departements-region.json
new file mode 100644
index 0000000..64a473f
--- /dev/null
+++ b/MotionMark/tests/dev/radial-chart/resources/departements-region.json
@@ -0,0 +1,507 @@
+[
+ {
+ "num_dep": "01",
+ "dep_name": "Ain",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": "02",
+ "dep_name": "Aisne",
+ "region_name": "Hauts-de-France"
+ },
+ {
+ "num_dep": "03",
+ "dep_name": "Allier",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": "04",
+ "dep_name": "Alpes-de-Haute-Provence",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": "05",
+ "dep_name": "Hautes-Alpes",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": "06",
+ "dep_name": "Alpes-Maritimes",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": "07",
+ "dep_name": "Ardèche",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": "08",
+ "dep_name": "Ardennes",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": "09",
+ "dep_name": "Ariège",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 10,
+ "dep_name": "Aube",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 11,
+ "dep_name": "Aude",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 12,
+ "dep_name": "Aveyron",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 13,
+ "dep_name": "Bouches-du-Rhône",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": 14,
+ "dep_name": "Calvados",
+ "region_name": "Normandie"
+ },
+ {
+ "num_dep": 15,
+ "dep_name": "Cantal",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 16,
+ "dep_name": "Charente",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 17,
+ "dep_name": "Charente-Maritime",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 18,
+ "dep_name": "Cher",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 19,
+ "dep_name": "Corrèze",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 21,
+ "dep_name": "Côte-d'Or",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 22,
+ "dep_name": "Côtes-d'Armor",
+ "region_name": "Bretagne"
+ },
+ {
+ "num_dep": 23,
+ "dep_name": "Creuse",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 24,
+ "dep_name": "Dordogne",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 25,
+ "dep_name": "Doubs",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 26,
+ "dep_name": "Drôme",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 27,
+ "dep_name": "Eure",
+ "region_name": "Normandie"
+ },
+ {
+ "num_dep": 28,
+ "dep_name": "Eure-et-Loir",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 29,
+ "dep_name": "Finistère",
+ "region_name": "Bretagne"
+ },
+ {
+ "num_dep": "2A",
+ "dep_name": "Corse-du-Sud",
+ "region_name": "Corse"
+ },
+ {
+ "num_dep": "2B",
+ "dep_name": "Haute-Corse",
+ "region_name": "Corse"
+ },
+ {
+ "num_dep": 30,
+ "dep_name": "Gard",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 31,
+ "dep_name": "Haute-Garonne",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 32,
+ "dep_name": "Gers",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 33,
+ "dep_name": "Gironde",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 34,
+ "dep_name": "Hérault",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 35,
+ "dep_name": "Ille-et-Vilaine",
+ "region_name": "Bretagne"
+ },
+ {
+ "num_dep": 36,
+ "dep_name": "Indre",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 37,
+ "dep_name": "Indre-et-Loire",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 38,
+ "dep_name": "Isère",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 39,
+ "dep_name": "Jura",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 40,
+ "dep_name": "Landes",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 41,
+ "dep_name": "Loir-et-Cher",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 42,
+ "dep_name": "Loire",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 43,
+ "dep_name": "Haute-Loire",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 44,
+ "dep_name": "Loire-Atlantique",
+ "region_name": "Pays de la Loire"
+ },
+ {
+ "num_dep": 45,
+ "dep_name": "Loiret",
+ "region_name": "Centre-Val de Loire"
+ },
+ {
+ "num_dep": 46,
+ "dep_name": "Lot",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 47,
+ "dep_name": "Lot-et-Garonne",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 48,
+ "dep_name": "Lozère",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 49,
+ "dep_name": "Maine-et-Loire",
+ "region_name": "Pays de la Loire"
+ },
+ {
+ "num_dep": 50,
+ "dep_name": "Manche",
+ "region_name": "Normandie"
+ },
+ {
+ "num_dep": 51,
+ "dep_name": "Marne",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 52,
+ "dep_name": "Haute-Marne",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 53,
+ "dep_name": "Mayenne",
+ "region_name": "Pays de la Loire"
+ },
+ {
+ "num_dep": 54,
+ "dep_name": "Meurthe-et-Moselle",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 55,
+ "dep_name": "Meuse",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 56,
+ "dep_name": "Morbihan",
+ "region_name": "Bretagne"
+ },
+ {
+ "num_dep": 57,
+ "dep_name": "Moselle",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 58,
+ "dep_name": "Nièvre",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 59,
+ "dep_name": "Nord",
+ "region_name": "Hauts-de-France"
+ },
+ {
+ "num_dep": 60,
+ "dep_name": "Oise",
+ "region_name": "Hauts-de-France"
+ },
+ {
+ "num_dep": 61,
+ "dep_name": "Orne",
+ "region_name": "Normandie"
+ },
+ {
+ "num_dep": 62,
+ "dep_name": "Pas-de-Calais",
+ "region_name": "Hauts-de-France"
+ },
+ {
+ "num_dep": 63,
+ "dep_name": "Puy-de-Dôme",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 64,
+ "dep_name": "Pyrénées-Atlantiques",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 65,
+ "dep_name": "Hautes-Pyrénées",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 66,
+ "dep_name": "Pyrénées-Orientales",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 67,
+ "dep_name": "Bas-Rhin",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 68,
+ "dep_name": "Haut-Rhin",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 69,
+ "dep_name": "Rhône",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 70,
+ "dep_name": "Haute-Saône",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 71,
+ "dep_name": "Saône-et-Loire",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 72,
+ "dep_name": "Sarthe",
+ "region_name": "Pays de la Loire"
+ },
+ {
+ "num_dep": 73,
+ "dep_name": "Savoie",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 74,
+ "dep_name": "Haute-Savoie",
+ "region_name": "Auvergne-Rhône-Alpes"
+ },
+ {
+ "num_dep": 75,
+ "dep_name": "Paris",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 76,
+ "dep_name": "Seine-Maritime",
+ "region_name": "Normandie"
+ },
+ {
+ "num_dep": 77,
+ "dep_name": "Seine-et-Marne",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 78,
+ "dep_name": "Yvelines",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 79,
+ "dep_name": "Deux-Sèvres",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 80,
+ "dep_name": "Somme",
+ "region_name": "Hauts-de-France"
+ },
+ {
+ "num_dep": 81,
+ "dep_name": "Tarn",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 82,
+ "dep_name": "Tarn-et-Garonne",
+ "region_name": "Occitanie"
+ },
+ {
+ "num_dep": 83,
+ "dep_name": "Var",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": 84,
+ "dep_name": "Vaucluse",
+ "region_name": "Provence-Alpes-Côte d'Azur"
+ },
+ {
+ "num_dep": 85,
+ "dep_name": "Vendée",
+ "region_name": "Pays de la Loire"
+ },
+ {
+ "num_dep": 86,
+ "dep_name": "Vienne",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 87,
+ "dep_name": "Haute-Vienne",
+ "region_name": "Nouvelle-Aquitaine"
+ },
+ {
+ "num_dep": 88,
+ "dep_name": "Vosges",
+ "region_name": "Grand Est"
+ },
+ {
+ "num_dep": 89,
+ "dep_name": "Yonne",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 90,
+ "dep_name": "Territoire de Belfort",
+ "region_name": "Bourgogne-Franche-Comté"
+ },
+ {
+ "num_dep": 91,
+ "dep_name": "Essonne",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 92,
+ "dep_name": "Hauts-de-Seine",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 93,
+ "dep_name": "Seine-Saint-Denis",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 94,
+ "dep_name": "Val-de-Marne",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 95,
+ "dep_name": "Val-d'Oise",
+ "region_name": "Île-de-France"
+ },
+ {
+ "num_dep": 971,
+ "dep_name": "Guadeloupe",
+ "region_name": "Guadeloupe"
+ },
+ {
+ "num_dep": 972,
+ "dep_name": "Martinique",
+ "region_name": "Martinique"
+ },
+ {
+ "num_dep": 973,
+ "dep_name": "Guyane",
+ "region_name": "Guyane"
+ },
+ {
+ "num_dep": 974,
+ "dep_name": "La Réunion",
+ "region_name": "La Réunion"
+ },
+ {
+ "num_dep": 976,
+ "dep_name": "Mayotte",
+ "region_name": "Mayotte"
+ }
+]
\ No newline at end of file
diff --git a/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.png b/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.png
new file mode 100644
index 0000000..4e8e04a
Binary files /dev/null and b/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.png differ
diff --git a/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.svg b/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.svg
new file mode 100644
index 0000000..959443b
--- /dev/null
+++ b/MotionMark/tests/dev/radial-chart/resources/department-shields/Ain.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/MotionMark/tests/dev/radial-chart/resources/radial-chart.js b/MotionMark/tests/dev/radial-chart/resources/radial-chart.js
new file mode 100644
index 0000000..151721c
--- /dev/null
+++ b/MotionMark/tests/dev/radial-chart/resources/radial-chart.js
@@ -0,0 +1,636 @@
+/*
+ * Copyright (C) 2015-2024 Apple Inc. All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions
+ * are met:
+ * 1. Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright
+ * notice, this list of conditions and the following disclaimer in the
+ * documentation and/or other materials provided with the distribution.
+ *
+ * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS''
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO,
+ * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+ * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS
+ * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+ * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+ * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+ * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+ * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+ * THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+// Move to shared code
+class Size {
+ constructor(width, height)
+ {
+ this.width = width;
+ this.height = height;
+ }
+}
+
+
+// To be moved.
+class MathHelpers {
+ static random(min, max)
+ {
+ return min + Pseudo.random() * (max - min);
+ }
+
+ static rotatingColor(hueOffset, cycleLengthMs, saturation, lightness)
+ {
+ return "hsl("
+ + MathHelpers.dateFractionalValue(cycleLengthMs, hueOffset) * 360 + ", "
+ + ((saturation || .8) * 100).toFixed(0) + "%, "
+ + ((lightness || .35) * 100).toFixed(0) + "%)";
+ }
+
+ // Returns a fractional value that wraps around within [0,1]
+ static dateFractionalValue(cycleLengthMs, offset)
+ {
+ return (offset + Date.now() / (cycleLengthMs || 2000)) % 1;
+ }
+
+ static cheapHash(s)
+ {
+ let hash = 0, i = 0, len = s.length;
+ while ( i < len )
+ hash = ((hash << 5) - hash + s.charCodeAt(i++)) << 0;
+
+ return hash + 2147483647 + 1;
+ }
+
+ // JavaScripts % operator is remainder, not modulo.
+ static modulo(dividend, divisor)
+ {
+ const quotient = Math.floor(dividend / divisor);
+ return dividend - divisor * quotient;
+ }
+
+ static normalizeRadians(radians)
+ {
+ return MathHelpers.modulo(radians, Math.PI * 2);
+ }
+}
+
+class ItemData {
+ constructor(deptNumber, label, imageURL)
+ {
+ this.deptNumber = deptNumber;
+ this.label = label;
+ this.imageURL = imageURL;
+
+ this.hueOffset = MathHelpers.cheapHash(label) / 0xFFFFFFFF;
+ this.colorLightness = MathHelpers.random(0.5, 0.7);
+ this.colorSaturation = MathHelpers.random(0.2, 0.5);
+ }
+
+ loadImage()
+ {
+ return new Promise(resolve => {
+ this.image = new Image();
+ this.image.onload = resolve;
+ this.image.src = this.imageURL;
+ });
+ }
+}
+
+class RandomWalk {
+ constructor(min, max, stepFraction)
+ {
+ this.min = min;
+ this.max = max;
+ this.stepFraction = stepFraction;
+ this.value = MathHelpers.random(this.min, this.max);
+ }
+
+ nextValue()
+ {
+ const scale = (this.max - this.min) * this.stepFraction;
+ const delta = scale * 2 * (Pseudo.random() - 0.5);
+ this.value = Math.max(Math.min(this.value + delta, this.max), this.min);
+ return this.value;
+ }
+}
+
+class SmoothWalk {
+ static timeOrigin;
+ constructor(min, max)
+ {
+ this.min = min;
+ this.max = max;
+
+ const minWaveLength = 200;
+ const maxWaveLength = 2000;
+
+ const amplitudeMin = 0.2;
+ const amplitudeMax = 1;
+ // We superimpose some sin functions to generate the values.
+ this.wave1Phase = Pseudo.random();
+ this.wave1Length = MathHelpers.random(minWaveLength, maxWaveLength);
+ this.wave1Amplitude = MathHelpers.random(amplitudeMin, amplitudeMax);
+
+ this.wave2Phase = Pseudo.random();
+ this.wave2Length = MathHelpers.random(minWaveLength, maxWaveLength);
+ this.wave2Amplitude = MathHelpers.random(amplitudeMin, amplitudeMax);
+
+ this.wave3Phase = Pseudo.random();
+ this.wave3Length = MathHelpers.random(minWaveLength, maxWaveLength);
+ this.wave3Amplitude = MathHelpers.random(amplitudeMin, amplitudeMax);
+
+ if (!SmoothWalk.timeOrigin)
+ SmoothWalk.timeOrigin = new Date();
+ }
+
+ nextValue()
+ {
+ this.value = this.#computeValue();
+ return this.value;
+ }
+
+ #computeValue()
+ {
+ const elapsedTime = Date.now() - SmoothWalk.timeOrigin;
+ const wave1Value = this.wave1Amplitude * (0.5 + Math.sin(this.wave1Amplitude + elapsedTime / this.wave1Length) / 2);
+ const wave2Value = this.wave2Amplitude * (0.5 + Math.sin(this.wave2Amplitude + elapsedTime / this.wave2Length) / 2);
+ const wave3Value = this.wave3Amplitude * (0.5 + Math.sin(this.wave3Amplitude + elapsedTime / this.wave3Length) / 2);
+
+ return this.min + (this.max - this.min) * (wave1Value + wave2Value + wave3Value) / (this.wave1Amplitude + this.wave2Amplitude + this.wave3Amplitude);
+ }
+}
+
+const TwoPI = Math.PI * 2;
+const Clockwise = false;
+const CounterClockwise = true;
+
+class RadialChart {
+ constructor(stage, center, innerRadius, outerRadius)
+ {
+ this.stage = stage;
+ this.center = center;
+ this.innerRadius = innerRadius;
+ this.outerRadius = outerRadius;
+ this._values = [];
+ this.#computeDimensions();
+
+ this.complexity = 1;
+ }
+
+ get complexity()
+ {
+ return this._complexity;
+ }
+
+ set complexity(value)
+ {
+ this._complexity = value;
+ if (this._complexity < this._values.length) {
+ this._values.length = this._complexity;
+ return;
+ }
+
+ const startIndex = this._values.length;
+ for (let i = startIndex; i < this._complexity; ++i)
+ this._values.push(new SmoothWalk(this.innerRadius, this.outerRadius, 1 / 100));
+ }
+
+ draw(ctx)
+ {
+ this.numSpokes = this._complexity;
+ this.wedgeAngleRadians = TwoPI / this.numSpokes;
+ this.angleOffsetRadians = Math.PI / 2; // Start at the top, rather than the right.
+
+ for (let i = 0; i < this.numSpokes; ++i) {
+ const instance = this.stage.instanceForIndex(i);
+
+ this.#drawWedge(ctx, i, instance);
+ this.#drawBadge(ctx, i, instance);
+ this.#drawWedgeLabels(ctx, i, instance);
+ }
+
+ this.#drawGraphAxes(ctx);
+ }
+
+ #computeDimensions()
+ {
+
+ }
+
+ #drawGraphAxes(ctx)
+ {
+ ctx.strokeStyle = 'black';
+ ctx.lineWidth = 0.5;
+ ctx.beginPath();
+ ctx.arc(this.center.x, this.center.y, this.innerRadius, 0, TwoPI, Clockwise);
+ ctx.stroke();
+
+ ctx.beginPath();
+ ctx.arc(this.center.x, this.center.y, this.outerRadius, 0, TwoPI, Clockwise);
+ ctx.stroke();
+
+ for (let i = 0; i < this.numSpokes; ++i) {
+ const angleRadians = this.#wedgeStartAngle(i);
+
+ const startPoint = this.center.add(GeometryHelpers.createPointOnCircle(angleRadians, this.innerRadius));
+ const endPoint = this.center.add(GeometryHelpers.createPointOnCircle(angleRadians, this.outerRadius));
+
+ ctx.beginPath();
+ ctx.moveTo(startPoint.x, startPoint.y);
+ ctx.lineTo(endPoint.x, endPoint.y);
+ ctx.stroke();
+ }
+ }
+
+ #wedgeStartAngle(index)
+ {
+ return index * this.wedgeAngleRadians - this.angleOffsetRadians;
+ }
+
+ #pathForWedge(index, outerRadius)
+ {
+ const startAngleRadians = this.#wedgeStartAngle(index);
+ const endAngleRadians = startAngleRadians + this.wedgeAngleRadians;
+
+ const path = new Path2D();
+
+ const firstStartPoint = this.center.add(GeometryHelpers.createPointOnCircle(startAngleRadians, this.innerRadius));
+ const firstEndPoint = this.center.add(GeometryHelpers.createPointOnCircle(startAngleRadians, outerRadius));
+
+ path.moveTo(firstStartPoint.x, firstStartPoint.y);
+ path.lineTo(firstEndPoint.x, firstEndPoint.y);
+
+ path.arc(this.center.x, this.center.y, outerRadius, startAngleRadians, endAngleRadians, Clockwise);
+
+ const secondEndPoint = this.center.add(GeometryHelpers.createPointOnCircle(endAngleRadians, this.innerRadius));
+ path.lineTo(secondEndPoint.x, secondEndPoint.y);
+ path.arc(this.center.x, this.center.y, this.innerRadius, endAngleRadians, startAngleRadians, CounterClockwise);
+ path.closePath();
+
+ return path;
+ }
+
+ #drawWedge(ctx, index, instance)
+ {
+ const outerRadius = this._values[index].nextValue();
+ const wedgePath = this.#pathForWedge(index, outerRadius);
+
+ const gradient = ctx.createRadialGradient(this.center.x, this.center.y, this.innerRadius, this.center.x, this.center.y, outerRadius);
+
+ const colorCycleLengthMS = 1200;
+ gradient.addColorStop(0, MathHelpers.rotatingColor(instance.hueOffset, colorCycleLengthMS, instance.colorSaturation, instance.colorLightness));
+ gradient.addColorStop(0.9, MathHelpers.rotatingColor(instance.hueOffset + 0.4, colorCycleLengthMS, instance.colorSaturation, instance.colorLightness));
+
+ ctx.fillStyle = gradient;
+ ctx.fill(wedgePath);
+ }
+
+ #drawWedgeLabels(ctx, index, instance)
+ {
+ const midAngleRadians = MathHelpers.normalizeRadians(this.#wedgeStartAngle(index) + 0.5 * this.wedgeAngleRadians);
+
+ const textInset = -15;
+ const textCenterPoint = this.center.add(GeometryHelpers.createPointOnCircle(midAngleRadians, this.innerRadius - textInset));
+
+ const labelAngle = midAngleRadians + Math.PI / 2;
+
+ {
+ ctx.save();
+ ctx.font = '12px "Helvetica Neue", Helvetica, sans-serif';
+
+ // Numbers on inner ring.
+ ctx.translate(textCenterPoint.x, textCenterPoint.y);
+ ctx.rotate(labelAngle);
+
+ const textSize = ctx.measureText(instance.deptNumber);
+
+ ctx.strokeStyle = 'black';
+ ctx.lineWidth = 1;
+ ctx.strokeText(instance.deptNumber, -textSize.width / 2, 0);
+
+ {
+ ctx.save();
+ ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
+ ctx.shadowBlur = 5;
+ ctx.fillStyle = 'white';
+ ctx.fillText(instance.deptNumber, -textSize.width / 2, 0);
+ ctx.restore();
+ }
+
+ ctx.restore();
+ }
+
+ // Labels around outside.
+ const labelDistance = 20;
+ const labelHorizontalOffset = 60;
+ const outsideMidSegmentPoint = this.center.add(GeometryHelpers.createPointOnCircle(midAngleRadians, this.outerRadius + labelDistance));
+ let outerLabelLocation = outsideMidSegmentPoint;
+ const isRightSide = midAngleRadians < Math.PI /2 || midAngleRadians > Math.PI * 1.5;
+ if (isRightSide)
+ outerLabelLocation = outsideMidSegmentPoint.add(new Point(labelHorizontalOffset, 0));
+ else
+ outerLabelLocation = outsideMidSegmentPoint.add(new Point(-labelHorizontalOffset, 0));
+
+ {
+ ctx.save();
+
+ ctx.translate(outerLabelLocation.x, outerLabelLocation.y);
+
+ ctx.font = '12px "Helvetica Neue", Helvetica, sans-serif';
+ ctx.fillStyle = 'black';
+
+ let textOffset = 0;
+ if (!isRightSide)
+ textOffset = -ctx.measureText(instance.label).width;
+
+ ctx.fillText(instance.label, textOffset, 0);
+ ctx.restore();
+ }
+
+ const wedgeArrowEnd = this.center.add(GeometryHelpers.createPointOnCircle(midAngleRadians, this.outerRadius));
+ const wedgeArrowEndAngle = MathHelpers.normalizeRadians(midAngleRadians + Math.PI);
+ const arrowPath = this.#pathForArrow(outerLabelLocation, wedgeArrowEnd, wedgeArrowEndAngle);
+
+ // Arrow.
+ {
+ ctx.save();
+ ctx.strokeStyle = 'gray';
+ ctx.setLineDash([4, 2]);
+ ctx.stroke(arrowPath);
+ ctx.restore();
+ }
+
+ // Arrowhead.
+ {
+ ctx.save();
+ const arrowheadPath = this.#pathForArrowHead();
+
+ ctx.translate(wedgeArrowEnd.x, wedgeArrowEnd.y);
+ const arrowheadSize = 12;
+ ctx.scale(arrowheadSize, arrowheadSize);
+ ctx.rotate(midAngleRadians);
+
+ ctx.fillStyle = 'gray';
+ ctx.fill(arrowheadPath);
+
+ ctx.restore();
+ }
+ }
+
+ #drawBadge(ctx, index, instance)
+ {
+ const midAngleRadians = this.#wedgeStartAngle(index) + 0.5 * this.wedgeAngleRadians;
+ const imageAngle = midAngleRadians + Math.PI / 2;
+
+ const imageInset = 30;
+ const imageCenterPoint = this.center.add(GeometryHelpers.createPointOnCircle(midAngleRadians, this.outerRadius - imageInset));
+
+ ctx.save();
+
+ const wedgePath = this.#pathForWedge(index, this.outerRadius);
+ ctx.clip(wedgePath);
+
+ ctx.translate(imageCenterPoint.x, imageCenterPoint.y);
+ ctx.rotate(imageAngle);
+
+ ctx.shadowColor = "black";
+ ctx.shadowBlur = 5;
+
+ const imageSize = new Size(20, 20);
+ ctx.drawImage(instance.image, -imageSize.width / 2, 0, imageSize.width, imageSize.height);
+ ctx.restore();
+ }
+
+ #locationForOuterLabel(index)
+ {
+ const labelsPerSide = this.numSpokes / 2;
+
+ const horizonalEdgeOffset = 100;
+ const verticalEdgeOffset = 20;
+ const verticalSpacing = this.outerRadius * 2 / labelsPerSide;
+
+ if (index <= labelsPerSide) {
+ // Right side, going down.
+ const labelX = horizonalEdgeOffset + this.center.x + this.outerRadius;
+ const labelY = verticalEdgeOffset + index * verticalSpacing;
+
+ return new Point(labelX, labelY);
+
+ } else {
+ // Left side, going up.
+ const bottomY = this.center.y + this.outerRadius;
+
+ const labelX = this.center.x - (horizonalEdgeOffset + this.outerRadius);
+ const labelY = bottomY - (index - labelsPerSide) * verticalSpacing;
+
+ return new Point(labelX, labelY);
+ }
+ }
+
+ #pathForArrow(startPoint, endPoint, endAngle)
+ {
+ const arrowPath = new Path2D();
+ arrowPath.moveTo(startPoint.x, startPoint.y);
+ // Compute a bezier path that keeps the line horizontal at the start and end.
+
+ const distance = startPoint.subtract(endPoint).length();
+
+ const controlPointProportion = 0.5;
+ const controlPoint1 = startPoint.add({ x: controlPointProportion * (endPoint.x - startPoint.x), y: 0});
+
+ const controlPoint2Offset = new Point(controlPointProportion * distance * Math.cos(endAngle), controlPointProportion * distance * Math.sin(endAngle));
+ const controlPoint2 = endPoint.subtract(controlPoint2Offset);
+
+ arrowPath.bezierCurveTo(controlPoint1.x, controlPoint1.y, controlPoint2.x, controlPoint2.y, endPoint.x, endPoint.y);
+ return arrowPath;
+ }
+
+ #pathForArrowHead()
+ {
+ // Arrowhead points left.
+ const arrowHeadPath = new Path2D();
+ const pointyness = 0.5;
+ const breadth = 0.4;
+
+ arrowHeadPath.moveTo(0, 0);
+ arrowHeadPath.quadraticCurveTo(pointyness, 0, 1, breadth);
+ arrowHeadPath.lineTo(1, -breadth);
+ arrowHeadPath.quadraticCurveTo(pointyness, 0, 0, 0);
+ arrowHeadPath.closePath();
+
+ return arrowHeadPath;
+ }
+}
+
+class RadialChartStage extends Stage {
+ constructor(canvasObject)
+ {
+ super();
+ this._canvasObject = canvasObject;
+ this.charts = [];
+ this.instanceData = [];
+ }
+
+ async initialize(benchmark, options)
+ {
+ await super.initialize(benchmark, options);
+
+ const dpr = window.devicePixelRatio || 1;
+ this.canvasDPR = Math.min(Math.floor(dpr), 2); // Just use 1 or 2.
+
+ const canvasClientRect = this._canvasObject.getBoundingClientRect();
+
+ this._canvasObject.width = canvasClientRect.width * dpr;
+ this._canvasObject.height = canvasClientRect.height * dpr;
+
+ this.canvasSize = new Size(this._canvasObject.width / this.canvasDPR, this._canvasObject.height / this.canvasDPR);
+ this._complexity = 0;
+
+ await this.#loadDataJSON();
+ await this.#loadImages();
+
+ this.context = this._canvasObject.getContext("2d");
+ this.context.scale(this.canvasDPR, this.canvasDPR);
+ }
+
+ tune(count)
+ {
+ if (count == 0)
+ return;
+
+ this._complexity += count;
+ // console.log(`tune ${count} - complexity is ${this._complexity}`);
+ this.#setupCharts();
+ }
+
+ async #loadDataJSON()
+ {
+ const url = "resources/departements-region.json";
+ const response = await fetch(url);
+ if (!response.ok) {
+ const errorString = `Failed to load data source ${url} with error ${response.status}`
+ console.error(errorString);
+ throw errorString;
+ }
+
+ const jsonData = await response.json();
+ for (const item of jsonData) {
+ // this.instanceData.push(new ItemData(item['dep_name'], `resources/department-shields${item['dep_name']}.png`));
+ this.instanceData.push(new ItemData(item['num_dep'], item['dep_name'], `resources/department-shields/Ain.png`));
+ }
+ }
+
+ async #loadImages()
+ {
+ let promises = [];
+ for (const instance of this.instanceData)
+ promises.push(instance.loadImage());
+
+ await Promise.all(promises);
+ }
+
+ #setupCharts()
+ {
+ const maxSegmentsPerChart = 100;
+ const numCharts = Math.ceil(this._complexity / maxSegmentsPerChart);
+
+ const perChartComplexity = Math.ceil(this._complexity / numCharts);
+ let remainingComplexity = this._complexity;
+
+ // FIXME: Outer charts should have more items because there's more space.
+ if (numCharts === this.charts.length) {
+ for (let i = this.charts.length; i > 0; --i) {
+ const chartComplexity = Math.min(perChartComplexity, remainingComplexity);
+
+ this.charts[i - 1].complexity = chartComplexity;
+ remainingComplexity -= chartComplexity;
+ }
+ return;
+ }
+
+ this.charts = [];
+
+ const centerPoint = new Point(this.canvasSize.width / 2, this.canvasSize.height / 2);
+
+ const outerRadius = this.canvasSize.height * 0.45;
+ const annulusRadius = outerRadius / numCharts;
+
+ for (let i = numCharts; i > 0; --i) {
+ const outerRadius = i * annulusRadius;
+ const innerRadius = outerRadius - (annulusRadius * 0.7)
+
+ const chart = new RadialChart(this, centerPoint, innerRadius, outerRadius);
+ const chartComplexity = Math.min(perChartComplexity, remainingComplexity);
+
+ chart.complexity = chartComplexity;
+ this.charts.push(chart);
+ remainingComplexity -= chartComplexity;
+ }
+ }
+
+ instanceForIndex(index)
+ {
+ return this.instanceData[index % this.instanceData.length];
+ }
+
+ animate()
+ {
+ const context = this.context;
+ context.clearRect(0, 0, this.canvasSize.width, this.canvasSize.height);
+
+ for (const chart of this.charts) {
+ chart.draw(context);
+ }
+ }
+
+ complexity()
+ {
+ return this._complexity;
+ }
+}
+
+class RadialChartBenchmark extends Benchmark {
+ constructor(options)
+ {
+ const canvas = document.getElementById('stage-canvas');
+ super(new RadialChartStage(canvas), options);
+ }
+}
+
+window.benchmarkClass = RadialChartBenchmark;
+
+class FakeController {
+ constructor()
+ {
+ this.initialComplexity = 200;
+ this.startTime = new Date;
+ }
+
+ shouldStop()
+ {
+ const now = new Date();
+ return (now - this.startTime) > 500;
+ }
+
+ results()
+ {
+ return [];
+ }
+}
+
+// Testing
+window.addEventListener('load', async () => {
+ if (!(window === window.parent))
+ return;
+
+ var benchmark = new window.benchmarkClass({ });
+ benchmark._controller = new FakeController();
+ await benchmark.initialize({ });
+
+ benchmark.run().then(function(testData) {
+
+ });
+
+}, false);