WebUI: Use Map instead of Mootools Hash in Torrents table
[qBittorrent.git] / src / webui / www / private / scripts / piecesbar.js
blobbfaf540e47458ea40c4ebe35e1f68ea5d87170f4
1 /*
2  * Bittorrent Client using Qt and libtorrent.
3  * Copyright (C) 2022  Jesse Smick <jesse.smick@gmail.com>
4  *
5  * This program is free software; you can redistribute it and/or
6  * modify it under the terms of the GNU General Public License
7  * as published by the Free Software Foundation; either version 2
8  * of the License, or (at your option) any later version.
9  *
10  * This program is distributed in the hope that it will be useful,
11  * but WITHOUT ANY WARRANTY; without even the implied warranty of
12  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13  * GNU General Public License for more details.
14  *
15  * You should have received a copy of the GNU General Public License
16  * along with this program; if not, write to the Free Software
17  * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18  *
19  * In addition, as a special exception, the copyright holders give permission to
20  * link this program with the OpenSSL project's "OpenSSL" library (or with
21  * modified versions of it that use the same license as the "OpenSSL" library),
22  * and distribute the linked executables. You must obey the GNU General Public
23  * License in all respects for all of the code used other than "OpenSSL".  If you
24  * modify file(s), you may extend this exception to your version of the file(s),
25  * but you are not obligated to do so. If you do not wish to do so, delete this
26  * exception statement from your version.
27  */
29 "use strict";
31 window.qBittorrent ??= {};
32 window.qBittorrent.PiecesBar ??= (() => {
33     const exports = () => {
34         return {
35             PiecesBar: PiecesBar
36         };
37     };
39     const STATUS_DOWNLOADING = 1;
40     const STATUS_DOWNLOADED = 2;
42     // absolute max width of 4096
43     // this is to support all browsers for size of canvas elements
44     // see https://github.com/jhildenbiddle/canvas-size#test-results
45     const MAX_CANVAS_WIDTH = 4096;
47     let piecesBarUniqueId = 0;
48     const PiecesBar = new Class({
49         initialize(pieces, parameters) {
50             const vals = {
51                 "id": "piecesbar_" + (piecesBarUniqueId++),
52                 "width": 0,
53                 "height": 0,
54                 "downloadingColor": "hsl(110deg 94% 27%)", // @TODO palette vars not supported for this value, apply average
55                 "haveColor": "hsl(210deg 55% 55%)", // @TODO palette vars not supported for this value, apply average
56                 "borderSize": 1,
57                 "borderColor": "var(--color-border-default)"
58             };
60             if (parameters && (typeOf(parameters) === "object"))
61                 Object.append(vals, parameters);
62             vals.height = Math.max(vals.height, 12);
64             const obj = new Element("div", {
65                 "id": vals.id,
66                 "class": "piecesbarWrapper",
67                 "styles": {
68                     "border": vals.borderSize.toString() + "px solid " + vals.borderColor,
69                     "height": vals.height.toString() + "px",
70                 }
71             });
72             obj.vals = vals;
73             obj.vals.pieces = [pieces, []].pick();
75             obj.vals.canvas = new Element("canvas", {
76                 "id": vals.id + "_canvas",
77                 "class": "piecesbarCanvas",
78                 "width": (vals.width - (2 * vals.borderSize)).toString(),
79                 "height": "1" // will stretch vertically to take up the height of the parent
80             });
81             obj.appendChild(obj.vals.canvas);
83             obj.setPieces = setPieces;
84             obj.refresh = refresh;
85             obj.clear = setPieces.bind(obj, []);
86             obj._drawStatus = drawStatus;
88             if (vals.width > 0)
89                 obj.setPieces(vals.pieces);
90             else
91                 setTimeout(() => { checkForParent(obj.id); });
93             return obj;
94         }
95     });
97     function setPieces(pieces) {
98         if (!Array.isArray(pieces))
99             pieces = [];
101         this.vals.pieces = pieces;
102         this.refresh(true);
103     }
105     function refresh(force) {
106         if (!this.parentNode)
107             return;
109         const pieces = this.vals.pieces;
111         // if the number of pieces is small, use that for the width,
112         // and have it stretch horizontally.
113         // this also limits the ratio below to >= 1
114         const width = Math.min(this.offsetWidth, pieces.length, MAX_CANVAS_WIDTH);
115         if ((this.vals.width === width) && !force)
116             return;
118         this.vals.width = width;
120         // change canvas size to fit exactly in the space
121         this.vals.canvas.width = width - (2 * this.vals.borderSize);
123         const canvas = this.vals.canvas;
124         const ctx = canvas.getContext("2d");
125         ctx.clearRect(0, 0, canvas.width, canvas.height);
127         const imageWidth = canvas.width;
129         if (imageWidth.length === 0)
130             return;
132         let minStatus = Infinity;
133         let maxStatus = 0;
135         for (const status of pieces) {
136             if (status > maxStatus)
137                 maxStatus = status;
138             if (status < minStatus)
139                 minStatus = status;
140         }
142         // if no progress then don't do anything
143         if (maxStatus === 0)
144             return;
146         // if all pieces are downloaded, fill entire image at once
147         if (minStatus === STATUS_DOWNLOADED) {
148             ctx.fillStyle = this.vals.haveColor;
149             ctx.fillRect(0, 0, canvas.width, canvas.height);
150             return;
151         }
153         /* Linear transformation from pieces to pixels.
154          *
155          * The canvas size can vary in width so this figures out what to draw at each pixel.
156          * Inspired by the GUI code here https://github.com/qbittorrent/qBittorrent/blob/25b3f2d1a6b14f0fe098fb79a3d034607e52deae/src/gui/properties/downloadedpiecesbar.cpp#L54
157          *
158          * example ratio > 1 (at least 2 pieces per pixel)
159          *        +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
160          * pieces |  2  |  1  |  2  |  0  |  2  |  0  |  1  |  0  |  1  |  2  |
161          *        +---------+---------+---------+---------+---------+---------+
162          * pixels |         |         |         |         |         |         |
163          *        +---------+---------+---------+---------+---------+---------+
164          *
165          * example ratio < 1 (at most 2 pieces per pixel)
166          * This case shouldn't happen since the max pixels are limited to the number of pieces
167          *        +---------+---------+---------+---------+----------+--------+
168          * pieces |    2    |    1    |    1    |    0    |    2    |    2    |
169          *        +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
170          * pixels |     |     |     |     |     |     |     |     |     |     |
171          *        +-----+-----+-----+-----+-----+-----+-----+-----+-----+-----+
172          */
174         const ratio = pieces.length / imageWidth;
176         let lastValue = null;
177         let rectangleStart = 0;
179         // for each pixel compute its status based on the pieces
180         for (let x = 0; x < imageWidth; ++x) {
181             // find positions in the pieces array
182             const piecesFrom = x * ratio;
183             const piecesTo = (x + 1) * ratio;
184             const piecesToInt = Math.ceil(piecesTo);
186             const statusValues = {
187                 [STATUS_DOWNLOADING]: 0,
188                 [STATUS_DOWNLOADED]: 0
189             };
191             // aggregate the status of each piece that contributes to this pixel
192             for (let p = piecesFrom; p < piecesToInt; ++p) {
193                 const piece = Math.floor(p);
194                 const pieceStart = Math.max(piecesFrom, piece);
195                 const pieceEnd = Math.min(piece + 1, piecesTo);
197                 const amount = pieceEnd - pieceStart;
198                 const status = pieces[piece];
200                 if (status in statusValues)
201                     statusValues[status] += amount;
202             }
204             // normalize to interval [0, 1]
205             statusValues[STATUS_DOWNLOADING] /= ratio;
206             statusValues[STATUS_DOWNLOADED] /= ratio;
208             // floats accumulate small errors, so smooth it out by rounding to hundredths place
209             // this effectively limits each status to a value 1 in 100
210             statusValues[STATUS_DOWNLOADING] = Math.round(statusValues[STATUS_DOWNLOADING] * 100) / 100;
211             statusValues[STATUS_DOWNLOADED] = Math.round(statusValues[STATUS_DOWNLOADED] * 100) / 100;
213             // float precision sometimes _still_ gives > 1
214             statusValues[STATUS_DOWNLOADING] = Math.min(statusValues[STATUS_DOWNLOADING], 1);
215             statusValues[STATUS_DOWNLOADED] = Math.min(statusValues[STATUS_DOWNLOADED], 1);
217             if (!lastValue)
218                 lastValue = statusValues;
220             // group contiguous colors together and draw as a single rectangle
221             if ((lastValue[STATUS_DOWNLOADING] === statusValues[STATUS_DOWNLOADING])
222                 && (lastValue[STATUS_DOWNLOADED] === statusValues[STATUS_DOWNLOADED]))
223                 continue;
225             const rectangleWidth = x - rectangleStart;
226             this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue);
228             lastValue = statusValues;
229             rectangleStart = x;
230         }
232         // fill a rect at the end of the canvas
233         if (rectangleStart < imageWidth) {
234             const rectangleWidth = imageWidth - rectangleStart;
235             this._drawStatus(ctx, rectangleStart, rectangleWidth, lastValue);
236         }
237     }
239     function drawStatus(ctx, start, width, statusValues) {
240         // mix the colors by using transparency and a composite mode
241         ctx.globalCompositeOperation = "lighten";
243         if (statusValues[STATUS_DOWNLOADING]) {
244             ctx.globalAlpha = statusValues[STATUS_DOWNLOADING];
245             ctx.fillStyle = this.vals.downloadingColor;
246             ctx.fillRect(start, 0, width, ctx.canvas.height);
247         }
249         if (statusValues[STATUS_DOWNLOADED]) {
250             ctx.globalAlpha = statusValues[STATUS_DOWNLOADED];
251             ctx.fillStyle = this.vals.haveColor;
252             ctx.fillRect(start, 0, width, ctx.canvas.height);
253         }
254     }
256     function checkForParent(id) {
257         const obj = $(id);
258         if (!obj)
259             return;
260         if (!obj.parentNode)
261             return setTimeout(() => { checkForParent(id); }, 100);
263         obj.refresh();
264     }
266     return exports();
267 })();
268 Object.freeze(window.qBittorrent.PiecesBar);