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
9use dokuwiki\Extension\SyntaxPlugin;
10
11class 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}