1/* 2 * Copyright (C) 2022 Huawei Device Co., Ltd. 3 * Licensed under the Apache License, Version 2.0 (the "License"); 4 * you may not use this file except in compliance with the License. 5 * You may obtain a copy of the License at 6 * 7 * http://www.apache.org/licenses/LICENSE-2.0 8 * 9 * Unless required by applicable law or agreed to in writing, software 10 * distributed under the License is distributed on an "AS IS" BASIS, 11 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 * See the License for the specific language governing permissions and 13 * limitations under the License. 14 */ 15 16import { resizeCanvas } from '../helper'; 17import { BaseElement, element } from '../../BaseElement'; 18import { LitChartPieConfig } from './LitChartPieConfig'; 19import { isPointIsCircle, pieChartColors, randomRgbColor } from './LitChartPieData'; 20import { Utils } from '../../../trace/component/trace/base/Utils'; 21 22interface Rectangle { 23 x: number; 24 y: number; 25 w: number; 26 h: number; 27} 28 29class Sector { 30 id?: unknown; 31 obj?: unknown; 32 key: unknown; 33 value: unknown; 34 startAngle?: number; 35 endAngle?: number; 36 startDegree?: number; 37 endDegree?: number; 38 color?: string; 39 percent?: number; 40 hover?: boolean; 41 ease?: { 42 initVal?: number; 43 step?: number; 44 process?: boolean; 45 }; 46} 47 48const initHtmlStyle = ` 49 <style> 50 :host { 51 display: flex; 52 flex-direction: column; 53 overflow: hidden; 54 width: 100%; 55 height: 100%; 56 } 57 .shape.active { 58 animation: color 3.75 both; 59 } 60 @keyframes color { 61 0% { background-color: white; } 62 100% { background-color: black; } 63 } 64 #tip{ 65 background-color: #f5f5f4; 66 border: 1px solid #fff; 67 border-radius: 5px; 68 color: #333322; 69 font-size: 8pt; 70 position: absolute; 71 display: none; 72 top: 0; 73 left: 0; 74 z-index: 99; 75 pointer-events: none; 76 user-select: none; 77 padding: 5px 10px; 78 box-shadow: 0 0 10px #22ffffff; 79 } 80 #root{ 81 position:relative; 82 } 83 .bg_nodata{ 84 background-repeat:no-repeat; 85 background-position:center; 86 background-image: url("img/pie_chart_no_data.png"); 87 } 88 .bg_hasdata{ 89 background-repeat:no-repeat; 90 background-position:center; 91 } 92 93 #labels{ 94 display: grid; 95 grid-template-columns: auto auto auto auto auto; 96 /*justify-content: center;*/ 97 /*align-items: center;*/ 98 width: 100%; 99 height: 25%; 100 box-sizing: border-box; 101 position: absolute; 102 bottom: 0px; 103 left: 0; 104 /*margin: 0px 10px;*/ 105 padding-left: 10px; 106 padding-right: 10px; 107 pointer-events: none ; 108 } 109 .name{ 110 flex: 1; 111 font-size: 9pt; 112 overflow: hidden; 113 white-space: nowrap; 114 text-overflow: ellipsis; 115 /*color: #666;*/ 116 color: var(--dark-color1,#252525); 117 pointer-events: painted; 118 } 119 .label{ 120 display: flex; 121 align-items: center; 122 max-lines: 1; 123 white-space: nowrap; 124 overflow: hidden; 125 padding-right: 5px; 126 } 127 .tag{ 128 display: flex; 129 align-items: center; 130 justify-content: center; 131 width: 10px; 132 height: 10px; 133 border-radius: 5px; 134 margin-right: 5px; 135 } 136 </style> 137 `; 138 139@element('lit-chart-pie') 140export class LitChartPie extends BaseElement { 141 private eleShape: Element | null | undefined; 142 private pieTipEL: HTMLDivElement | null | undefined; 143 private labelsEL: HTMLDivElement | null | undefined; 144 canvas: HTMLCanvasElement | undefined | null; 145 ctx: CanvasRenderingContext2D | undefined | null; 146 litChartPieConfig: LitChartPieConfig | null | undefined; 147 centerX: number | null | undefined; 148 centerY: number | null | undefined; 149 data: Sector[] = []; 150 radius: number | undefined; 151 private textRects: Rectangle[] = []; 152 153 set config(litChartPieCfg: LitChartPieConfig | null | undefined) { 154 if (!litChartPieCfg) { 155 return; 156 } 157 this.litChartPieConfig = litChartPieCfg; 158 this.measure(); 159 this.render(); 160 (this.shadowRoot!.querySelector('#root') as HTMLDivElement).className = 161 this.data.length > 0 ? 'bg_hasdata' : 'bg_nodata'; 162 } 163 164 set dataSource(litChartPieArr: unknown[]) { 165 if (this.litChartPieConfig) { 166 this.litChartPieConfig.data = litChartPieArr; 167 this.measure(); 168 this.render(); 169 } 170 } 171 172 showHover(): void { 173 let hasHover = false; 174 this.data.forEach((it) => { 175 // @ts-ignore 176 it.hover = it.obj.isHover; 177 if (it.hover) { 178 hasHover = true; 179 } 180 this.updateHoverItemStatus(it); 181 if (it.hover) { 182 if (this.centerX && this.centerX > 0 && this.centerY && this.centerY > 0) { 183 this.showTip( 184 this.centerX - 40 || 0, 185 this.centerY || 0, 186 this.litChartPieConfig!.tip ? this.litChartPieConfig!.tip(it) : `${it.key}: ${it.value}` 187 ); 188 } 189 } 190 }); 191 if (!hasHover) { 192 this.hideTip(); 193 } 194 this.render(); 195 } 196 197 measureInitialize(): void { 198 this.data = []; 199 this.radius = (Math.min(this.clientHeight, this.clientWidth) * 0.65) / 2 - 10; 200 this.labelsEL!.textContent = ''; 201 } 202 203 measure(): void { 204 if (!this.litChartPieConfig) { 205 return; 206 } 207 this.measureInitialize(); 208 let pieCfg = this.litChartPieConfig!; 209 let startAngle = 0; 210 let startDegree = 0; 211 let full = Math.PI / 180; //每度 212 let fullDegree = 0; //每度 213 let sum = this.litChartPieConfig.data.reduce( 214 // @ts-ignore 215 (previousValue, currentValue) => currentValue[pieCfg.angleField] + previousValue, 216 0 217 ); 218 let labelArray: string[] = []; 219 sum && this.litChartPieConfig.data.forEach((pieItem, index) => { 220 let item: Sector = { 221 id: `id-${Utils.uuid()}`, 222 color: this.litChartPieConfig!.label.color 223 ? // @ts-ignore 224 this.litChartPieConfig!.label.color(pieItem) 225 : pieChartColors[index % pieChartColors.length], 226 obj: pieItem, // @ts-ignore 227 key: pieItem[pieCfg.colorField], // @ts-ignore 228 value: pieItem[pieCfg.angleField], 229 startAngle: startAngle, // @ts-ignore 230 endAngle: startAngle + full * ((pieItem[pieCfg.angleField] / sum) * 360), 231 startDegree: startDegree, // @ts-ignore 232 endDegree: startDegree + fullDegree + (pieItem[pieCfg.angleField] / sum) * 360, 233 ease: { 234 initVal: 0, // @ts-ignore 235 step: (startAngle + full * ((pieItem[pieCfg.angleField] / sum) * 360)) / startDegree, 236 process: true, 237 }, 238 }; 239 this.data.push(item); // @ts-ignore 240 startAngle += full * ((pieItem[pieCfg.angleField] / sum) * 360); // @ts-ignore 241 startDegree += fullDegree + (pieItem[pieCfg.angleField] / sum) * 360; // @ts-ignore 242 let colorFieldValue = item.obj[pieCfg.colorField]; 243 if (this.config?.colorFieldTransferHandler) { 244 colorFieldValue = this.config.colorFieldTransferHandler(colorFieldValue); 245 } 246 labelArray.push(`<label class="label"> 247 <div style="display: flex;flex-direction: row;margin-left: 5px;align-items: center;overflow: hidden;text-overflow: ellipsis" 248 id="${item.id}"> 249 <div class="tag" style="background-color: ${item.color}"></div> 250 <span class="name">${colorFieldValue}</span> 251 </div> 252 </label>`); 253 }); 254 this.labelsEL!.innerHTML = labelArray.join(''); 255 } 256 257 get config(): LitChartPieConfig | null | undefined { 258 return this.litChartPieConfig; 259 } 260 261 addCanvasOnmousemoveEvent(): void { 262 this.canvas!.onmousemove = (ev): void => { 263 let rect = this.getBoundingClientRect(); 264 let x = ev.pageX - rect.left - this.centerX!; 265 let y = ev.pageY - rect.top - this.centerY!; 266 if (isPointIsCircle(0, 0, x, y, this.radius!)) { 267 let degree = this.computeDegree(x, y); 268 this.data.forEach((it) => { 269 it.hover = degree >= it.startDegree! && degree <= it.endDegree!; 270 this.updateHoverItemStatus(it); // @ts-ignore 271 it.obj.isHover = it.hover; 272 if (it.hover && this.litChartPieConfig) { 273 this.litChartPieConfig.hoverHandler?.(it.obj); 274 this.showTip( 275 ev.pageX - rect.left > this.centerX! ? ev.pageX - rect.left - 165 : ev.pageX - rect.left + 10, 276 ev.pageY - this.offsetTop > this.centerY! ? ev.pageY - this.offsetTop - 50 : ev.pageY + (this.offsetTop - rect.top) - this.offsetTop + 20, 277 this.litChartPieConfig.tip ? this.litChartPieConfig!.tip(it) : `${it.key}: ${it.value}` 278 ); 279 } 280 }); 281 } else { 282 this.hideTip(); 283 this.data.forEach((it) => { 284 it.hover = false; // @ts-ignore 285 it.obj.isHover = false; 286 this.updateHoverItemStatus(it); 287 }); 288 this.litChartPieConfig?.hoverHandler?.(undefined); 289 } 290 this.render(); 291 }; 292 } 293 connectedCallback(): void { 294 super.connectedCallback(); 295 this.eleShape = this.shadowRoot!.querySelector<Element>('#shape'); 296 this.pieTipEL = this.shadowRoot!.querySelector<HTMLDivElement>('#tip'); 297 this.labelsEL = this.shadowRoot!.querySelector<HTMLDivElement>('#labels'); 298 this.canvas = this.shadowRoot!.querySelector<HTMLCanvasElement>('#canvas'); 299 this.ctx = this.canvas!.getContext('2d', { alpha: true }); 300 resizeCanvas(this.canvas!); 301 this.radius = (Math.min(this.clientHeight, this.clientWidth) * 0.65) / 2 - 10; 302 this.centerX = this.clientWidth / 2; 303 this.centerY = this.clientHeight / 2 - 40; 304 this.ctx?.translate(this.centerX, this.centerY); 305 this.canvas!.onmouseout = (e): void => { 306 this.hideTip(); 307 this.data.forEach((it) => { 308 it.hover = false; 309 this.updateHoverItemStatus(it); 310 }); 311 this.render(); 312 }; 313 //增加点击事件 314 this.canvas!.onclick = (ev): void => { 315 let rect = this.getBoundingClientRect(); 316 let x = ev.pageX - rect.left - this.centerX!; 317 let y = ev.pageY - rect.top - this.centerY!; 318 if (isPointIsCircle(0, 0, x, y, this.radius!)) { 319 let degree = this.computeDegree(x, y); 320 this.data.forEach((it) => { 321 if (degree >= it.startDegree! && degree <= it.endDegree!) { 322 // @ts-ignore 323 this.config?.angleClick?.(it.obj); 324 } 325 }); 326 } 327 }; 328 this.addCanvasOnmousemoveEvent(); 329 this.render(); 330 } 331 332 updateHoverItemStatus(item: unknown): void { 333 // @ts-ignore 334 let label = this.shadowRoot!.querySelector(`#${item.id}`); 335 if (label) { 336 // @ts-ignore 337 (label as HTMLLabelElement).style.boxShadow = item.hover ? '0 0 5px #22ffffff' : ''; 338 } 339 } 340 341 computeDegree(x: number, y: number): number { 342 let degree = (360 * Math.atan(y / x)) / (2 * Math.PI); 343 if (x >= 0 && y >= 0) { 344 degree = degree; 345 } else if (x < 0 && y >= 0) { 346 degree = 180 + degree; 347 } else if (x < 0 && y < 0) { 348 degree = 180 + degree; 349 } else { 350 degree = 270 + (90 + degree); 351 } 352 return degree; 353 } 354 355 initElements(): void { 356 new ResizeObserver((entries, observer) => { 357 entries.forEach((it) => { 358 resizeCanvas(this.canvas!); 359 this.centerX = this.clientWidth / 2; 360 this.centerY = this.clientHeight / 2 - 40; 361 this.ctx?.translate(this.centerX, this.centerY); 362 this.measure(); 363 this.render(); 364 }); 365 }).observe(this); 366 } 367 368 handleData(): void { 369 this.textRects = []; 370 if (this.litChartPieConfig!.showChartLine) { 371 this.data.forEach((dataItem) => { 372 let text = `${dataItem.value}`; 373 let metrics = this.ctx!.measureText(text); 374 let textWidth = metrics.width; 375 let textHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; 376 this.ctx!.beginPath(); 377 this.ctx!.strokeStyle = dataItem.color!; 378 this.ctx!.fillStyle = '#595959'; 379 let deg = dataItem.startDegree! + (dataItem.endDegree! - dataItem.startDegree!) / 2; 380 let dep = 25; 381 let x1 = 0 + this.radius! * Math.cos((deg * Math.PI) / 180); 382 let y1 = 0 + this.radius! * Math.sin((deg * Math.PI) / 180); 383 let x2 = 0 + (this.radius! + 13) * Math.cos((deg * Math.PI) / 180); 384 let y2 = 0 + (this.radius! + 13) * Math.sin((deg * Math.PI) / 180); 385 let x3 = 0 + (this.radius! + dep) * Math.cos((deg * Math.PI) / 180); 386 let y3 = 0 + (this.radius! + dep) * Math.sin((deg * Math.PI) / 180); 387 this.ctx!.moveTo(x1, y1); 388 this.ctx!.lineTo(x2, y2); 389 this.ctx!.stroke(); 390 let rect = this.correctRect({ 391 x: x3 - textWidth / 2, 392 y: y3 + textHeight / 2, 393 w: textWidth, 394 h: textHeight, 395 }); 396 this.ctx?.fillText(text, rect.x, rect.y); 397 this.ctx?.closePath(); 398 }); 399 } 400 } 401 402 render(ease: boolean = true): void { 403 if (!this.canvas || !this.litChartPieConfig) { 404 return; 405 } 406 if (this.radius! <= 0) { 407 return; 408 } 409 this.ctx?.clearRect(0 - this.centerX!, 0 - this.centerY!, this.clientWidth, this.clientHeight); 410 this.data.forEach((it) => { 411 this.ctx!.beginPath(); 412 this.ctx!.fillStyle = it.color as string; 413 this.ctx!.strokeStyle = this.data.length > 1 ? '#fff' : (it.color as string); 414 this.ctx?.moveTo(0, 0); 415 if (it.hover) { 416 this.ctx!.lineWidth = 1; 417 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 418 } else { 419 this.ctx!.lineWidth = 1; 420 if (ease) { 421 if (it.ease!.initVal! < it.endAngle! - it.startAngle!) { 422 it.ease!.process = true; 423 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.startAngle! + it.ease!.initVal!, false); 424 it.ease!.initVal! += it.ease!.step!; 425 } else { 426 it.ease!.process = false; 427 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 428 } 429 } else { 430 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 431 } 432 } 433 this.ctx?.lineTo(0, 0); 434 this.ctx?.fill(); 435 this.ctx!.stroke(); 436 this.ctx?.closePath(); 437 }); 438 this.setData(ease); 439 } 440 441 setData(ease: boolean): void { 442 this.data 443 .filter((it) => it.hover) 444 .forEach((it) => { 445 this.ctx!.beginPath(); 446 this.ctx!.fillStyle = it.color as string; 447 this.ctx!.lineWidth = 1; 448 this.ctx?.moveTo(0, 0); 449 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 450 this.ctx?.lineTo(0, 0); 451 this.ctx!.strokeStyle = this.data.length > 1 ? '#000' : (it.color as string); 452 this.ctx!.stroke(); 453 this.ctx?.closePath(); 454 }); 455 this.handleData(); 456 if (this.data.filter((it) => it.ease!.process).length > 0) { 457 requestAnimationFrame(() => this.render(ease)); 458 } 459 } 460 461 correctRect(pieRect: Rectangle): Rectangle { 462 if (this.textRects.length === 0) { 463 this.textRects.push(pieRect); 464 return pieRect; 465 } else { 466 let rectangles = this.textRects.filter((it) => this.intersect(it, pieRect).cross); 467 if (rectangles.length === 0) { 468 this.textRects.push(pieRect); 469 return pieRect; 470 } else { 471 let it = rectangles[0]; 472 let inter = this.intersect(it, pieRect); 473 if (inter.direction === 'Right') { 474 pieRect.x += inter.crossW; 475 } else if (inter.direction === 'Bottom') { 476 pieRect.y += inter.crossH; 477 } else if (inter.direction === 'Left') { 478 pieRect.x -= inter.crossW; 479 } else if (inter.direction === 'Top') { 480 pieRect.y -= inter.crossH; 481 } else if (inter.direction === 'Right-Top') { 482 pieRect.y -= inter.crossH; 483 } else if (inter.direction === 'Right-Bottom') { 484 pieRect.y += inter.crossH; 485 } else if (inter.direction === 'Left-Top') { 486 pieRect.y -= inter.crossH; 487 } else if (inter.direction === 'Left-Bottom') { 488 pieRect.y += inter.crossH; 489 } 490 this.textRects.push(pieRect); 491 return pieRect; 492 } 493 } 494 } 495 496 intersect( 497 r1: Rectangle, 498 rect: Rectangle 499 ): { 500 cross: boolean; 501 direction: string; 502 crossW: number; 503 crossH: number; 504 } { 505 let cross: boolean; 506 let direction: string = ''; 507 let crossW: number; 508 let crossH: number; 509 let maxX = r1.x + r1.w > rect.x + rect.w ? r1.x + r1.w : rect.x + rect.w; 510 let maxY = r1.y + r1.h > rect.y + rect.h ? r1.y + r1.h : rect.y + rect.h; 511 let minX = r1.x < rect.x ? r1.x : rect.x; 512 let minY = r1.y < rect.y ? r1.y : rect.y; 513 cross = maxX - minX < rect.w + r1.w && maxY - minY < r1.h + rect.h; 514 crossW = Math.abs(maxX - minX - (rect.w + r1.w)); 515 crossH = Math.abs(maxY - minY - (rect.y + r1.y)); 516 if (rect.x > r1.x) { 517 if (rect.y > r1.y) { 518 direction = 'Right-Bottom'; 519 } else if (rect.y === r1.y) { 520 direction = 'Right'; 521 } else { 522 direction = 'Right-Top'; 523 } 524 } else if (rect.x < r1.x) { 525 if (rect.y > r1.y) { 526 direction = 'Left-Bottom'; 527 } else if (rect.y === r1.y) { 528 direction = 'Left'; 529 } else { 530 direction = 'Left-Top'; 531 } 532 } else { 533 direction = this.rectSuperposition(rect, r1); 534 } 535 return { 536 cross, 537 direction, 538 crossW, 539 crossH, 540 }; 541 } 542 543 rectSuperposition(rect: Rectangle, r1: Rectangle): string { 544 if (rect.y > r1.y) { 545 return 'Bottom'; 546 } else if (rect.y === r1.y) { 547 return 'Right'; //superposition default right 548 } else { 549 return 'Top'; 550 } 551 } 552 553 showTip(x: number, y: number, msg: string): void { 554 this.pieTipEL!.style.display = 'flex'; 555 this.pieTipEL!.style.top = `${y}px`; 556 this.pieTipEL!.style.left = `${x}px`; 557 this.pieTipEL!.innerHTML = msg; 558 } 559 560 hideTip(): void { 561 this.pieTipEL!.style.display = 'none'; 562 } 563 564 initHtml(): string { 565 return ` 566 ${initHtmlStyle} 567 <div id="root"> 568 <div id="shape" class="shape active"></div> 569 <canvas id="canvas" style="top: 0;left: 0;z-index: 21"></canvas> 570 <div id="tip"></div> 571 <div id="labels"></div> 572 </div>`; 573 } 574} 575