zui-progress-circle.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465
  1. <template>
  2. <view ref="eleMeter" :class="['zui-progress-circle', debug ? 'debug' : '']" :style="style">
  3. <view class="zui-progress-circle-wrapper">
  4. <image v-if="pointer" class="zui-progress-circle-pointer" mode="aspectFit" :src="pointer" :style="pointerStyle" />
  5. <image class="zui-progress-circle-ring" mode="aspectFit" :src="svgDataUrl" />
  6. </view>
  7. <view class="zui-progress-circle-slot">
  8. <slot />
  9. </view>
  10. <view v-if="debug" class="debug-frame">
  11. <view class="cross-v"></view>
  12. <view class="cross-h"></view>
  13. <view class="half-size"></view>
  14. </view>
  15. </view>
  16. </template>
  17. <script>
  18. const DIR_CCW = "counterclockwise";
  19. const DIR_CW = "clockwise";
  20. const generateId = () => {
  21. const base = 999999 * Math.random();
  22. return Math.round(base) + 100000;
  23. };
  24. export default {
  25. name: "zui-progress-circle",
  26. components: {},
  27. props: {
  28. size: {
  29. type: Number,
  30. default: 180,
  31. },
  32. /**
  33. * 当前位置
  34. *
  35. * [0, 1]
  36. */
  37. position: {
  38. type: Number,
  39. default: 0,
  40. },
  41. /**
  42. * 环形起止位置
  43. */
  44. range: {
  45. type: [Array],
  46. default: () => [0, 360],
  47. },
  48. /**
  49. * 方向
  50. */
  51. direction: {
  52. type: String,
  53. default: DIR_CW,
  54. validator(val) {
  55. return [DIR_CCW, DIR_CW].includes(val)
  56. }
  57. },
  58. /**
  59. * 环形宽度
  60. */
  61. ringWidth: {
  62. type: Number,
  63. default: 8,
  64. },
  65. /**
  66. * 端点效果
  67. *
  68. * round | butt | square
  69. */
  70. linecap: {
  71. type: String,
  72. default: "round",
  73. },
  74. /**
  75. * 纹理贴图,组件支持配置前景和背景2个贴图
  76. *
  77. * 贴图可以是一个颜色,一个渐变填充,一个 base64 编码的图片。3种贴图可以搭配使用
  78. *
  79. */
  80. texture: {
  81. type: [String, Array],
  82. default: () => ["#1BB507", "#E2D8D8"],
  83. },
  84. pointer: String,
  85. pointerOffset: Number,
  86. /**
  87. * 修复遮盖问题
  88. */
  89. fixOverlay: Boolean,
  90. debug: Boolean,
  91. },
  92. data() {
  93. return {};
  94. },
  95. computed: {
  96. preset() {
  97. const preset = {}
  98. preset.start = this.range[0];
  99. preset.end =
  100. this.range[0] > this.range[1]
  101. ? this.range[1] + 360
  102. : this.range[1];
  103. preset.ringRadius = (this.size - this.ringWidth) / 2;
  104. preset.ringCenter = this.size / 2;
  105. preset.ringPerimeter = 2 * Math.PI * preset.ringRadius;
  106. preset.ringLength =
  107. ((preset.end - preset.start) * Math.PI * preset.ringRadius) / 180;
  108. preset.ringStart = (preset.start * Math.PI * preset.ringRadius) / 180;
  109. preset.ringEnd = (preset.end * Math.PI * preset.ringRadius) / 180;
  110. if (/^(ccw|counterclockwise)$/i.test(this.direction))
  111. preset.direction = DIR_CCW;
  112. else preset.direction = DIR_CW;
  113. return preset
  114. },
  115. textureFG() {
  116. const textureSize = this.size;
  117. if (typeof this.texture === "string") {
  118. return this.parseTexture(this.texture, textureSize);
  119. } else if (
  120. Object.prototype.toString.call(this.texture) === "[object Array]"
  121. ) {
  122. if (typeof this.texture[0] === "number") {
  123. return this.parseTexture(this.texture, textureSize);
  124. } else {
  125. return this.parseTexture(this.texture[0], textureSize);
  126. }
  127. } else {
  128. // use default texture
  129. return this.parseTexture("#1BB507", textureSize);
  130. }
  131. },
  132. textureBG() {
  133. const textureSize = this.size;
  134. if (typeof this.texture === "string") {
  135. return this.parseTexture(undefined, textureSize);
  136. } else if (
  137. Object.prototype.toString.call(this.texture) === "[object Array]"
  138. ) {
  139. if (typeof this.texture[0] === "number") {
  140. return this.parseTexture(undefined, textureSize);
  141. } else {
  142. return this.parseTexture(this.texture[1], textureSize);
  143. }
  144. } else {
  145. // use default texture
  146. return this.parseTexture("#E2D8D8", textureSize);
  147. }
  148. },
  149. hasBackground() {
  150. return !!this.textureBG;
  151. },
  152. svgDataUrl() {
  153. let svg = this.createSVG();
  154. svg = `data:image/svg+xml,${encodeURIComponent(svg.replace(/ +/g, " "))}`;
  155. return svg;
  156. },
  157. style() {
  158. const style = {
  159. width: `${this.size}px`,
  160. height: `${this.size}px`,
  161. "--zui-progress-circle-ring-size": `${this.size}px`,
  162. "--zui-progress-circle-ring-width": `${this.ringWidth}px`,
  163. }
  164. return Object.keys(style)
  165. .map((key) => `${key}:${style[key]}`)
  166. .join(";");
  167. },
  168. pointerStyle() {
  169. const style = {}
  170. const { start, end, ringRadius } = this.preset
  171. let rotate = ((end - start) * this.position + start)
  172. if (this.linecap === 'round' || this.linecap === 'butt') {
  173. rotate += (this.ringWidth / 3 * 180) / (Math.PI * ringRadius)
  174. }
  175. const offset = this.pointerOffset || 0
  176. style['--zui-progress-circle-pointer-rotate'] = `translate(-${offset}px, -50%) rotate(${rotate}deg)`
  177. style['--zui-progress-circle-pointer-center'] = `${offset}px 50%`
  178. return Object.keys(style)
  179. .map((key) => `${key}:${style[key]}`)
  180. .join(";");
  181. },
  182. },
  183. methods: {
  184. parseTexture(texture, textureSize) {
  185. if (!texture) return undefined;
  186. if (/^#[0-9a-f]+/i.test(texture)) {
  187. return {
  188. type: "color",
  189. value: texture,
  190. };
  191. }
  192. const defId = generateId();
  193. if (/Gradient>/i.test(texture)) {
  194. if (/id="[^"]+"/.test(texture)) {
  195. // Replace id
  196. texture = texture.replace(/id="[^"]+"/, `id="def_${defId}"`);
  197. } else {
  198. // Create id
  199. texture = texture.replace(
  200. /<(\w+Gradient) /,
  201. `<$1 id="def_${defId}" `
  202. );
  203. }
  204. return {
  205. type: "gradient",
  206. value: `url(#def_${defId})`,
  207. def: texture,
  208. };
  209. }
  210. if (Object.prototype.toString.call(texture) === "[object Array]") {
  211. texture = this.createGradient(defId, texture.slice(1), texture[0]);
  212. return {
  213. type: "gradient",
  214. value: `url(#def_${defId})`,
  215. def: texture,
  216. };
  217. }
  218. // Create image pattern
  219. if (/<pattern /.test(texture)) {
  220. if (/id="[^"]+"/.test(texture)) {
  221. // Replace id
  222. texture = texture.replace(/id="[^"]+"/, `id="def_${defId}"`);
  223. } else {
  224. // Create id
  225. texture = texture.replace(/<pattern /, `<$1 id="def_${defId}" `);
  226. }
  227. } else {
  228. // Url or base64 code
  229. texture = this.createPattern(`def_${defId}`, texture, textureSize);
  230. }
  231. return {
  232. type: "pattern",
  233. value: `url(#def_${defId})`,
  234. def: texture,
  235. };
  236. },
  237. /**
  238. * 创建渐变填充
  239. *
  240. * @param {color[]} stops 渐变颜色
  241. * @param {number} angle 渐变填充角度
  242. */
  243. createGradient(id, stops, angle) {
  244. const step = 100 / (stops.length - 1);
  245. const stopNodes = new Array(stops.length).fill(null).map((_, idx) => {
  246. return `<stop offset="${step * idx * 100}%" stop-color="${
  247. stops[idx]
  248. }" />`;
  249. });
  250. return `<linearGradient id="def_${id}" x1="0%" y1="0%" x2="100%" y2="64.9%" gradientTransform="rotate(${angle})">
  251. ${stopNodes.join("")}
  252. </linearGradient>`;
  253. },
  254. /**
  255. * 创建文理填充
  256. *
  257. * @param {string} img base64 image. URI not surpported.
  258. */
  259. createPattern(id, img, size) {
  260. size = size || "100";
  261. return `<pattern id="${id}" patternUnits="userSpaceOnUse" width="100%" height="100%">
  262. <image xlink:href="${img}" x="0" y="0" width="100%" height="100%"/>
  263. </pattern>`;
  264. },
  265. /**
  266. * 创建圆环
  267. *
  268. * @param {string} fill 纹理ID
  269. * @param {number[]} dasharray 弧形数据
  270. */
  271. createCircle(fill, dasharray, type) {
  272. const { ringCenter, ringRadius, fixOverlay } =
  273. this.preset;
  274. const circle = {
  275. cx: ringCenter,
  276. cy: ringCenter,
  277. r: ringRadius,
  278. // 前景环稍大点, 用于遮盖底部环纹理
  279. "stroke-width": this.ringWidth,
  280. stroke: fill && fill.value,
  281. "stroke-linecap": this.linecap,
  282. "stroke-dasharray": dasharray.join(","),
  283. };
  284. if (fixOverlay) {
  285. /**
  286. * 装背景环尺寸调小,以解决前景无法完全遮盖的问题
  287. */
  288. const fw = type === "fg" ? this.ringWidth : this.ringWidth - 1;
  289. circle["stroke-width"] = fw > 8 ? fw : 8;
  290. }
  291. const props = Object.keys(circle)
  292. .map((key) => (circle[key] ? `${key}="${circle[key]}"` : ""))
  293. .join(" ");
  294. return `<circle fill="none" stroke-dashoffset="1" ${props}></circle>`;
  295. },
  296. generateDashArray(pos) {
  297. const {
  298. direction,
  299. ringStart,
  300. ringPerimeter,
  301. ringLength,
  302. } = this.preset;
  303. let ringStartPos = direction === DIR_CCW ? ringStart + (1 - pos) * ringLength : ringStart;
  304. let dash1 = 0;
  305. let dash2 = 0;
  306. let dash3 = 0;
  307. dash2 = 1 + ringStartPos;
  308. dash3 = pos * ringLength;
  309. const npos = ringStartPos + pos * ringLength;
  310. if (npos > ringPerimeter) {
  311. dash1 = npos - ringPerimeter;
  312. dash2 = ringStartPos - dash1;
  313. } else {
  314. dash1 = 0;
  315. }
  316. return [dash1, dash2, dash3, ringPerimeter];
  317. },
  318. /**
  319. * 创建 SVG 图形
  320. */
  321. createSVG() {
  322. const cirleBG = this.hasBackground
  323. ? this.createCircle(this.textureBG, this.generateDashArray(1))
  324. : "";
  325. const cirleFG = this.createCircle(
  326. this.textureFG,
  327. this.generateDashArray(this.position),
  328. "fg"
  329. );
  330. const defs = [this.textureFG.def || "", (this.textureBG && this.textureBG.def) || ""];
  331. const svg = `<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="${this.size}" height="${this.size}">
  332. <defs>
  333. ${defs.join("\n")}
  334. </defs>
  335. <g>
  336. ${cirleBG}
  337. ${cirleFG}
  338. </g>
  339. </svg>`;
  340. return svg;
  341. },
  342. },
  343. };
  344. </script>
  345. <style lang="scss" scoped>
  346. .zui-progress-circle {
  347. --zui-progress-circle-debug-color: #f00;
  348. position: relative;
  349. }
  350. .zui-progress-circle-wrapper {
  351. width: 100%;
  352. height: 100%;
  353. }
  354. .zui-progress-circle-ring {
  355. width: var(--zui-progress-circle-ring-size);
  356. height: var(--zui-progress-circle-ring-size);
  357. }
  358. .zui-progress-circle-slot {
  359. position: absolute;
  360. top: 0;
  361. left: 0;
  362. width: 100%;
  363. height: 100%;
  364. padding: var(--zui-progress-circle-ring-width);
  365. box-sizing: border-box;
  366. }
  367. .zui-progress-circle-pointer {
  368. position: absolute;
  369. z-index: 10;
  370. top: 50%;
  371. left: 50%;
  372. transform: var(--zui-progress-circle-pointer-rotate);
  373. transform-origin: var(--zui-progress-circle-pointer-center);
  374. width: 50%;
  375. height: 50%;
  376. transition: transform 0.1s linear;
  377. }
  378. // Debug item style
  379. .debug-frame ,
  380. .cross-v,
  381. .cross-h,
  382. .half-size {
  383. position: absolute;
  384. }
  385. .debug-frame {
  386. position: absolute;
  387. z-index: 99;
  388. top: 0;
  389. left: 0;
  390. width: 100%;
  391. height: 100%;
  392. border: 1px solid var(--zui-progress-circle-debug-color);
  393. border-radius: 50%;
  394. }
  395. .cross-h, .cross-v,
  396. .half-size {
  397. top: 50%;
  398. left: 50%;
  399. transform: translate(-50%, -50%);
  400. background-color: var(--zui-progress-circle-debug-color);
  401. mix-blend-mode: difference;
  402. }
  403. .cross-v {
  404. width: 1px;
  405. height: 100%;
  406. }
  407. .cross-h {
  408. width: 100%;
  409. height: 1px;
  410. }
  411. .half-size {
  412. width: 50%;
  413. height: 50%;
  414. border: 1px solid var(--zui-progress-circle-debug-color);
  415. background-color: transparent;
  416. border-radius: 50%;
  417. }
  418. // Debug style END
  419. </style>