web-ui-pack
Version:
Web package with UI elements
371 lines (370 loc) • 14.4 kB
JavaScript
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(" ");
}