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.shadowRoot!.querySelector('#root') as HTMLDivElement).className = 159 litChartPieCfg && litChartPieCfg.data.length > 0 ? 'bg_hasdata' : 'bg_nodata'; 160 this.measure(); 161 this.render(); 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 this.showTip( 183 this.centerX || 0, 184 this.centerY || 0, 185 this.litChartPieConfig!.tip ? this.litChartPieConfig!.tip(it) : `${it.key}: ${it.value}` 186 ); 187 } 188 }); 189 if (!hasHover) { 190 this.hideTip(); 191 } 192 this.render(); 193 } 194 195 measureInitialize(): void { 196 this.data = []; 197 this.radius = (Math.min(this.clientHeight, this.clientWidth) * 0.65) / 2 - 10; 198 this.labelsEL!.textContent = ''; 199 } 200 201 measure(): void { 202 if (!this.litChartPieConfig) { 203 return; 204 } 205 this.measureInitialize(); 206 let pieCfg = this.litChartPieConfig!; 207 let startAngle = 0; 208 let startDegree = 0; 209 let full = Math.PI / 180; //每度 210 let fullDegree = 0; //每度 211 let sum = this.litChartPieConfig.data.reduce( 212 // @ts-ignore 213 (previousValue, currentValue) => currentValue[pieCfg.angleField] + previousValue, 214 0 215 ); 216 let labelArray: string[] = []; 217 this.litChartPieConfig.data.forEach((pieItem, index) => { 218 let item: Sector = { 219 id: `id-${Utils.uuid()}`, 220 color: this.litChartPieConfig!.label.color 221 ? // @ts-ignore 222 this.litChartPieConfig!.label.color(pieItem) 223 : pieChartColors[index % pieChartColors.length], 224 obj: pieItem, // @ts-ignore 225 key: pieItem[pieCfg.colorField], // @ts-ignore 226 value: pieItem[pieCfg.angleField], 227 startAngle: startAngle, // @ts-ignore 228 endAngle: startAngle + full * ((pieItem[pieCfg.angleField] / sum) * 360), 229 startDegree: startDegree, // @ts-ignore 230 endDegree: startDegree + fullDegree + (pieItem[pieCfg.angleField] / sum) * 360, 231 ease: { 232 initVal: 0, // @ts-ignore 233 step: (startAngle + full * ((pieItem[pieCfg.angleField] / sum) * 360)) / startDegree, 234 process: true, 235 }, 236 }; 237 this.data.push(item); // @ts-ignore 238 startAngle += full * ((pieItem[pieCfg.angleField] / sum) * 360); // @ts-ignore 239 startDegree += fullDegree + (pieItem[pieCfg.angleField] / sum) * 360; // @ts-ignore 240 let colorFieldValue = item.obj[pieCfg.colorField]; 241 if (this.config?.colorFieldTransferHandler) { 242 colorFieldValue = this.config.colorFieldTransferHandler(colorFieldValue); 243 } 244 labelArray.push(`<label class="label"> 245 <div style="display: flex;flex-direction: row;margin-left: 5px;align-items: center;overflow: hidden;text-overflow: ellipsis" 246 id="${item.id}"> 247 <div class="tag" style="background-color: ${item.color}"></div> 248 <span class="name">${colorFieldValue}</span> 249 </div> 250 </label>`); 251 }); 252 this.labelsEL!.innerHTML = labelArray.join(''); 253 } 254 255 get config(): LitChartPieConfig | null | undefined { 256 return this.litChartPieConfig; 257 } 258 259 addCanvasOnmousemoveEvent(): void { 260 this.canvas!.onmousemove = (ev): void => { 261 let rect = this.getBoundingClientRect(); 262 let x = ev.pageX - rect.left - this.centerX!; 263 let y = ev.pageY - rect.top - this.centerY!; 264 if (isPointIsCircle(0, 0, x, y, this.radius!)) { 265 let degree = this.computeDegree(x, y); 266 this.data.forEach((it) => { 267 it.hover = degree >= it.startDegree! && degree <= it.endDegree!; 268 this.updateHoverItemStatus(it); // @ts-ignore 269 it.obj.isHover = it.hover; 270 if (it.hover && this.litChartPieConfig) { 271 this.litChartPieConfig.hoverHandler?.(it.obj); 272 this.showTip( 273 ev.pageX - rect.left + 10, 274 ev.pageY - this.offsetTop - 10, 275 this.litChartPieConfig.tip ? this.litChartPieConfig!.tip(it) : `${it.key}: ${it.value}` 276 ); 277 } 278 }); 279 } else { 280 this.hideTip(); 281 this.data.forEach((it) => { 282 it.hover = false; // @ts-ignore 283 it.obj.isHover = false; 284 this.updateHoverItemStatus(it); 285 }); 286 this.litChartPieConfig?.hoverHandler?.(undefined); 287 } 288 this.render(); 289 }; 290 } 291 connectedCallback(): void { 292 super.connectedCallback(); 293 this.eleShape = this.shadowRoot!.querySelector<Element>('#shape'); 294 this.pieTipEL = this.shadowRoot!.querySelector<HTMLDivElement>('#tip'); 295 this.labelsEL = this.shadowRoot!.querySelector<HTMLDivElement>('#labels'); 296 this.canvas = this.shadowRoot!.querySelector<HTMLCanvasElement>('#canvas'); 297 this.ctx = this.canvas!.getContext('2d', { alpha: true }); 298 resizeCanvas(this.canvas!); 299 this.radius = (Math.min(this.clientHeight, this.clientWidth) * 0.65) / 2 - 10; 300 this.centerX = this.clientWidth / 2; 301 this.centerY = this.clientHeight / 2 - 40; 302 this.ctx?.translate(this.centerX, this.centerY); 303 this.canvas!.onmouseout = (e): void => { 304 this.hideTip(); 305 this.data.forEach((it) => { 306 it.hover = false; 307 this.updateHoverItemStatus(it); 308 }); 309 this.render(); 310 }; 311 //增加点击事件 312 this.canvas!.onclick = (ev): void => { 313 let rect = this.getBoundingClientRect(); 314 let x = ev.pageX - rect.left - this.centerX!; 315 let y = ev.pageY - rect.top - this.centerY!; 316 if (isPointIsCircle(0, 0, x, y, this.radius!)) { 317 let degree = this.computeDegree(x, y); 318 this.data.forEach((it) => { 319 if (degree >= it.startDegree! && degree <= it.endDegree!) { 320 // @ts-ignore 321 this.config?.angleClick?.(it.obj); 322 } 323 }); 324 } 325 }; 326 this.addCanvasOnmousemoveEvent(); 327 this.render(); 328 } 329 330 updateHoverItemStatus(item: unknown): void { 331 // @ts-ignore 332 let label = this.shadowRoot!.querySelector(`#${item.id}`); 333 if (label) { 334 // @ts-ignore 335 (label as HTMLLabelElement).style.boxShadow = item.hover ? '0 0 5px #22ffffff' : ''; 336 } 337 } 338 339 computeDegree(x: number, y: number): number { 340 let degree = (360 * Math.atan(y / x)) / (2 * Math.PI); 341 if (x >= 0 && y >= 0) { 342 degree = degree; 343 } else if (x < 0 && y >= 0) { 344 degree = 180 + degree; 345 } else if (x < 0 && y < 0) { 346 degree = 180 + degree; 347 } else { 348 degree = 270 + (90 + degree); 349 } 350 return degree; 351 } 352 353 initElements(): void { 354 new ResizeObserver((entries, observer) => { 355 entries.forEach((it) => { 356 resizeCanvas(this.canvas!); 357 this.centerX = this.clientWidth / 2; 358 this.centerY = this.clientHeight / 2 - 40; 359 this.ctx?.translate(this.centerX, this.centerY); 360 this.measure(); 361 this.render(); 362 }); 363 }).observe(this); 364 } 365 366 handleData(): void { 367 this.textRects = []; 368 if (this.litChartPieConfig!.showChartLine) { 369 this.data.forEach((dataItem) => { 370 let text = `${dataItem.value}`; 371 let metrics = this.ctx!.measureText(text); 372 let textWidth = metrics.width; 373 let textHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; 374 this.ctx!.beginPath(); 375 this.ctx!.strokeStyle = dataItem.color!; 376 this.ctx!.fillStyle = '#595959'; 377 let deg = dataItem.startDegree! + (dataItem.endDegree! - dataItem.startDegree!) / 2; 378 let dep = 25; 379 let x1 = 0 + this.radius! * Math.cos((deg * Math.PI) / 180); 380 let y1 = 0 + this.radius! * Math.sin((deg * Math.PI) / 180); 381 let x2 = 0 + (this.radius! + 13) * Math.cos((deg * Math.PI) / 180); 382 let y2 = 0 + (this.radius! + 13) * Math.sin((deg * Math.PI) / 180); 383 let x3 = 0 + (this.radius! + dep) * Math.cos((deg * Math.PI) / 180); 384 let y3 = 0 + (this.radius! + dep) * Math.sin((deg * Math.PI) / 180); 385 this.ctx!.moveTo(x1, y1); 386 this.ctx!.lineTo(x2, y2); 387 this.ctx!.stroke(); 388 let rect = this.correctRect({ 389 x: x3 - textWidth / 2, 390 y: y3 + textHeight / 2, 391 w: textWidth, 392 h: textHeight, 393 }); 394 this.ctx?.fillText(text, rect.x, rect.y); 395 this.ctx?.closePath(); 396 }); 397 } 398 } 399 400 render(ease: boolean = true): void { 401 if (!this.canvas || !this.litChartPieConfig) { 402 return; 403 } 404 if (this.radius! <= 0) { 405 return; 406 } 407 this.ctx?.clearRect(0 - this.centerX!, 0 - this.centerY!, this.clientWidth, this.clientHeight); 408 this.data.forEach((it) => { 409 this.ctx!.beginPath(); 410 this.ctx!.fillStyle = it.color as string; 411 this.ctx!.strokeStyle = this.data.length > 1 ? '#fff' : (it.color as string); 412 this.ctx?.moveTo(0, 0); 413 if (it.hover) { 414 this.ctx!.lineWidth = 1; 415 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 416 } else { 417 this.ctx!.lineWidth = 1; 418 if (ease) { 419 if (it.ease!.initVal! < it.endAngle! - it.startAngle!) { 420 it.ease!.process = true; 421 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.startAngle! + it.ease!.initVal!, false); 422 it.ease!.initVal! += it.ease!.step!; 423 } else { 424 it.ease!.process = false; 425 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 426 } 427 } else { 428 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 429 } 430 } 431 this.ctx?.lineTo(0, 0); 432 this.ctx?.fill(); 433 this.ctx!.stroke(); 434 this.ctx?.closePath(); 435 }); 436 this.setData(ease); 437 } 438 439 setData(ease: boolean): void { 440 this.data 441 .filter((it) => it.hover) 442 .forEach((it) => { 443 this.ctx!.beginPath(); 444 this.ctx!.fillStyle = it.color as string; 445 this.ctx!.lineWidth = 1; 446 this.ctx?.moveTo(0, 0); 447 this.ctx!.arc(0, 0, this.radius!, it.startAngle!, it.endAngle!, false); 448 this.ctx?.lineTo(0, 0); 449 this.ctx!.strokeStyle = this.data.length > 1 ? '#000' : (it.color as string); 450 this.ctx!.stroke(); 451 this.ctx?.closePath(); 452 }); 453 this.handleData(); 454 if (this.data.filter((it) => it.ease!.process).length > 0) { 455 requestAnimationFrame(() => this.render(ease)); 456 } 457 } 458 459 correctRect(pieRect: Rectangle): Rectangle { 460 if (this.textRects.length === 0) { 461 this.textRects.push(pieRect); 462 return pieRect; 463 } else { 464 let rectangles = this.textRects.filter((it) => this.intersect(it, pieRect).cross); 465 if (rectangles.length === 0) { 466 this.textRects.push(pieRect); 467 return pieRect; 468 } else { 469 let it = rectangles[0]; 470 let inter = this.intersect(it, pieRect); 471 if (inter.direction === 'Right') { 472 pieRect.x += inter.crossW; 473 } else if (inter.direction === 'Bottom') { 474 pieRect.y += inter.crossH; 475 } else if (inter.direction === 'Left') { 476 pieRect.x -= inter.crossW; 477 } else if (inter.direction === 'Top') { 478 pieRect.y -= inter.crossH; 479 } else if (inter.direction === 'Right-Top') { 480 pieRect.y -= inter.crossH; 481 } else if (inter.direction === 'Right-Bottom') { 482 pieRect.y += inter.crossH; 483 } else if (inter.direction === 'Left-Top') { 484 pieRect.y -= inter.crossH; 485 } else if (inter.direction === 'Left-Bottom') { 486 pieRect.y += inter.crossH; 487 } 488 this.textRects.push(pieRect); 489 return pieRect; 490 } 491 } 492 } 493 494 intersect( 495 r1: Rectangle, 496 rect: Rectangle 497 ): { 498 cross: boolean; 499 direction: string; 500 crossW: number; 501 crossH: number; 502 } { 503 let cross: boolean; 504 let direction: string = ''; 505 let crossW: number; 506 let crossH: number; 507 let maxX = r1.x + r1.w > rect.x + rect.w ? r1.x + r1.w : rect.x + rect.w; 508 let maxY = r1.y + r1.h > rect.y + rect.h ? r1.y + r1.h : rect.y + rect.h; 509 let minX = r1.x < rect.x ? r1.x : rect.x; 510 let minY = r1.y < rect.y ? r1.y : rect.y; 511 cross = maxX - minX < rect.w + r1.w && maxY - minY < r1.h + rect.h; 512 crossW = Math.abs(maxX - minX - (rect.w + r1.w)); 513 crossH = Math.abs(maxY - minY - (rect.y + r1.y)); 514 if (rect.x > r1.x) { 515 if (rect.y > r1.y) { 516 direction = 'Right-Bottom'; 517 } else if (rect.y === r1.y) { 518 direction = 'Right'; 519 } else { 520 direction = 'Right-Top'; 521 } 522 } else if (rect.x < r1.x) { 523 if (rect.y > r1.y) { 524 direction = 'Left-Bottom'; 525 } else if (rect.y === r1.y) { 526 direction = 'Left'; 527 } else { 528 direction = 'Left-Top'; 529 } 530 } else { 531 direction = this.rectSuperposition(rect, r1); 532 } 533 return { 534 cross, 535 direction, 536 crossW, 537 crossH, 538 }; 539 } 540 541 rectSuperposition(rect: Rectangle, r1: Rectangle): string { 542 if (rect.y > r1.y) { 543 return 'Bottom'; 544 } else if (rect.y === r1.y) { 545 return 'Right'; //superposition default right 546 } else { 547 return 'Top'; 548 } 549 } 550 551 showTip(x: number, y: number, msg: string): void { 552 this.pieTipEL!.style.display = 'flex'; 553 this.pieTipEL!.style.top = `${y}px`; 554 this.pieTipEL!.style.left = `${x}px`; 555 this.pieTipEL!.innerHTML = msg; 556 } 557 558 hideTip(): void { 559 this.pieTipEL!.style.display = 'none'; 560 } 561 562 initHtml(): string { 563 return ` 564 ${initHtmlStyle} 565 <div id="root"> 566 <div id="shape" class="shape active"></div> 567 <canvas id="canvas" style="top: 0;left: 0;z-index: 21"></canvas> 568 <div id="tip"></div> 569 <div id="labels"></div> 570 </div>`; 571 } 572} 573