1 | import WUPBaseElement from "./baseElement";
|
2 | import WUPPopupElement from "./popup/popupElement";
|
3 | import animate from "./helpers/animate";
|
4 | import { mathScaleValue, rotate } from "./helpers/math";
|
5 | import { parseMsTime } from "./helpers/styleHelpers";
|
6 | import { onEvent } from "./indexHelpers";
|
7 |
|
8 |
|
9 |
|
10 | const tagName = "wup-circle";
|
11 | const radius = 50;
|
12 |
|
13 |
|
14 |
|
15 |
|
16 |
|
17 |
|
18 |
|
19 |
|
20 |
|
21 |
|
22 |
|
23 |
|
24 |
|
25 |
|
26 |
|
27 |
|
28 |
|
29 | export default class WUPCircleElement extends WUPBaseElement {
|
30 | #ctr = this.constructor;
|
31 | static get observedOptions() {
|
32 | return this.observedAttributes;
|
33 | }
|
34 | static get observedAttributes() {
|
35 | return ["items", "width", "back", "corner", "from", "to", "min", "max", "space", "minsize"];
|
36 | }
|
37 | static get $styleRoot() {
|
38 | return `:root {
|
39 | --circle-0: var(--base-sep);
|
40 | --circle-1: var(--base-btn-bg);
|
41 | --circle-2: #ff9f00;
|
42 | --circle-3: #1fb13f;
|
43 | --circle-4: #9482bd;
|
44 | --circle-5: #8bc4d7;
|
45 | --circle-6: #1abdb5;
|
46 | }`;
|
47 | }
|
48 | static get $style() {
|
49 | return `${super.$style}
|
50 | :host {
|
51 | contain: style;
|
52 | display: block;
|
53 | position: relative;
|
54 | overflow: visible;
|
55 | margin: auto;
|
56 | --anim-time: 400ms;
|
57 | }
|
58 | :host>strong {
|
59 | display: block;
|
60 | position: absolute;
|
61 | transform: translate(-50%,-50%);
|
62 | top: 50%; left: 50%;
|
63 | font-size: larger;
|
64 | }
|
65 | :host>svg {
|
66 | overflow: visible;
|
67 | display: block;
|
68 | }
|
69 | :host>svg path {
|
70 | stroke-width: 0;
|
71 | fill-rule: evenodd;
|
72 | }
|
73 | :host>svg>path { fill: var(--circle-0); }
|
74 | :host>svg>g>path:nth-child(1) { fill: var(--circle-1); }
|
75 | :host>svg>g>path:nth-child(2) { fill: var(--circle-2); }
|
76 | :host>svg>g>path:nth-child(3) { fill: var(--circle-3); }
|
77 | :host>svg>g>path:nth-child(4) { fill: var(--circle-4); }
|
78 | :host>svg>g>path:nth-child(5) { fill: var(--circle-5); }
|
79 | :host>svg>g>path:nth-child(6) { fill: var(--circle-6); }
|
80 | :host>wup-popup,
|
81 | :host>wup-popup-arrow {
|
82 | white-space: pre;
|
83 | pointer-events: none;
|
84 | user-select: none;
|
85 | touch-action: none;
|
86 | }
|
87 | :host>wup-popup {
|
88 | background: rgba(255,255,255,0.9)
|
89 | }
|
90 | :host>wup-popup-arrow:before {
|
91 | opacity: 0.9;
|
92 | margin: 0;
|
93 | }`;
|
94 | }
|
95 | static $defaults = {
|
96 | width: 14,
|
97 | corner: 0.25,
|
98 | back: true,
|
99 | from: 0,
|
100 | to: 360,
|
101 | min: 0,
|
102 | max: 100,
|
103 | space: 2,
|
104 | minsize: 10,
|
105 | hoverShowTimeout: WUPPopupElement.$defaults.hoverShowTimeout,
|
106 | hoverHideTimeout: 0,
|
107 | };
|
108 | constructor() {
|
109 | super();
|
110 | this._opts.items = [];
|
111 | }
|
112 | $refSVG = this.make("svg");
|
113 | $refItems = this.make("g");
|
114 | $refLabel;
|
115 |
|
116 | make(tag) {
|
117 | return document.createElementNS("http://www.w3.org/2000/svg", tag);
|
118 | }
|
119 | gotChanges(propsChanged) {
|
120 | super.gotChanges(propsChanged);
|
121 | this._opts.items = this.getAttr("items", "ref") || [];
|
122 | this._opts.back = this.getAttr("back", "bool") || false;
|
123 | ["width", "corner", "from", "to", "min", "max", "space", "minsize"].forEach((key) => {
|
124 | this._opts[key] = this.getAttr(key, "number");
|
125 | });
|
126 | if (propsChanged) {
|
127 | this.removeChildren.call(this.$refItems);
|
128 | this.removeChildren.call(this.$refSVG);
|
129 | }
|
130 | this.gotRenderItems();
|
131 | }
|
132 |
|
133 | mapItems(valueMin, valueMax, angleMin, angleMax, space, minSizeDeg, animTime, items) {
|
134 | if (items.length > 1) {
|
135 | valueMin = 0;
|
136 | valueMax = items.reduce((v, item) => item.value + v, 0);
|
137 | angleMax -= (items.length - (angleMax - angleMin === 360 ? 0 : 1)) * space;
|
138 | }
|
139 | let angleFrom = angleMin;
|
140 | let diff = 0;
|
141 | let diffCnt = 0;
|
142 |
|
143 | const arr = items.map((s) => {
|
144 | const v = mathScaleValue(s.value, valueMin, valueMax, angleMin, angleMax) - angleMin;
|
145 | const a = { angleFrom: 0, angleTo: 0, ms: 0, v };
|
146 | if (v !== 0 && v < minSizeDeg) {
|
147 | diff += minSizeDeg - v;
|
148 | ++diffCnt;
|
149 | a.v = minSizeDeg;
|
150 | }
|
151 | return a;
|
152 | });
|
153 |
|
154 | if (diff !== 0 && arr.length !== 1) {
|
155 | let cnt = arr.length - diffCnt;
|
156 | arr.forEach((a) => {
|
157 | if (a.v > minSizeDeg) {
|
158 | let v = diff / cnt;
|
159 | let next = a.v - v;
|
160 | if (next < minSizeDeg) {
|
161 | v -= minSizeDeg - next;
|
162 | next = minSizeDeg;
|
163 | }
|
164 | a.v = next;
|
165 | diff -= v;
|
166 | --cnt;
|
167 | }
|
168 | });
|
169 | if (diff > 0) {
|
170 |
|
171 | console.error("WUP-CIRCLE. Impossible to increase segments up to $options.minSize. Change [minSize] or filter items yourself. arguments:", { valueMin, valueMax, angleMin, angleMax, space, minSize: minSizeDeg, animTime, items });
|
172 |
|
173 | items.forEach((s, i) => {
|
174 | arr[i].v = mathScaleValue(s.value, valueMin, valueMax, angleMin, angleMax) - angleMin;
|
175 | });
|
176 | }
|
177 | }
|
178 |
|
179 | const totalAngle = angleMax - angleMin;
|
180 | arr.forEach((a) => {
|
181 | a.angleFrom = angleFrom;
|
182 | a.angleTo = angleFrom + a.v;
|
183 | a.ms = items.length === 1 ? animTime : Math.abs((a.v * animTime) / totalAngle);
|
184 | angleFrom = a.angleTo + space;
|
185 | });
|
186 | return arr;
|
187 | }
|
188 | _animation;
|
189 | gotRenderItems() {
|
190 | this._animation?.stop(false);
|
191 | const angleMin = this._opts.from;
|
192 | const angleMax = this._opts.to;
|
193 | const vMin = this._opts.min ?? 0;
|
194 | const vMax = this._opts.max ?? 360;
|
195 | const { items, minsize, corner, width } = this._opts;
|
196 | const style = getComputedStyle(this);
|
197 | const animTime = parseMsTime(style.getPropertyValue("--anim-time"));
|
198 |
|
199 | const inR = radius - width + corner * width;
|
200 | const minDegCorner = (Math.min(width, width * 2 * corner) * 180) / Math.PI / inR;
|
201 | const minDeg = Math.max(minsize, minDegCorner);
|
202 | const arr = this.mapItems(vMin, vMax, angleMin, angleMax, this._opts.space, minDeg, animTime, items);
|
203 |
|
204 | if (this._opts.back) {
|
205 | const back = this.make("path");
|
206 | back.setAttribute("d", this.drawArc(angleMin, angleMax));
|
207 | this.$refSVG.appendChild(back);
|
208 | }
|
209 |
|
210 | this.$refSVG.appendChild(this.$refItems);
|
211 | (async () => {
|
212 | let hasTooltip = false;
|
213 | const hw = this._opts.width / 2;
|
214 | for (let i = 0; i < items.length; ++i) {
|
215 | const a = items[i];
|
216 | const c = arr[i];
|
217 |
|
218 | const path = this.$refItems.appendChild(this.make("path"));
|
219 | const col = a.color || style.getPropertyValue(`--circle-${i + 1}`).trim();
|
220 | col && path.setAttribute("fill", col);
|
221 | if (a.color) {
|
222 | path.style.fill = a.color;
|
223 | }
|
224 | path._center = { x: radius, y: hw };
|
225 | if (a.tooltip) {
|
226 | hasTooltip = true;
|
227 | path._hasTooltip = true;
|
228 |
|
229 | const ca = c.angleFrom + (c.angleTo - c.angleFrom) / 2;
|
230 | [path._center.x, path._center.y] = rotate(radius, radius, radius, hw, ca);
|
231 | }
|
232 | path._relatedItem = a;
|
233 |
|
234 | this._animation = animate(c.angleFrom, c.angleTo, c.ms, (animV) => {
|
235 | path.setAttribute("d", this.drawArc(c.angleFrom, animV));
|
236 | });
|
237 | await this._animation.catch().finally(() => delete this._animation);
|
238 | }
|
239 | this.useTooltip(hasTooltip);
|
240 | })();
|
241 |
|
242 | let ariaLbl = "";
|
243 | if (items.length === 1) {
|
244 | this.$refLabel = this.$refLabel ?? this.appendChild(document.createElement("strong"));
|
245 | const rawV = items[0].value;
|
246 | const perc = mathScaleValue(rawV, vMin, vMax, 0, 100);
|
247 | this.renderLabel(this.$refLabel, perc, rawV);
|
248 | ariaLbl = this.$refLabel.textContent;
|
249 | }
|
250 | else {
|
251 | this.$refLabel && this.$refLabel.remove();
|
252 | delete this.$refLabel;
|
253 | ariaLbl = `Values: ${items.map((a) => a.value).join(",")}`;
|
254 | }
|
255 | this.$refSVG.setAttribute("aria-label", ariaLbl);
|
256 | }
|
257 |
|
258 | renderTooltip(segment) {
|
259 | const popup = document.createElement("wup-popup");
|
260 | popup.$options.showCase = 0 ;
|
261 | popup.$options.target = segment;
|
262 | popup.$options.arrowEnable = true;
|
263 |
|
264 | popup.getTargetRect = () => {
|
265 | const r = this.$refSVG.getBoundingClientRect();
|
266 | const scale = Math.min(r.width, r.height) / 100;
|
267 | const x = r.x + segment._center.x * scale;
|
268 | const y = r.y + segment._center.y * scale;
|
269 | return DOMRect.fromRect({ x, y, width: 0.01, height: 0.01 });
|
270 | };
|
271 | const total = this.$options.items.reduce((sum, a) => sum + a.value, 0);
|
272 | const item = { ...segment._relatedItem, percentage: mathScaleValue(segment._relatedItem.value, 0, total, 0, 100) };
|
273 | const lbl = item.tooltip;
|
274 | popup.innerText =
|
275 | typeof lbl === "function"
|
276 | ? lbl.call(this, item, popup)
|
277 | : lbl
|
278 | .replace("{#}", item.value.toString())
|
279 | .replace("{#%}", `${(Math.round(item.percentage * 10) / 10).toString()}%`);
|
280 | return this.appendChild(popup);
|
281 | }
|
282 |
|
283 | _tooltipDisposeLst;
|
284 |
|
285 | useTooltip(isEnable) {
|
286 | if (!isEnable) {
|
287 | this._tooltipDisposeLst?.call(this);
|
288 | this._tooltipDisposeLst = undefined;
|
289 | return;
|
290 | }
|
291 | if (this._tooltipDisposeLst) {
|
292 | return;
|
293 | }
|
294 |
|
295 | this._tooltipDisposeLst = onEvent(this, "mouseenter",
|
296 | (e) => {
|
297 |
|
298 | const t = e.target;
|
299 | if (t._hasTooltip) {
|
300 | t._tid && clearTimeout(t._tid);
|
301 | t._tid = setTimeout(() => {
|
302 | if (t._tooltip)
|
303 | t._tooltip.$show();
|
304 | else
|
305 | t._tooltip = this.renderTooltip(t);
|
306 | }, this._opts.hoverShowTimeout);
|
307 | onEvent(e.target, "mouseleave", () => {
|
308 | t._tid && clearTimeout(t._tid);
|
309 | t._tid = setTimeout(() => {
|
310 | t._tooltip?.$hide().finally(() => {
|
311 |
|
312 | if (t._tooltip && !t._tooltip.$isOpen) {
|
313 | t._tooltip.remove();
|
314 | t._tooltip = undefined;
|
315 | }
|
316 | });
|
317 | t._tid = undefined;
|
318 | }, this._opts.hoverHideTimeout);
|
319 | }, { once: true });
|
320 | }
|
321 | }, { capture: true, passive: true });
|
322 | }
|
323 |
|
324 | gotRender() {
|
325 | super.gotRender();
|
326 | this.$refSVG.setAttribute("viewBox", `0 0 100 100`);
|
327 | this.$refSVG.setAttribute("role", "img");
|
328 | this.appendChild(this.$refSVG);
|
329 | }
|
330 |
|
331 |
|
332 | renderLabel(label, percent, rawValue) {
|
333 | label.textContent = `${Math.round(percent)}%`;
|
334 | }
|
335 |
|
336 | drawArc(angleFrom, angleTo) {
|
337 | const r = radius;
|
338 | const center = [r, r];
|
339 | return drawArc(center, r, this._opts.width, angleFrom, angleTo, this._opts.corner);
|
340 | }
|
341 | }
|
342 | customElements.define(tagName, WUPCircleElement);
|
343 |
|
344 | export function drawCircle(center, r, width) {
|
345 | const inR = r - width;
|
346 | const [x, y] = center;
|
347 | return [
|
348 | `M${x - r} ${y}`,
|
349 | `A${r} ${r} 0 1 0 ${x + r} ${y}`,
|
350 | `A${r} ${r} 0 1 0 ${x - r} ${y}`,
|
351 | `M${x - inR} ${y}`,
|
352 | `A${inR} ${inR} 0 1 0 ${x + inR} ${y}`,
|
353 | `A${inR} ${inR} 0 1 0 ${x - inR} ${y}`,
|
354 | "Z",
|
355 | ].join(" ");
|
356 | }
|
357 |
|
358 | function pointOnArc(center, r, angle) {
|
359 | const radians = ((angle - 90) * Math.PI) / 180.0;
|
360 | return [center[0] + r * Math.cos(radians), center[1] + r * Math.sin(radians)];
|
361 | }
|
362 |
|
363 | export function drawArc(center, r, width, angleFrom, angleTo, cornerR) {
|
364 | if (Math.abs(angleTo - angleFrom) === 360) {
|
365 | return drawCircle(center, r, width);
|
366 | }
|
367 | const inR = r - width;
|
368 | const circumference = Math.abs(angleTo - angleFrom);
|
369 | const maxCorner = (circumference / 360) * Math.PI * (r - width / 2);
|
370 | cornerR = Math.min(width / 2, width * cornerR, maxCorner);
|
371 |
|
372 | const inR2 = inR + cornerR;
|
373 | const outR = r - cornerR;
|
374 |
|
375 | const oStart = pointOnArc(center, outR, angleFrom);
|
376 | const oEnd = pointOnArc(center, outR, angleTo);
|
377 | const iStart = pointOnArc(center, inR2, angleFrom);
|
378 | const iEnd = pointOnArc(center, inR2, angleTo);
|
379 | const iSection = 360 * (cornerR / (2 * Math.PI * inR));
|
380 | const oSection = 360 * (cornerR / (2 * Math.PI * r));
|
381 |
|
382 | const iArcStart = pointOnArc(center, inR, angleFrom + iSection);
|
383 | const iArcEnd = pointOnArc(center, inR, angleTo - iSection);
|
384 | const oArcStart = pointOnArc(center, r, angleFrom + oSection);
|
385 | const oArcEnd = pointOnArc(center, r, angleTo - oSection);
|
386 | const arcSweep1 = circumference > 180 + 2 * oSection ? 1 : 0;
|
387 | const arcSweep2 = circumference > 180 + 2 * iSection ? 1 : 0;
|
388 | return [
|
389 | `M ${oStart[0]} ${oStart[1]}`,
|
390 | `A ${cornerR} ${cornerR} 0 0 1 ${oArcStart[0]} ${oArcStart[1]}`,
|
391 | `A ${r} ${r} 0 ${arcSweep1} 1 ${oArcEnd[0]} ${oArcEnd[1]}`,
|
392 | `A ${cornerR} ${cornerR} 0 0 1 ${oEnd[0]} ${oEnd[1]}`,
|
393 | `L ${iEnd[0]} ${iEnd[1]}`,
|
394 | `A ${cornerR} ${cornerR} 0 0 1 ${iArcEnd[0]} ${iArcEnd[1]}`,
|
395 | `A ${inR} ${inR} 0 ${arcSweep2} 0 ${iArcStart[0]} ${iArcStart[1]}`,
|
396 | `A ${cornerR} ${cornerR} 0 0 1 ${iStart[0]} ${iStart[1]}`,
|
397 | "Z",
|
398 | ].join(" ");
|
399 | }
|