%PDF- %PDF-
Direktori : /var/www/projetos/suporte.iigd.com.br/src/Dashboard/ |
Current File : /var/www/projetos/suporte.iigd.com.br/src/Dashboard/Widget.php |
<?php /** * --------------------------------------------------------------------- * * GLPI - Gestionnaire Libre de Parc Informatique * * http://glpi-project.org * * @copyright 2015-2024 Teclib' and contributors. * @copyright 2003-2014 by the INDEPNET Development Team. * @licence https://www.gnu.org/licenses/gpl-3.0.html * * --------------------------------------------------------------------- * * LICENSE * * This file is part of GLPI. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * * --------------------------------------------------------------------- */ namespace Glpi\Dashboard; use Glpi\Plugin\Hooks; use Glpi\RichText\RichText; use Html; use Mexitek\PHPColors\Color; use Michelf\MarkdownExtra; use Plugin; use ScssPhp\ScssPhp\Compiler; use Symfony\Component\DomCrawler\Crawler; use Search; use Toolbox; /** * Widget class **/ class Widget { public static $animation_duration = 1000; // in millseconds /** * Define all possible widget types with their $labels/ * This is used when adding a new card to display optgroups * This array can be hooked by plugins to add their own definitions. * * @return array */ public static function getAllTypes(): array { /** @var array $CFG_GLPI */ global $CFG_GLPI; $types = [ 'pie' => [ 'label' => __("Pie"), 'function' => 'Glpi\\Dashboard\\Widget::pie', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/pie.png', 'gradient' => true, 'limit' => true, 'width' => 3, 'height' => 3, ], 'donut' => [ 'label' => __("Donut"), 'function' => 'Glpi\\Dashboard\\Widget::donut', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/donut.png', 'gradient' => true, 'limit' => true, 'width' => 3, 'height' => 3, ], 'halfpie' => [ 'label' => __("Half pie"), 'function' => 'Glpi\\Dashboard\\Widget::halfPie', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/halfpie.png', 'gradient' => true, 'limit' => true, 'width' => 3, 'height' => 2, ], 'halfdonut' => [ 'label' => __("Half donut"), 'function' => 'Glpi\\Dashboard\\Widget::halfDonut', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/halfdonut.png', 'gradient' => true, 'limit' => true, 'width' => 3, 'height' => 2, ], 'bar' => [ 'label' => __("Bars"), 'function' => 'Glpi\\Dashboard\\Widget::simpleBar', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/bar.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'line' => [ 'label' => \Line::getTypeName(1), 'function' => 'Glpi\\Dashboard\\Widget::simpleLine', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/line.png', 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'lines' => [ 'label' => __("Multiple lines"), 'function' => 'Glpi\\Dashboard\\Widget::multipleLines', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/line.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'area' => [ 'label' => __("Area"), 'function' => 'Glpi\\Dashboard\\Widget::simpleArea', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/area.png', 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'areas' => [ 'label' => __("Multiple areas"), 'function' => 'Glpi\\Dashboard\\Widget::multipleAreas', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/area.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 5, 'height' => 3, ], 'bars' => [ 'label' => __("Multiple bars"), 'function' => 'Glpi\\Dashboard\\Widget::multipleBars', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/bar.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 5, 'height' => 3, ], 'hBars' => [ 'label' => __("Multiple horizontal bars"), 'function' => 'Glpi\\Dashboard\\Widget::multipleHBars', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/hbar.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 5, 'height' => 3, ], 'stackedbars' => [ 'label' => __("Stacked bars"), 'function' => 'Glpi\\Dashboard\\Widget::StackedBars', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/stacked.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'stackedHBars' => [ 'label' => __("Horizontal stacked bars"), 'function' => 'Glpi\\Dashboard\\Widget::stackedHBars', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/hstacked.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 4, 'height' => 3, ], 'hbar' => [ 'label' => __("Horizontal bars"), 'function' => 'Glpi\\Dashboard\\Widget::simpleHbar', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/hbar.png', 'gradient' => true, 'limit' => true, 'pointlbl' => true, 'width' => 3, 'height' => 4, ], 'bigNumber' => [ 'label' => __("Big number"), 'function' => 'Glpi\\Dashboard\\Widget::bigNumber', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/bignumber.png', ], 'multipleNumber' => [ 'label' => __("Multiple numbers"), 'function' => 'Glpi\\Dashboard\\Widget::multipleNumber', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/multiplenumbers.png', 'limit' => true, 'gradient' => true, 'width' => 3, 'height' => 3, ], 'markdown' => [ 'label' => __("Editable markdown"), 'function' => 'Glpi\\Dashboard\\Widget::markdown', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/markdown.png', 'width' => 4, 'height' => 4, ], 'searchShowList' => [ 'label' => __("Search result"), 'function' => 'Glpi\\Dashboard\\Widget::searchShowList', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/table.png', 'limit' => true, 'width' => 5, 'height' => 4, ], 'summaryNumbers' => [ 'label' => __("Summary numbers"), 'function' => 'Glpi\\Dashboard\\Widget::summaryNumber', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/summarynumber.png', 'limit' => true, 'gradient' => true, 'width' => 4, 'height' => 2, ], 'articleList' => [ 'label' => __("List of articles"), 'function' => 'Glpi\\Dashboard\\Widget::articleList', 'image' => $CFG_GLPI['root_doc'] . '/pics/charts/articles.png', 'limit' => true, 'width' => 3, 'height' => 4, ], ]; $more_types = Plugin::doHookFunction(Hooks::DASHBOARD_TYPES); if (is_array($more_types)) { $types = array_merge($types, $more_types); } return $types; } /** * Display a big number widget. * * @param array $params contains these keys: * - int 'number': the number to display * - string 'url': url to redirect when clicking on the widget * - string 'label': title of the widget * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - array 'filters': array of filter's id to apply classes on widget html * * @return string html of the widget */ public static function bigNumber(array $params = []): string { $default = [ 'number' => 0, 'url' => '', 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'id' => 'bn_' . mt_rand(), 'filters' => [], ]; $p = array_merge($default, $params); $formatted_number = Toolbox::shortenNumber($p['number']); $fg_color = Toolbox::getFgColor($p['color']); $fg_hover_color = Toolbox::getFgColor($p['color'], 15); $fg_hover_border = Toolbox::getFgColor($p['color'], 30); $class = count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $href = strlen($p['url']) ? "href='{$p['url']}'" : ""; $label = $p['label']; $html = <<<HTML <style> #{$p['id']} { background-color: {$p['color']}; color: {$fg_color}; } #{$p['id']}:hover { background-color: {$fg_hover_color}; border: 1px solid {$fg_hover_border}; } .theme-dark #{$p['id']} { background-color: {$fg_color}; color: {$p['color']}; } .theme-dark #{$p['id']}:hover { background-color: {$fg_hover_color}; color: {$fg_color}; border: 1px solid {$fg_hover_border}; } </style> <a {$href} id="{$p['id']}" class="card big-number $class" title="{$p['alt']}"> <span class="content">$formatted_number</span> <div class="label" title="{$label}">{$label}</div> <i class="main-icon {$p['icon']}"></i> </a> HTML; return $html; } public static function summaryNumber(array $params = []): string { $params['class'] = 'summary-numbers'; return self::multipleNumber($params); } /** * Display a multiple big number widget. * * @param array $params contains these keys: * - array 'data': represents the lines to display * - int 'number': the number to display in the line * - string 'url': url to redirect when clicking on the line * - string 'label': title of the line * - string 'number': number to display in the line * - string 'icon': font awesome class to display an icon side of the line * - int 'limit': the numbers of lines diplayed * - string 'label': global title of the widget * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - array 'filters': array of filter's id to apply classes on widget html * * @return string html of the widget */ public static function multipleNumber(array $params = []): string { $default = [ 'data' => [], 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'limit' => 99999, 'use_gradient' => false, 'class' => "multiple-numbers", 'filters' => [], 'rand' => mt_rand(), ]; $p = array_merge($default, $params); $default_entry = [ 'url' => '', 'icon' => '', 'label' => '', 'number' => '', ]; $nb_lines = min($p['limit'], count($p['data'])); array_splice($p['data'], $nb_lines); $fg_color = Toolbox::getFgColor($p['color']); $class = $p['class']; $class .= count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $numbers_html = ""; $i = 0; foreach ($p['data'] as $entry) { if (!is_array($entry)) { continue; } $entry = array_merge($default_entry, $entry); $href = strlen($entry['url']) ? "href='{$entry['url']}'" : ""; $color = isset($entry['color']) ? "style=\"color: {$entry['color']};\"" : ""; $color2 = isset($entry['color']) ? "style=\"color: " . Toolbox::getFgColor($entry['color'], 20) . ";\"" : ""; $formatted_number = Toolbox::shortenNumber($entry['number']); $numbers_html .= <<<HTML <a {$href} class="line line-{$i}"> <span class="content" {$color}>$formatted_number</span> <i class="icon {$entry['icon']}" {$color2}></i> <span class="label" {$color2}>{$entry['label']}</span> </a> HTML; $i++; } $nodata = isset($p['data']['nodata']) && $p['data']['nodata']; if ($nodata) { $numbers_html = "<span class='line empty-card no-data'> <span class='content'> <i class='icon fas fa-alert-triangle'></i> </span> <span class='label'>" . __('No data found') . "</span> <span>"; } $palette_style = ""; if ($p['use_gradient']) { $palette = self::getGradientPalette($p['color'], $i, false); foreach ($palette['names'] as $index => $letter) { $bgcolor = $palette['colors'][$index]; $bgcolor_h = Toolbox::getFgColor($bgcolor, 10); $color = Toolbox::getFgColor($bgcolor); $palette_style .= " #chart-{$p['rand']} .line-$letter { background-color: $bgcolor; color: $color; } #chart-{$p['rand']} .line-$letter:hover { background-color: $bgcolor_h; font-weight: bold; } "; } } $html = <<<HTML <style> {$palette_style} #chart-{$p['rand']} { background-color: {$p['color']}; color: {$fg_color}; } .theme-dark #chart-{$p['rand']} { background-color: {$fg_color}; color: {$p['color']}; } </style> <div class="card $class" id="chart-{$p['rand']}" title="{$p['alt']}"> <div class='scrollable'> <div class='table'> {$numbers_html} </div> </div> <span class="main-label">{$p['label']}</span> <i class="main-icon {$p['icon']}" style="color: {$fg_color}"></i> </div> HTML; return $html; } /** * Display a widget with a pie chart * * @param array $params contains these keys: * - array 'data': represents the slices to display * - int 'number': number of the slice * - string 'url': url to redirect when clicking on the slice * - string 'label': title of the slice * - string 'label': global title of the widget * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - bool 'use_gradient': gradient or generic palette * - int 'limit': the number of slices * - bool 'donut': do we want a "holed" pie * - bool 'gauge': do we want an half pie * - array 'filters': array of filter's id to apply classes on widget html * * @return string html of the widget */ public static function pie(array $params = []): string { $default = [ 'data' => [], 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'donut' => false, 'half' => false, 'use_gradient' => false, 'limit' => 99999, 'filters' => [], 'rand' => mt_rand(), ]; $p = array_merge($default, $params); $p['cache_key'] = $p['cache_key'] ?? $p['rand']; $default_entry = [ 'url' => '', 'icon' => '', 'label' => '', 'number' => '', ]; $nb_slices = min($p['limit'], count($p['data'])); array_splice($p['data'], $nb_slices); $nodata = isset($p['data']['nodata']) && $p['data']['nodata']; $fg_color = Toolbox::getFgColor($p['color']); $dark_bg_color = Toolbox::getFgColor($p['color'], 80); $dark_fg_color = Toolbox::getFgColor($p['color'], 40); $chart_id = "chart-{$p['cache_key']}"; $class = "pie"; $class .= $p['half'] ? " half" : ""; $class .= $p['donut'] ? " donut" : ""; $class .= count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $no_data_html = ""; if ($nodata) { $no_data_html = "<span class='empty-card no-data'> <div>" . __('No data found') . "</div> <span>"; } $nb_series = min($p['limit'], count($p['data'])); $palette_style = ""; if ($p['use_gradient']) { $palette_style = self::getCssGradientPalette( $p['color'], $nb_series, ".dashboard #{$chart_id}", false ); } $html = <<<HTML <style> #{$chart_id} { background-color: {$p['color']}; color: {$fg_color} } .theme-dark #{$chart_id} { background-color: {$dark_bg_color}; color: {$dark_fg_color}; } #{$chart_id} .ct-label { fill: {$fg_color}; color: {$fg_color}; } .theme-dark #{$chart_id} .ct-label { fill: {$dark_fg_color}; color: {$dark_fg_color}; } {$palette_style} </style> <div style="height: 100%"> <div class="card g-chart {$class}" id="{$chart_id}"> <div class="chart ct-chart">{$no_data_html}</div> <span class="main-label">{$p['label']}</span> <i class="main-icon {$p['icon']}"></i> </div> </div> HTML; if ($nodata) { return $html; } $labels = []; $series = []; $total = 0; foreach ($p['data'] as $entry) { $entry = array_merge($default_entry, $entry); $total += $entry['number']; $labels[] = $entry['label']; $series[] = [ 'meta' => $entry['label'], 'value' => $entry['number'], 'url' => $entry['url'], ]; } $total_txt = Toolbox::shortenNumber($total, 1, false); $labels = json_encode($labels); $series = json_encode($series); $chartPadding = 4; $height_divider = 1; $half_opts = ""; if ($p['half']) { $half_opts = " startAngle: 270, total: " . ($total * 2) . ", "; $chartPadding = 9; $height_divider = 2; } $donut_opts = " showLabel: false, "; if ($p['donut']) { $donut_opts = " donutSolid: true, showLabel: true, labelInterpolationFnc: function(value) { return '{$total_txt}'; }, "; } $donut = $p['donut'] ? 'true' : 'false'; $animation_duration = self::$animation_duration; $js = <<<JAVASCRIPT $(function () { if (Dashboard.getActiveDashboard()) { var target = Dashboard.getActiveDashboard().element.find('#{$chart_id} .chart')[0]; } else { var target = '#{$chart_id} .chart'; } var chart = new Chartist.Pie(target, { labels: {$labels}, series: {$series}, }, { width: '100%', chartPadding: {$chartPadding}, donut: {$donut}, $donut_opts $half_opts donutWidth: '50%', plugins: [ Chartist.plugins.tooltip({ appendToBody: true, class: 'dashboard-tooltip' }) ] }); chart.on('draw', function(data) { // animate if (data.type === 'slice') { // set url redirecting on slice var url = _.get(data, 'series.url') || ""; if (url.length > 0) { data.element.attr({ 'data-clickable': true }); data.element._node.onclick = function() { if (!Dashboard.getActiveDashboard().edit_mode) { window.location = url; } } } // Get the total path length in order to use for dash array animation var pathLength = data.element._node.getTotalLength(); // Set a dasharray that matches the path length as prerequisite to animate dashoffset data.element.attr({ 'stroke-dasharray': pathLength + 'px ' + pathLength + 'px' }); // Create animation definition while also assigning an ID to the animation for later sync usage var animationDefinition = { 'stroke-dashoffset': { id: 'anim' + data.index, dur: {$animation_duration}, from: -pathLength + 'px', to: '0px', easing: Chartist.Svg.Easing.easeOutQuint, // We need to use `fill: 'freeze'` otherwise our animation will fall back to initial (not visible) fill: 'freeze' } }; // We need to set an initial value before the animation starts as we are not in guided mode which would do that for us data.element.attr({ 'stroke-dashoffset': -pathLength + 'px' }); // We can't use guided mode as the animations need to rely on setting begin manually // See http://gionkunz.github.io/chartist-js/api-documentation.html#chartistsvg-function-animate data.element.animate(animationDefinition, false); } // donut center label if (data.type === 'label') { if (data.index === 0) { var width = data.element.root().width() / 2; var height = data.element.root().height() / 2; var fontsize = ((height / {$height_divider}) / (1.3 * "{$total_txt}".length)); data.element.attr({ dx: width, dy: height - ($chartPadding / 2), 'style': 'font-size: '+fontsize, }); // apend real total var text = new Chartist.Svg('title'); text.text("{$total}"); data.element.append(text); } else { data.element.remove(); } } // fade others bars on one mouseouver chart.on('created', function(bar) { $('#{$chart_id} .ct-series') .mouseover(function() { $(this).parent().children().addClass('disable-animation'); $(this).addClass('mouseover'); $(this).siblings() .addClass('notmouseover'); $('#{$chart_id} .ct-label') .addClass('fade'); }) .mouseout(function() { $(this).removeClass('mouseover'); $(this).siblings() .removeClass('notmouseover'); $('#{$chart_id} .ct-label') .removeClass('fade'); }); }); }); }); JAVASCRIPT; $js = \Html::scriptBlock($js); return $html . $js; } /** * Display a widget with a donut chart * @see self::pie for params * * @return string html */ public static function donut(array $params = []): string { return self::pie(array_merge($params, [ 'donut' => true, ])); } /** * Display a widget with a half donut chart * @see self::pie for params * * @return string html */ public static function halfDonut(array $params = []): string { return self::donut(array_merge($params, [ 'half' => true, ])); } /** * Display a widget with a half pie chart * @see self::pie for params * * @return string html */ public static function halfPie(array $params = []): string { return self::pie(array_merge($params, [ 'half' => true, ])); } /** * Display a widget with a bar chart (with single series) * @see self::getBarsGraph for params * * @return string html */ public static function simpleBar(array $params = []): string { $default = [ 'data' => [], 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'horizontal' => false, 'distributed' => true, 'rand' => mt_rand(), ]; $params = array_merge($default, $params); $default_entry = [ 'url' => '', 'icon' => '', 'label' => '', 'number' => '', ]; $labels = []; $series = []; $total = 0; foreach ($params['data'] as $entry) { if (!is_array($entry)) { continue; } $entry = array_merge($default_entry, $entry); $total += $entry['number']; $labels[] = $entry['label']; $series[] = [ 'meta' => $entry['label'], 'value' => $entry['number'], 'url' => $entry['url'], ]; } // chartist bar graphs are always multiple lines if (!$params['distributed']) { $series = [$series]; } return self::getBarsGraph($params, $labels, $series); } /** * Display a widget with an horizontal bar chart * @see self::getBarsGraph for params * * @return string html */ public static function simpleHbar(array $params = []): string { return self::simpleBar(array_merge($params, [ 'horizontal' => true, ])); } /** * @inheritdoc self::simpleHbar */ public static function hbar(array $params = []): string { return self::simpleHbar($params); } /** * Display a widget with a multiple bars chart * @see self::getBarsGraph for params * * @return string html */ public static function multipleBars(array $params = []): string { return self::getBarsGraph( array_merge($params, [ 'legend' => true, 'multiple' => true, ]), $params['data']['labels'], $params['data']['series'] ); } /** * Display a widget with a stacked multiple bars chart * @see self::getBarsGraph for params * * @return string html */ public static function StackedBars(array $params = []): string { return self::multipleBars(array_merge($params, [ 'stacked' => true, ])); } /** * Display a widget with a horizontal stacked multiple bars chart * @see self::getBarsGraph for params * * @return string html */ public static function stackedHBars(array $params = []): string { return self::StackedBars(array_merge($params, [ 'horizontal' => true, ])); } /** * Display a widget with a horizontal multiple bars chart * @see self::getBarsGraph for params * * @return string html */ public static function multipleHBars(array $params = []): string { return self::multipleBars(array_merge($params, [ 'horizontal' => true, ])); } /** * Display a widget with a bars chart * * @param array $params contains these keys: * - array 'data': represents the bars to display * - string 'url': url to redirect when clicking on the bar * - string 'label': title of the bar * - int 'number': number of the bar * - string 'label': global title of the widget * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - bool 'horizontal': do we want an horizontal chart * - bool 'distributed': do we want a distributed chart (see https://gionkunz.github.io/chartist-js/examples.html#example-bar-distributed-series) * - bool 'legend': do we display a legend for the graph * - bool 'stacked': do we display multiple bart stacked or grouped * - bool 'use_gradient': gradient or generic palette * - bool 'point_labels': display labels (for values) directly on graph * - int 'limit': the number of bars * - array 'filters': array of filter's id to apply classes on widget html * @param array $labels title of the bars (if a single array is given, we have a single bar graph) * @param array $series values of the bar (if a single array is given, we have a single bar graph) * * @return string html of the widget */ private static function getBarsGraph( array $params = [], array $labels = [], array $series = [] ): string { $defaults = [ 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'legend' => false, 'multiple' => false, 'stacked' => false, 'horizontal' => false, 'distributed' => false, 'use_gradient' => false, 'point_labels' => false, 'limit' => 99999, 'filters' => [], 'rand' => mt_rand(), ]; $p = array_merge($defaults, $params); $p['cache_key'] = $p['cache_key'] ?? $p['rand']; $nb_series = count($series); $nb_labels = min($p['limit'], count($labels)); if ($p['distributed']) { array_splice($labels, $nb_labels); } else { array_splice($labels, 0, -$nb_labels); } if ($p['multiple']) { foreach ($series as &$serie) { if (isset($serie['data'])) { array_splice($serie['data'], 0, -$nb_labels); } } } else { if ($p['distributed']) { array_splice($series, $nb_labels); } else { array_splice($series[0], 0, -$nb_labels); } } $json_labels = json_encode($labels); $json_series = json_encode($series); $fg_color = Toolbox::getFgColor($p['color']); $line_color = Toolbox::getFgColor($p['color'], 10); $dark_bg_color = Toolbox::getFgColor($p['color'], 80); $dark_fg_color = Toolbox::getFgColor($p['color'], 40); $dark_line_color = Toolbox::getFgColor($p['color'], 90); $animation_duration = self::$animation_duration; $chart_id = 'chart_' . $p['cache_key']; $class = "bar"; $class .= $p['horizontal'] ? " horizontal" : ""; $class .= $p['distributed'] ? " distributed" : ""; $class .= $nb_series <= 10 ? " tab10" : ""; $class .= $nb_series > 10 ? " tab20" : ""; $class .= count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $palette_style = ""; if ($p['use_gradient']) { $nb_gradients = $p['distributed'] ? $nb_labels : $nb_series; $palette_style = self::getCssGradientPalette($p['color'], $nb_gradients, "#{$chart_id}"); } $nodata = isset($p['data']['nodata']) && $p['data']['nodata'] || count($series) == 0; $no_data_html = ""; if ($nodata) { $no_data_html = "<span class='empty-card no-data'> <div>" . __('No data found') . "</div> <span>"; } $legend_options = ""; if ($p['legend']) { $legend_options = " Chartist.plugins.legend(),"; } $html = <<<HTML <style> #{$chart_id} { background-color: {$p['color']}; color: {$fg_color} } .theme-dark #{$chart_id} { background-color: {$dark_bg_color}; color: {$dark_fg_color}; } #{$chart_id} .ct-label { color: {$fg_color}; } .theme-dark #{$chart_id} .ct-label { color: {$dark_fg_color}; } #{$chart_id} .ct-grid { stroke: {$line_color}; } .theme-dark #{$chart_id} .ct-grid { stroke: {$dark_line_color}; } {$palette_style} </style> <div style="height: 100%"> <div class="card g-chart $class" id="{$chart_id}"> <div class="chart ct-chart">$no_data_html</div> <span class="main-label">{$p['label']}</span> <i class="main-icon {$p['icon']}"></i> </div> </div> HTML; $horizontal_options = ""; $vertical_options = ""; $is_horizontal = "false"; if ($p['horizontal']) { $is_horizontal = "true"; $horizontal_options = " horizontalBars: true, axisY: { offset: 100 }, axisX: { onlyInteger: true }, "; } else { $vertical_options = " axisX: { offset: 50, }, axisY: { onlyInteger: true }, "; } $stack_options = ""; if ($p['stacked']) { $stack_options = " stackBars: true,"; } $distributed_options = ""; if ($p['distributed']) { $distributed_options = " distributeSeries: true,"; } // just to avoid issues with syntax coloring $point_labels = $p['point_labels'] ? "true" : "false;"; $is_multiple = $p['multiple'] ? "true" : "false;"; $js = <<<JAVASCRIPT $(function () { if (Dashboard.getActiveDashboard()) { var target = Dashboard.getActiveDashboard().element.find('#{$chart_id} .chart')[0]; } else { var target = '#{$chart_id} .chart'; } var chart = new Chartist.Bar(target, { labels: {$json_labels}, series: {$json_series}, }, { width: '100%', seriesBarDistance: 10, chartPadding: 0, $distributed_options $horizontal_options $vertical_options $stack_options plugins: [ $legend_options Chartist.plugins.tooltip({ appendToBody: true, class: 'dashboard-tooltip' }) ] }); var is_horizontal = chart.options.horizontalBars; var is_vertical = !is_horizontal; var is_stacked = chart.options.stackBars; var nb_elements = chart.data.labels.length; var nb_series = chart.data.series.length; var bar_margin = chart.options.seriesBarDistance; var point_labels = {$point_labels} var is_multiple = {$is_multiple} if (!chart.options.stackBars && chart.data.series.length > 0 && chart.data.series[0].hasOwnProperty('data')) { nb_elements = nb_elements * chart.data.series.length; bar_margin += 1; } chart.on('draw', function(data) { if (data.type === 'bar') { // set url redirecting on bar var url = _.get(data, 'series['+data.index+'].url') || _.get(data, 'series.data['+data.index+'].url') || _.get(data, 'series.url') || ""; if (url.length > 0) { data.element.attr({ 'data-clickable': true }); data.element._node.onclick = function() { if (!Dashboard.getActiveDashboard().edit_mode) { window.location = url; } } } var chart_height = data.chartRect.height(); var chart_width = data.chartRect.width(); var stroke_width = chart_width / nb_elements; if (is_horizontal) { stroke_width = chart_height / nb_elements; } if (!chart.options.stackBars && chart.data.series.length > 0 && is_vertical) { stroke_width -= bar_margin * nb_elements; } else { stroke_width -= bar_margin; } data.element.attr({ 'style': 'stroke-width: '+stroke_width+'px' }); var axis_anim = 'y'; if ({$is_horizontal}) { axis_anim = 'x'; } var animate_properties = { opacity: { dur: {$animation_duration}, from: 0, to: 1, easing: Chartist.Svg.Easing.easeOutQuint } }; animate_properties[axis_anim+'2'] = { dur: {$animation_duration}, from: data[axis_anim+'1'], to: data[axis_anim+'2'], easing: Chartist.Svg.Easing.easeOutQuint }; data.element.animate(animate_properties); // append labels var display_labels = true; var labelX = 0; var labelY = 0; var value = data.element.attr('ct:value').toString(); var text_anchor = 'middle'; if (is_vertical) { labelX = data.x2; labelY = data.y2 + 15; if (is_multiple) { labelY = data.y2 - 5; } else if (data.y1 - data.y2 < 18) { display_labels = false; } } if (is_horizontal) { var word_width = value.length * 5 + 5; var bar_width = 0; if (value > 0) { labelX = data.x2 - word_width; bar_width = data.x2 - data.x1; } else { labelX = data.x2 + word_width; bar_width = data.x1 - data.x2; } labelY = data.y2; // don't display label if width too short if (bar_width < word_width) { display_labels = false; } } if (is_stacked) { labelY = data.y2 + 15; // don't display label if height too short if (is_horizontal) { if (data.x2 - data.x1 < 15) { display_labels = false; } } else { if (data.y1 - data.y2 < 15) { display_labels = false; } } } // don't display label if value is not relevant if (value == 0 || !point_labels) { display_labels = false; } if (display_labels) { label = new Chartist.Svg('text'); label.text(value); label.addClass("ct-barlabel"); label.attr({ x: labelX, y: labelY, 'text-anchor': text_anchor }); return data.group.append(label); } } }); chart.on('created', function(bar) { $('#{$chart_id} .ct-series') .mouseover(function() { $(this).siblings().children().css('stroke-opacity', "0.2"); }) .mouseout(function() { $(this).siblings().children().css('stroke-opacity', "1"); }); }); }); JAVASCRIPT; $js = \Html::scriptBlock($js); return $html . $js; } /** * Display a widget with a line chart (with single series) * @see self::getLinesGraph for params * * @return string html */ public static function simpleLine(array $params = []): string { $default_entry = [ 'url' => '', 'icon' => '', 'label' => '', 'number' => '', ]; $labels = []; $series = []; foreach ($params['data'] as $entry) { $entry = array_merge($default_entry, $entry); $labels[] = $entry['label']; $series[] = [ 'meta' => $entry['label'], 'value' => $entry['number'], 'url' => $entry['url'], ]; } // chartist line graphs are always multiple lines $series = [$series]; return self::getLinesGraph($params, $labels, $series); } /** * Display a widget with a area chart (with single serie) * @see self::getLinesGraph for params * * @return string html */ public static function simpleArea(array $params = []): string { return self::simpleLine(array_merge($params, [ 'area' => true, ])); } /** * Display a widget with a multiple line chart (with multiple series) * @see self::getLinesGraph for params * * @return string html */ public static function multipleLines(array $params = []): string { return self::getLinesGraph( array_merge($params, [ 'legend' => true, 'multiple' => true, ]), $params['data']['labels'], $params['data']['series'] ); } /** * Display a widget with a multiple area chart (with multiple series) * @see self::getLinesGraph for params * * @return string html */ public static function multipleAreas(array $params = []): string { return self::multipleLines(array_merge($params, [ 'area' => true, ])); } /** * Display a widget with a lines chart * * @param array $params contains these keys: * - array 'data': represents the lines to display * - string 'url': url to redirect when clicking on the line * - string 'label': title of the line * - int 'number': number of the line * - string 'label': global title of the widget * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - bool 'area': do we want an area chart * - bool 'legend': do we display a legend for the graph * - bool 'use_gradient': gradient or generic palette * - bool 'point_labels': display labels (for values) directly on graph * - int 'limit': the number of lines * - array 'filters': array of filter's id to apply classes on widget html * @param array $labels title of the lines (if a single array is given, we have a single line graph) * @param array $series values of the line (if a single array is given, we have a single line graph) * * @return string html of the widget */ private static function getLinesGraph( array $params = [], array $labels = [], array $series = [] ): string { $defaults = [ 'data' => [], 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 'area' => false, 'legend' => false, 'multiple' => false, 'use_gradient' => false, 'point_labels' => false, 'limit' => 99999, 'filters' => [], 'rand' => mt_rand(), ]; $p = array_merge($defaults, $params); $p['cache_key'] = $p['cache_key'] ?? $p['rand']; $nb_series = count($series); $nb_labels = min($p['limit'], count($labels)); array_splice($labels, 0, -$nb_labels); if ($p['multiple']) { foreach ($series as &$serie) { if (isset($serie['data'])) { array_splice($serie['data'], 0, -$nb_labels); } } } else { array_splice($series[0], 0, -$nb_labels); } $json_labels = json_encode($labels); $json_series = json_encode($series); $chart_id = 'chart_' . $p['cache_key']; $fg_color = Toolbox::getFgColor($p['color']); $line_color = Toolbox::getFgColor($p['color'], 10); $dark_bg_color = Toolbox::getFgColor($p['color'], 80); $dark_fg_color = Toolbox::getFgColor($p['color'], 40); $dark_line_color = Toolbox::getFgColor($p['color'], 90); $class = "line"; $class .= $p['area'] ? " area" : ""; $class .= $p['multiple'] ? " multiple" : ""; $class .= count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $animation_duration = self::$animation_duration; $palette_style = ""; if (!$p['multiple'] || $p['use_gradient']) { $palette_style = self::getCssGradientPalette($p['color'], $nb_series, "#{$chart_id}"); } $pointlabels_plugins = ""; if ($p['point_labels']) { $pointlabels_plugins = ", Chartist.plugins.ctPointLabels({ textAnchor: 'middle', labelInterpolationFnc: function(value) { if (value == undefined) { return '' } return value; } })"; } $legend_options = ""; if ($p['legend']) { $legend_options = " Chartist.plugins.legend(),"; } // Adding a legend will add an "<ul>" element in a div that already have // a <svg> child set to take 100% of the available height // This will create some overflow that will impact the content below the // graph (the label) // We avoid that by adding some padding at the top of the label if a // legend is defined $label_class = $p['legend'] ? "mt-4" : ""; $html = <<<HTML <style> #{$chart_id} { background-color: {$p['color']}; color: {$fg_color} } .theme-dark #{$chart_id} { background-color: {$dark_bg_color}; color: {$dark_fg_color}; } #{$chart_id} .ct-label { color: {$fg_color}; } .theme-dark #{$chart_id} .ct-label { color: {$dark_fg_color}; } #{$chart_id} .ct-grid { stroke: {$line_color}; } .theme-dark #{$chart_id} .ct-grid { stroke: {$dark_line_color}; } #{$chart_id} .ct-circle { stroke: {$p['color']}; stroke-width: 3; } #{$chart_id} .ct-circle + .ct-label { stroke: {$p['color']}; } {$palette_style} </style> <div style="height: 100%"> <div class="card g-chart $class" id="{$chart_id}"> <div class="chart ct-chart"></div> <span class="main-label {$label_class}">{$p['label']}</span> <i class="main-icon {$p['icon']}"></i> </div> </div> HTML; $area_options = ""; if ($p['area']) { $area_options = " showArea: true,"; } $js = <<<JAVASCRIPT $(function () { if (Dashboard.getActiveDashboard()) { var target = Dashboard.getActiveDashboard().element.find('#{$chart_id} .chart')[0]; } else { var target = '#{$chart_id} .chart'; } var chart = new Chartist.Line(target, { labels: {$json_labels}, series: {$json_series}, }, { width: '100%', fullWidth: true, chartPadding: { right: 40 }, axisY: { labelInterpolationFnc: function(value) { let display_value = 0; let unit = ""; if (value < 1e3) { // less than 1K display_value = value; } else if (value < 1e6) { // More than 1k, less than 1M display_value = value / 1e3; unit = "K"; } else { // More than 1M display_value = value / 1e6; unit = "M"; } // 1 decimal max display_value = Math.round(display_value * 10) / 10; return display_value + unit; }, }, {$area_options} plugins: [ {$legend_options} Chartist.plugins.tooltip({ appendToBody: true, class: 'dashboard-tooltip', pointClass: 'ct-circle' }) {$pointlabels_plugins} ] }); chart.on('draw', function(data) { // animation if (data.type === 'line' || data.type === 'area') { data.element.animate({ d: { begin: 300 * data.index, dur: $animation_duration, from: data.path.clone().scale(1, 0).translate(0, data.chartRect.height()).stringify(), to: data.path.clone().stringify(), easing: Chartist.Svg.Easing.easeOutQuint } }); } if (data.type === 'point') { // set url redirecting on line var url = _.get(data, 'series['+data.index+'].url') || _.get(data, 'series.data['+data.index+'].url') || _.get(data, 'series.url') || ''; var clickable = url.length > 0; var circle = new Chartist.Svg('circle', { cx: [data.x], cy: [data.y], r: data.value.y > 0 ? [5] : [0], "ct:value": data.value.y, "data-clickable": clickable }, 'ct-circle'); var circle = data.element.replace(circle); if (clickable) { circle.getNode().onclick = function() { if (!Dashboard.getActiveDashboard().edit_mode) { window.location = url; } } } } }); // hide other lines when hovering a point chart.on('created', function(bar) { $('#{$chart_id} .ct-series .ct-circle, #{$chart_id} .ct-series .ct-circle + .ct-label') .mouseover(function() { $(this) .attr('r', "9") .parent(".ct-series") .siblings().children() .css('stroke-opacity', "0.05") .filter(".ct-circle, .ct-label").css('fill-opacity', "0.1"); }) .mouseout(function() { $(this) .attr('r', "5") .parent(".ct-series") .siblings().children() .css('stroke-opacity', "1") .filter(".ct-circle, .ct-label").css('fill-opacity', "1"); }); }); }); JAVASCRIPT; $js = Html::scriptBlock($js); return $html . $js; } /** * Display an editable markdown widget * * @param array $params with these keys: * - string 'color': hex color * - string 'markdown_content': text content formatted with warkdown * * @return string html */ public static function markdown(array $params = []): string { $default = [ 'color' => '', 'markdown_content' => '', ]; $p = array_merge($default, $params); // fix auto-escaping if (isset($p['markdown_content'])) { $p['markdown_content'] = \Html::cleanPostForTextArea($p['markdown_content']); } $ph = __("Type markdown text here"); $fg_color = Toolbox::getFgColor($p['color']); $border_color = Toolbox::getFgColor($p['color'], 10); $md = new MarkdownExtra(); // Prevent escaping as code is already escaped by GLPI sanityze $md->code_span_content_func = function ($code) { return $code; }; $md->code_block_content_func = function ($code) { return $code; }; $content = RichText::getSafeHtml($md->transform($p['markdown_content'])); $html = <<<HTML <div class="card markdown" style="background-color: {$p['color']}; color: {$fg_color}; border-color: {$border_color}"> <div class="html_content">{$content}</div> <textarea class="markdown_content" placeholder="{$ph}">{$p['markdown_content']}</textarea> </div> HTML; return $html; } /** * Display an html table from a \Search result * * @param array $params contains these keys: * - string 'itemtype': Glpi oObject to search * - array 's_criteria': parameters to pass to the search engine (@see \Search::manageParams) * - string 'label': global title of the widget * - string 'url': link to the full search result * - string 'alt': tooltip * - string 'color': hex color of the widget * - string 'icon': font awesome class to display an icon side of the label * - string 'id': unique dom identifier * - int 'limit': the number of displayed lines * - array 'filters': array of filter's id to apply classes on widget html * * @return string html of the widget */ public static function searchShowList(array $params = []): string { $default = [ 'url' => '', 'label' => '', 'alt' => '', 'color' => '', 'icon' => '', 's_criteria' => '', 'itemtype' => '', 'limit' => $_SESSION['glpilist_limit'], 'rand' => mt_rand(), 'filters' => [], ]; $p = array_merge($default, $params); $id = "search-table-" . $p['rand']; $color = new Color($p['color']); $is_light = $color->isLight(); $fg_color = Toolbox::getFgColor($p['color'], $is_light ? 65 : 40); $fg_color2 = Toolbox::getFgColor($p['color'], 5); $href = strlen($p['url']) ? "href='{$p['url']}'" : ""; $class = count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; // prepare search data $_GET['_in_modal'] = true; $params = [ 'criteria' => $p['s_criteria'], 'reset' => 'reset', ]; ob_start(); $params = Search::manageParams($p['itemtype'], $params, false); // remove parts of search list $params = array_merge($params, [ 'showmassiveactions' => false, 'dont_flush' => true, 'show_pager' => false, 'show_footer' => false, 'no_sort' => true, 'list_limit' => $p['limit'] ]); Search::showList($p['itemtype'], $params); $crawler = new Crawler(ob_get_clean()); $search_result = $crawler->filter('.search-results')->outerHtml(); $html = <<<HTML <style> #{$id} .tab_cadrehov th { background: {$fg_color2}; } </style> <div class="card search-table {$class}" id="{$id}" style="background-color: {$p['color']}; color: {$fg_color}"> <div class='table-container'> $search_result </div> <span class="main-label"> <a {$href}>{$p['label']}</a> </span> <i class="main-icon {$p['icon']}"></i> </div> HTML; return $html; } public static function articleList(array $params): string { $default = [ 'data' => [], 'label' => '', 'alt' => '', 'url' => '', 'color' => '', 'icon' => '', 'limit' => 99999, 'class' => "articles-list", 'rand' => mt_rand(), 'filters' => [], ]; $p = array_merge($default, $params); $default_entry = [ 'url' => '', 'icon' => '', 'label' => '', 'number' => '', ]; $nb_lines = min($p['limit'], count($p['data'])); array_splice($p['data'], $nb_lines); $fg_color = Toolbox::getFgColor($p['color']); $bg_color_2 = Toolbox::getFgColor($p['color'], 5); $class = $p['class']; $class .= count($p['filters']) > 0 ? " filter-" . implode(' filter-', $p['filters']) : ""; $i = 0; $list_html = ""; foreach ($p['data'] as $entry) { if (!is_array($entry)) { continue; } $entry = array_merge($default_entry, $entry); $href = strlen($entry['url']) ? "href='{$entry['url']}'" : ""; $author = strlen($entry['author']) ? "<i class='fas fa-user'></i> {$entry['author']}" : ""; $content_size = strlen($entry['content']); $content = strlen($entry['content']) ? RichText::getEnhancedHtml($entry['content']) . ($content_size > 300 ? "<p class='read_more'><span class='read_more_button'>...</span></p>" : "" ) : ""; $list_html .= <<<HTML <li class="line"><a {$href}> <span class="label">{$entry['label']}</span> <div class="content long_text">{$content}</div> <span class="author">$author</span> <span class="date">{$entry['date']}</span> </a></li> HTML; $i++; } $nodata = isset($p['data']['nodata']) && $p['data']['nodata']; if ($nodata) { $list_html = "<span class='line empty-card no-data'> <span class='content'> <i class='icon fas fa-exclamation-triangle'></i> </span> <span class='label'>" . __('No data found') . "</span> <span>"; } $view_all = strlen($p['url']) ? "<a href='{$p['url']}'><i class='fas fa-eye' title='" . __("See all") . "'></i></a>" : ""; $html = <<<HTML <style> #chart-{$p['rand']} .line { background-color: $bg_color_2; } #chart-{$p['rand']} .fa-eye { color: {$fg_color}; } </style> <div class="card {$class}" id="chart-{$p['rand']}" title="{$p['alt']}" style="background-color: {$p['color']}; color: {$fg_color}"> <div class='scrollable'> <ul class='list'> {$list_html} </ul> </div> <span class="main-label"> {$p['label']} $view_all </span> <i class="main-icon {$p['icon']}" style="color: {$fg_color}"></i> </div> HTML; $js = <<<JAVASCRIPT $(function () { // init readmore controls read_more(); // set dates in relative format $('#chart-{$p['rand']} .date').each(function() { var line_date = $(this).html(); var rel_date = relativeDate(line_date); $(this).html(rel_date).attr('title', line_date); }); }); JAVASCRIPT; $js = \Html::scriptBlock($js); return $html . $js; } /** * Get a gradient palette for a given background color * * @param string $bgcolor the background color in hexadecimal format (Ex: #FFFFFF) * @param int $nb_series how much step in gradient we need * @param bool $revert direction of the gradient * * @return array with [ * 'names' => [...] * 'colors' => [...] * ] */ public static function getGradientPalette( string $bgcolor = "", int $nb_series = 1, bool $revert = true ) { if ($nb_series == 0) { return [ 'names' => [], 'colors' => [], ]; } if ($nb_series == 1) { return [ 'names' => ['a'], 'colors' => [Toolbox::getFgColor($bgcolor)], ]; } $min_l = 20; // min for luminosity $max_l = 20; // max ... $min_s = 30; // min for saturation $max_s = 50; // max ... $step_l = (100 - ($min_l + $max_l)) / ($nb_series * 100); $step_s = (100 - ($min_s + $max_s)) / ($nb_series * 100); $color_instance = new Color($bgcolor); $hsl = $color_instance->getHsl(); $names = []; $colors = []; for ($i = 1; $i <= $nb_series; $i++) { $names[$i - 1] = $i - 1; // adjust luminosity $i_l_step = $i * $step_l + $min_l / 100; $hsl['L'] = min(1, $revert ? 1 - $i_l_step : $i_l_step); // adjust saturation if ($hsl['H'] != 0 && $hsl['H'] != 1) { $i_s_step = $i * $step_s + $min_s / 100; $hsl['S'] = min(1, $revert ? $i_s_step : 1 - $i_s_step); } $colors[$i - 1] = "#" . Color::hslToHex($hsl); } return [ 'names' => $names, 'colors' => $colors, ]; } /** * Generate a css ruleset for chartist given a starting background color * Based on @see self::getGradientPalette */ public static function getCssGradientPalette( string $bgcolor = "", int $nb_series = 1, string $css_dom_parent = "", bool $revert = true ) { /** @var \Psr\SimpleCache\CacheInterface $GLPI_CACHE */ global $GLPI_CACHE; $palette = self::getGradientPalette($bgcolor, $nb_series, $revert); $series_names = implode(',', $palette['names']); $series_colors = implode(',', $palette['colors']); $hash = sha1($css_dom_parent . $series_names . $series_colors); if (($palette_css = $GLPI_CACHE->get($hash)) !== null) { return $palette_css; } $scss = new Compiler(); $generate_scss_path = str_replace( DIRECTORY_SEPARATOR, '/', realpath(GLPI_ROOT . '/css/includes/components/chartist/_generate.scss') ); $result = $scss->compileString( "{$css_dom_parent} { \$ct-series-names: ({$series_names}); \$ct-series-colors: ({$series_colors}); @import '{$generate_scss_path}'; }", dirname($generate_scss_path) ); $palette_css = $result->getCss(); $GLPI_CACHE->set($hash, $palette_css); return $palette_css; } }