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);