UNPKG

16.7 kBJavaScriptView Raw
1import WUPBaseElement from "./baseElement";
2import WUPPopupElement from "./popup/popupElement";
3import animate from "./helpers/animate";
4import { mathScaleValue, rotate } from "./helpers/math";
5import { parseMsTime } from "./helpers/styleHelpers";
6import { onEvent } from "./indexHelpers";
7// example: https://medium.com/@pppped/how-to-code-a-responsive-circular-percentage-chart-with-svg-and-css-3632f8cd7705
8// similar https://github.com/w8r/svg-arc-corners
9// demo https://milevski.co/svg-arc-corners/demo/
10const tagName = "wup-circle";
11const radius = 50;
12/** Arc/circle chart based on SVG
13 * @example
14 * <wup-circle
15 * back="true"
16 * from="0"
17 * to="360"
18 * space="2"
19 * min="0"
20 * max="100"
21 * width="14"
22 * corner="0.25"
23 * items="window.circleItems"
24 * ></wup-circle>
25 * // or JS/TS
26 * const el = document.createElement("wup-circle");
27 * el.$options.items = [{value:20}]; // etc.
28 * document.body.appendChild(el); */
29export 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 /** Creates new svg-part */
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); // NiceToHave: instead of re-init update/remove required children
128 this.removeChildren.call(this.$refSVG); // clean before new render
129 }
130 this.gotRenderItems();
131 }
132 /** Returns array of render-angles according to pointed arguments */
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 // calc angle-value per item
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; // gather sum of difference to apply later
148 ++diffCnt;
149 a.v = minSizeDeg; // not allow angle to be < minSize
150 }
151 return a;
152 });
153 // smash difference to other segments
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 // possible issue when items so many that we don't have enough-space
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 // assign values without minSize
173 items.forEach((s, i) => {
174 arr[i].v = mathScaleValue(s.value, valueMin, valueMax, angleMin, angleMax) - angleMin;
175 });
176 }
177 }
178 // calc result
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 // calc min possible segment size so cornerR can fit
199 const inR = radius - width + corner * width;
200 const minDegCorner = (Math.min(width, width * 2 * corner) * 180) / Math.PI / inR; // min segment degrees according to corner radius
201 const minDeg = Math.max(minsize, minDegCorner);
202 const arr = this.mapItems(vMin, vMax, angleMin, angleMax, this._opts.space, minDeg, animTime, items);
203 // render background circle
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 // render items
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 // apply colors
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); // only for saving as file-image
221 if (a.color) {
222 path.style.fill = a.color; // attr [fill] can't override css-rules
223 }
224 path._center = { x: radius, y: hw };
225 if (a.tooltip) {
226 hasTooltip = true;
227 path._hasTooltip = true;
228 // calc center for tooltip
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 // animate
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 // render/remove label
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 /** Called when need to show popup over segment */
258 renderTooltip(segment) {
259 const popup = document.createElement("wup-popup");
260 popup.$options.showCase = 0 /* ShowCases.always */;
261 popup.$options.target = segment;
262 popup.$options.arrowEnable = true;
263 // place in the center of drawed path
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 /** Function to remove tooltipListener */
283 _tooltipDisposeLst;
284 /** Apply tooltip listener; */
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 // impossible to use popupListen because it listens for single target but we need per each segment
295 this._tooltipDisposeLst = onEvent(this, "mouseenter", // mouseenter is fired even with touch event (mouseleave fired with touch outside in this case)
296 (e) => {
297 // NiceToHave: rewrite popupListen to use here
298 const t = e.target;
299 if (t._hasTooltip) {
300 t._tid && clearTimeout(t._tid); // remove timer for mouseleave
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); // remove timer for mouseenter
309 t._tid = setTimeout(() => {
310 t._tooltip?.$hide().finally(() => {
311 // popup can be opened when user returns mouse back in a short time
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 /** Called on every changeEvent */
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 /** Called every time as need text-value */
331 // eslint-disable-next-line @typescript-eslint/no-unused-vars
332 renderLabel(label, percent, rawValue) {
333 label.textContent = `${Math.round(percent)}%`;
334 }
335 /** Returns svg-path for Arc according to options */
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}
342customElements.define(tagName, WUPCircleElement);
343/** Returns svg-path for Cirle according to options */
344export 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/** Returns x,y for point in the circle */
358function 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/** Returns svg-path for Arc according to options */
363export 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 // inner and outer radiuses
372 const inR2 = inR + cornerR;
373 const outR = r - cornerR;
374 // butts corner points
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 // arcs endpoints
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", // end path
398 ].join(" ");
399}