1 <?php
2 /**
3  * DokuWiki Plugin RadarChart (Syntax Component)
4  *
5  * @license GPL 2 http://www.gnu.org/licenses/gpl-2.0.html
6  * @author Heinrich Krupp
7  */
8 
9 use dokuwiki\Extension\SyntaxPlugin;
10 
11 class syntax_plugin_radarchart extends SyntaxPlugin {
12     /** @var array */
13     protected array $colorSchemes;
14 
15     /** @var array */
16     protected array $defaultConfig;
17 
18     /**
19      * Constructor. Initializes properties.
20      */
21     public function __construct() {
22         $this->colorSchemes = array(
23             'default' => array(
24                 array('background' => 'rgba(54, 162, 235, 0.2)', 'border' => 'rgb(54, 162, 235)'),
25                 array('background' => 'rgba(255, 99, 132, 0.2)', 'border' => 'rgb(255, 99, 132)'),
26                 array('background' => 'rgba(75, 192, 192, 0.2)', 'border' => 'rgb(75, 192, 192)'),
27                 array('background' => 'rgba(255, 159, 64, 0.2)', 'border' => 'rgb(255, 159, 64)')
28             ),
29             'pastel' => array(
30                 array('background' => 'rgba(190, 227, 219, 0.2)', 'border' => 'rgb(137, 207, 191)'),
31                 array('background' => 'rgba(255, 214, 214, 0.2)', 'border' => 'rgb(255, 169, 169)'),
32                 array('background' => 'rgba(214, 229, 250, 0.2)', 'border' => 'rgb(159, 190, 237)'),
33                 array('background' => 'rgba(255, 234, 214, 0.2)', 'border' => 'rgb(255, 202, 149)')
34             )
35         );
36 
37         $this->defaultConfig = array(
38             'width' => 400,
39             'height' => 400,
40             'colorScheme' => 'default',
41             'minScale' => 0,
42             'maxScale' => 100,
43             'legendPosition' => 'top',
44             'borderWidth' => 2,
45             'pointRadius' => 3,
46             'fillOpacity' => 0.2,
47             'containerBg' => 'default',
48             'chartBg' => 'transparent'
49         );
50     }
51 
52     public function getType(): string {
53         return 'protected';
54     }
55 
56     public function getPType(): string {
57         return 'block';
58     }
59 
60     public function getSort(): int {
61         return 155;
62     }
63 
64     public function connectTo($mode) {
65         $this->Lexer->addSpecialPattern('<radar.*?>.*?</radar>', $mode, 'plugin_radarchart');
66     }
67 
68     protected function parseConfig($configString): array {
69         $config = $this->defaultConfig;
70 
71         try {
72             if (preg_match('/<radar\s+([^>]+)>/', $configString, $matches)) {
73                 $attrs = $matches[1];
74                 if (preg_match_all('/(\w+)="([^"]*)"/', $attrs, $pairs)) {
75                     for ($i = 0; $i < count($pairs[1]); $i++) {
76                         $key = $pairs[1][$i];
77                         $value = $pairs[2][$i];
78                         if (array_key_exists($key, $config)) {
79                             $config[$key] = $value;
80                         }
81                     }
82                 }
83             }
84         } catch (Exception $e) {
85             msg('RadarChart config parsing error: ' . hsc($e->getMessage()), -1);
86         }
87 
88         return $config;
89     }
90 
91     public function handle($match, $state, $pos, $handler): array {
92         try {
93             $config = $this->parseConfig($match);
94 
95             if (!preg_match('/<radar.*?>(.*?)<\/radar>/s', $match, $matches)) {
96                 return array(array(), array(), $config);
97             }
98 
99             $content = trim($matches[1]);
100             if (empty($content)) {
101                 return array(array(), array(), $config);
102             }
103 
104             $lines = explode("\n", $content);
105             $datasets = array();
106             $labels = array();
107             $currentDataset = null;
108 
109             foreach ($lines as $line) {
110                 $line = trim($line);
111                 if (empty($line)) continue;
112 
113                 if (preg_match('/^@dataset\s+([^|]+)(?:\|([^|]+))?(?:\|([^|]+))?$/', $line, $matches)) {
114                     if ($currentDataset !== null) {
115                         $datasets[] = $currentDataset;
116                     }
117                     $currentDataset = array(
118                         'label' => trim($matches[1]),
119                         'data' => array()
120                     );
121                     continue;
122                 }
123 
124                 $parts = array_map('trim', explode('|', $line));
125                 if (count($parts) >= 2) {
126                     if (!in_array($parts[0], $labels)) {
127                         $labels[] = $parts[0];
128                     }
129                     if ($currentDataset === null) {
130                         $currentDataset = array(
131                             'label' => 'Dataset 1',
132                             'data' => array()
133                         );
134                     }
135                     $currentDataset['data'][] = floatval($parts[1]);
136                 }
137             }
138 
139             if ($currentDataset !== null) {
140                 $datasets[] = $currentDataset;
141             }
142 
143             return array($labels, $datasets, $config);
144 
145         } catch (Exception $e) {
146             msg('RadarChart error: ' . hsc($e->getMessage()), -1);
147             return array(array(), array(), $this->defaultConfig);
148         }
149     }
150 
151     public function render($mode, $renderer, $data): bool {
152         if ($mode !== 'xhtml') return false;
153 
154         try {
155             list($labels, $datasets, $config) = $data;
156 
157             if (empty($labels) || empty($datasets)) {
158                 return false;
159             }
160 
161             $chartId = 'radar_' . md5(uniqid('', true));
162 
163             // Container style with specific dimensions
164             $containerStyle = sprintf('width: %dpx; height: %dpx;',
165                 intval($config['width']),
166                 intval($config['height'])
167             );
168 
169             $renderer->doc .= sprintf(
170                 '<div class="radar-chart-container" style="%s">',
171                 $containerStyle
172             );
173             $renderer->doc .= sprintf(
174                 '<canvas id="%s" width="%d" height="%d"></canvas>',
175                 $chartId,
176                 intval($config['width']),
177                 intval($config['height'])
178             );
179             $renderer->doc .= '</div>';
180 
181             $chartData = array(
182                 'labels' => $labels,
183                 'datasets' => array()
184             );
185 
186             $colors = $this->colorSchemes[$config['colorScheme']] ?? $this->colorSchemes['default'];
187             foreach ($datasets as $index => $dataset) {
188                 $colorIndex = $index % count($colors);
189                 $chartData['datasets'][] = array(
190                     'label' => $dataset['label'],
191                     'data' => $dataset['data'],
192                     'backgroundColor' => $colors[$colorIndex]['background'],
193                     'borderColor' => $colors[$colorIndex]['border'],
194                     'borderWidth' => intval($config['borderWidth']),
195                     'fill' => true
196                 );
197             }
198 
199             $renderer->doc .= '<script>';
200             $renderer->doc .= 'window.addEventListener("load", function() {';
201             $renderer->doc .= '  if (typeof Chart === "undefined") {';
202             $renderer->doc .= '    const script = document.createElement("script");';
203             $renderer->doc .= '    script.src = "https://cdn.jsdelivr.net/npm/chart.js";';
204             $renderer->doc .= '    script.onload = function() { createChart(); };';
205             $renderer->doc .= '    document.head.appendChild(script);';
206             $renderer->doc .= '  } else {';
207             $renderer->doc .= '    createChart();';
208             $renderer->doc .= '  }';
209             $renderer->doc .= '  function createChart() {';
210             $renderer->doc .= '    new Chart(document.getElementById("' . $chartId . '"), {';
211             $renderer->doc .= '      type: "radar",';
212             $renderer->doc .= '      data: ' . json_encode($chartData) . ',';
213             $renderer->doc .= '      options: {';
214             $renderer->doc .= '        responsive: true,';
215             $renderer->doc .= '        maintainAspectRatio: false,';
216             $renderer->doc .= '        scales: {r: {suggestedMin: ' . intval($config['minScale']) . ',';
217             $renderer->doc .= '                    suggestedMax: ' . intval($config['maxScale']) . '}},';
218             $renderer->doc .= '        plugins: {legend: {position: "' . $config['legendPosition'] . '"}}';
219             $renderer->doc .= '      }';
220             $renderer->doc .= '    });';
221             $renderer->doc .= '  }';
222             $renderer->doc .= '});';
223             $renderer->doc .= '</script>';
224 
225             return true;
226         } catch (Exception $e) {
227             msg('RadarChart render error: ' . hsc($e->getMessage()), -1);
228             return false;
229         }
230     }
231 }