UNPKG

web-ui-pack

Version:
371 lines (370 loc) 14.4 kB
import WUPBaseElement from "./baseElement"; import WUPPopupElement from "./popup/popupElement"; import animate from "./helpers/animate"; import { mathScaleValue, mathRotate } from "./helpers/math"; import { parseMsTime } from "./helpers/styleHelpers"; import onEvent from "./helpers/onEvent"; const tagName = "wup-circle"; const radius = 50; export default class WUPCircleElement extends WUPBaseElement { static get $styleRoot() { return `:root { --circle-0: #e4e4e4; --circle-1: #009fbc; --circle-2: #ff9f00; --circle-3: #1fb13f; --circle-4: #9482bd; --circle-5: #8bc4d7; --circle-6: #1abdb5; --circle-7: #a0db67; --circle-8: #67dbba; } [wupdark] { --circle-0: #104652; }`; } static get $style() { return `${super.$style} :host { contain: style; display: block; position: relative; overflow: visible; margin: auto; min-width: 100px; min-height: 50px; --anim-t: 400ms; } :host>strong { display: block; position: absolute; transform: translate(-50%,-50%); top: 50%; left: 50%; font-size: larger; max-width: calc(100% - 20px); white-space: pre-line; text-align: center; } :host[half] { aspect-ratio: 2; } :host[half]>strong { top: initial; bottom: 0; transform: translateX(-50%); } :host>svg { overflow: visible; display: block; } :host>svg path { stroke-width: 0; fill-rule: evenodd; } :host>svg>path { fill: var(--circle-0); } :host>svg>g>path:nth-child(1) { fill: var(--circle-1); } :host>svg>g>path:nth-child(2) { fill: var(--circle-2); } :host>svg>g>path:nth-child(3) { fill: var(--circle-3); } :host>svg>g>path:nth-child(4) { fill: var(--circle-4); } :host>svg>g>path:nth-child(5) { fill: var(--circle-5); } :host>svg>g>path:nth-child(6) { fill: var(--circle-6); } :host>svg>g>path:nth-child(7) { fill: var(--circle-7); } :host>svg>g>path:nth-child(8) { fill: var(--circle-8); } :host>wup-popup, :host>wup-popup-arrow { white-space: pre; pointer-events: none; user-select: none; touch-action: none; }`; } static $defaults = { width: 14, corner: 0.25, back: true, from: 0, to: 360, min: 0, max: 100, space: 2, minSize: 10, hoverOpenTimeout: WUPPopupElement.$defaults.hoverOpenTimeout, hoverCloseTimeout: 0, items: [], }; static cloneDefaults() { const d = super.cloneDefaults(); d.items = []; return d; } $refSVG = this.make("svg"); $refItems = this.make("g"); $refLabel; make(tag) { return document.createElementNS("http://www.w3.org/2000/svg", tag); } gotChanges(propsChanged) { this._opts.items ??= []; super.gotChanges(propsChanged); let skipAnim = false; if (propsChanged) { this.removeChildren.call(this.$refItems); this.removeChildren.call(this.$refSVG); skipAnim = true; } const isHalf = Math.abs(this._opts.to - this._opts.from) === 180; this.setAttr("half", isHalf, true); this.renderItems(skipAnim); } mapItems(valueMin, valueMax, angleMin, angleMax, space, minSizeDeg, animTime, items) { if (items.length > 1) { valueMin = 0; valueMax = items.reduce((v, item) => item.value + v, 0); angleMax -= (items.length - (angleMax - angleMin === 360 ? 0 : 1)) * space; } let angleFrom = angleMin; let diff = 0; let diffCnt = 0; const arr = items.map((s) => { const v = mathScaleValue(s.value, valueMin, valueMax, angleMin, angleMax) - angleMin; const a = { angleFrom: 0, angleTo: 0, ms: 0, v }; if (v !== 0 && v < minSizeDeg) { diff += minSizeDeg - v; ++diffCnt; a.v = minSizeDeg; } return a; }); if (diff !== 0 && arr.length !== 1) { let cnt = arr.length - diffCnt; arr.forEach((a) => { if (a.v > minSizeDeg) { let v = diff / cnt; let next = a.v - v; if (next < minSizeDeg) { v -= minSizeDeg - next; next = minSizeDeg; } a.v = next; diff -= v; --cnt; } }); if (diff > 0) { 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 }); items.forEach((s, i) => { arr[i].v = mathScaleValue(s.value, valueMin, valueMax, angleMin, angleMax) - angleMin; }); } } const totalAngle = angleMax - angleMin; arr.forEach((a) => { a.angleFrom = angleFrom; a.angleTo = angleFrom + a.v; a.ms = items.length === 1 ? animTime : Math.abs((a.v * animTime) / totalAngle); angleFrom = a.angleTo + space; }); return arr; } _animation; renderItems(skipAnim) { this._animation?.stop(false); const angleMin = this._opts.from; const angleMax = this._opts.to; const vMin = this._opts.min; const vMax = this._opts.max; const { items, minSize: minsize, corner, width } = this._opts; const style = getComputedStyle(this); const animTime = skipAnim ? 0 : parseMsTime(style.getPropertyValue("--anim-t")); const inR = radius - width + corner * width; const minDegCorner = (Math.min(width, width * 2 * corner) * 180) / Math.PI / inR; const minDeg = Math.max(minsize, minDegCorner); const arr = this.mapItems(vMin, vMax, angleMin, angleMax, this._opts.space, minDeg, animTime, items); if (this._opts.back) { const back = this.make("path"); back.setAttribute("d", this.drawArc(angleMin, angleMax)); this.$refSVG.appendChild(back); } this.$refSVG.appendChild(this.$refItems); (async () => { let hasTooltip = false; const hw = this._opts.width / 2; for (let i = 0; i < items.length; ++i) { const a = items[i]; const c = arr[i]; const path = this.$refItems.appendChild(this.make("path")); const cssVar = a.color || style.getPropertyValue(`--circle-${i + 1}`).trim() || "#000"; path.setAttribute("fill", cssVar); if (a.color) { path.style.fill = a.color; } path._definedColor = a.color || cssVar; path._center = { x: radius, y: hw }; if (a.tooltip) { hasTooltip = true; path._hasTooltip = true; const ca = c.angleFrom + (c.angleTo - c.angleFrom) / 2; [path._center.x, path._center.y] = mathRotate(radius, radius, radius, hw, ca); } path._relatedItem = a; this._animation = animate(c.angleFrom, c.angleTo, c.ms, (animV) => { path.setAttribute("d", this.drawArc(c.angleFrom, animV)); }); !skipAnim && (await this._animation.catch().finally(() => delete this._animation)); } this.useTooltip(hasTooltip); })(); let ariaLbl = ""; if (items.length === 1) { let isCustomLabel = false; if (!this.$refLabel) { const existed = this.querySelector("strong"); if (existed) { ariaLbl = existed.textContent || ""; isCustomLabel = true; } } if (!isCustomLabel) { this.$refLabel ??= this.appendChild(document.createElement("strong")); const rawV = items[0].value; const perc = mathScaleValue(rawV, vMin, vMax, 0, 100); this.renderLabel(this.$refLabel, perc, rawV); ariaLbl = this.$refLabel.textContent; } } else { this.$refLabel?.remove(); delete this.$refLabel; ariaLbl = `Values: ${items.map((a) => a.value).join(",")}`; } this.$refSVG.setAttribute("aria-label", ariaLbl); } renderTooltip(segment) { const popup = document.createElement("wup-popup"); popup.setAttribute("tooltip", ""); popup.$options.openCase = 1; popup.$options.target = segment; popup.$options.arrowEnable = true; popup.getTargetRect = () => { const r = this.$refSVG.getBoundingClientRect(); const scale = Math.min(r.width, r.height) / 100; const x = r.x + segment._center.x * scale; const y = r.y + segment._center.y * scale; return DOMRect.fromRect({ x, y, width: 0.01, height: 0.01 }); }; const total = this.$options.items.reduce((sum, a) => sum + a.value, 0); const item = { ...segment._relatedItem, color: segment._definedColor, percentage: mathScaleValue(segment._relatedItem.value, 0, total, 0, 100), }; const lbl = item.tooltip; popup.innerText = typeof lbl === "function" ? lbl.call(this, item, popup) : lbl .replace("{#}", item.value.toString()) .replace("{#%}", `${(Math.round(item.percentage * 10) / 10).toString()}%`); return this.appendChild(popup); } _tooltipDisposeLst; useTooltip(isEnable) { if (!isEnable) { this._tooltipDisposeLst?.call(this); this._tooltipDisposeLst = undefined; return; } if (this._tooltipDisposeLst) { return; } this._tooltipDisposeLst = onEvent(this, "mouseenter", (e) => { const t = e.target; if (t._hasTooltip) { t._tid && clearTimeout(t._tid); t._tid = setTimeout(() => { if (t._tooltip) t._tooltip.$open(); else t._tooltip = this.renderTooltip(t); }, this._opts.hoverOpenTimeout); onEvent(e.target, "mouseleave", () => { t._tid && clearTimeout(t._tid); t._tid = setTimeout(() => { t._tooltip?.$close().finally(() => { if (t._tooltip && !t._tooltip.$isOpened) { t._tooltip.remove(); t._tooltip = undefined; } }); t._tid = undefined; }, this._opts.hoverCloseTimeout); }, { once: true }); } }, { capture: true, passive: true }); } gotRender() { this.$refSVG.setAttribute("viewBox", `0 0 100 100`); this.$refSVG.setAttribute("role", "img"); this.appendChild(this.$refSVG); } renderLabel(label, percent, rawValue) { label.textContent = `${Math.round(percent)}%`; } drawArc(angleFrom, angleTo) { const r = radius; const center = [r, r]; return drawArc(center, r, this._opts.width, angleFrom, angleTo, this._opts.corner); } } customElements.define(tagName, WUPCircleElement); export function drawCircle(center, r, width) { const inR = r - width; const [x, y] = center; return [ `M${x - r} ${y}`, `A${r} ${r} 0 1 0 ${x + r} ${y}`, `A${r} ${r} 0 1 0 ${x - r} ${y}`, `M${x - inR} ${y}`, `A${inR} ${inR} 0 1 0 ${x + inR} ${y}`, `A${inR} ${inR} 0 1 0 ${x - inR} ${y}`, "Z", ].join(" "); } function pointOnArc(center, r, angle) { const radians = ((angle - 90) * Math.PI) / 180.0; return [center[0] + r * Math.cos(radians), center[1] + r * Math.sin(radians)]; } export function drawArc(center, r, width, angleFrom, angleTo, cornerR) { if (Math.abs(angleTo - angleFrom) === 360) { return drawCircle(center, r, width); } const inR = r - width; const circumference = Math.abs(angleTo - angleFrom); const maxCorner = (circumference / 360) * Math.PI * (r - width / 2); cornerR = Math.min(width / 2, width * cornerR, maxCorner); const inR2 = inR + cornerR; const outR = r - cornerR; const oStart = pointOnArc(center, outR, angleFrom); const oEnd = pointOnArc(center, outR, angleTo); const iStart = pointOnArc(center, inR2, angleFrom); const iEnd = pointOnArc(center, inR2, angleTo); const iSection = 360 * (cornerR / (2 * Math.PI * inR)); const oSection = 360 * (cornerR / (2 * Math.PI * r)); const iArcStart = pointOnArc(center, inR, angleFrom + iSection); const iArcEnd = pointOnArc(center, inR, angleTo - iSection); const oArcStart = pointOnArc(center, r, angleFrom + oSection); const oArcEnd = pointOnArc(center, r, angleTo - oSection); const arcSweep1 = circumference > 180 + 2 * oSection ? 1 : 0; const arcSweep2 = circumference > 180 + 2 * iSection ? 1 : 0; return [ `M ${oStart[0]} ${oStart[1]}`, `A ${cornerR} ${cornerR} 0 0 1 ${oArcStart[0]} ${oArcStart[1]}`, `A ${r} ${r} 0 ${arcSweep1} 1 ${oArcEnd[0]} ${oArcEnd[1]}`, `A ${cornerR} ${cornerR} 0 0 1 ${oEnd[0]} ${oEnd[1]}`, `L ${iEnd[0]} ${iEnd[1]}`, `A ${cornerR} ${cornerR} 0 0 1 ${iArcEnd[0]} ${iArcEnd[1]}`, `A ${inR} ${inR} 0 ${arcSweep2} 0 ${iArcStart[0]} ${iArcStart[1]}`, `A ${cornerR} ${cornerR} 0 0 1 ${iStart[0]} ${iStart[1]}`, "Z", ].join(" "); }