<?php
declare(strict_types=1);

if (!defined('ABSPATH')) { exit; }

/**
 * Chart element renderer (shared).
 *
 * Phase 1, step 33. Adopts the frontend's renderer as canonical — it
 * shipped more polish than the editor (~690 vs ~540 lines, 62% diff)
 * with extras the editor was missing:
 *
 *   - "Tilted ring" pie style with perspective-based 3D tilt + mouse
 *     parallax sensitivity setting (`chart_tilt_sens`).
 *   - Volume 3D extrusion for pie / donut / bar / line charts
 *     (`chart_volume`) with per-layer shaded "side walls".
 *   - Conic-gradient mask clockwise reveal for pies (CSS @property
 *     animation; falls back gracefully where unsupported).
 *   - Self-healing migration that forces chart_animate ON for any chart
 *     that isn't pie-3D — fixes a class of saves with chart_animate
 *     incorrectly set to false by a previous panel bug.
 *   - Half-pie viewBox math + flex-align tuning so the legend's bottom
 *     sits flush with the pie's flat baseline (was floating above).
 *   - Bars/horizontal-bar both fully implemented with axis labels.
 *
 * Editor preview rendered a simpler version. Adopting frontend canonical
 * upgrades the preview to match published output — the same direction
 * we took for animator, booking, products, etc.
 *
 * The five inline `break;` statements (early-exits when row data is
 * empty or totals are zero, plus the case-end) became
 * `return ob_get_clean();` in the function form, since `break;` only
 * exits switch/loops and there's no enclosing switch inside a function.
 *
 * @sideeffect wp_add_inline_style('instinctor-page-inline', $css) for
 *             the per-chart sweep keyframes and @property declarations.
 */
function impulse_element_render_chart($el, array $settings, string $context): string {
    if (is_object($el)) $el = (array) $el;
    // Note: $context is currently unused — both contexts render the same
    // markup. Kept in the signature for future divergence (e.g. editor
    // overlays for hot-zone hit testing).
    unset($context);

    ob_start();
            $chEntries = $settings['chart_entries'] ?? [];
            if (is_string($chEntries)) $chEntries = json_decode($chEntries, true) ?: [];
            if (!is_array($chEntries)) $chEntries = [];
            // Normalise rows — drop blanks, coerce value to float.
            $chRows = [];
            foreach ($chEntries as $r) {
                if (!is_array($r)) continue;
                $lbl = trim((string)($r['label'] ?? ''));
                $val = floatval($r['value'] ?? 0);
                if ($lbl === '' && $val == 0) continue;
                $chRows[] = [
                    'label' => $lbl !== '' ? $lbl : 'Untitled',
                    'value' => max(0, $val),
                    'color' => trim((string)($r['color'] ?? '')),
                ];
            }
            if (empty($chRows)) return ob_get_clean();
            $chType        = $settings['chart_type']        ?? 'pie';     // pie | bar | line | radial
            $chStyle       = $settings['chart_style']       ?? 'flat';    // pie: flat | exploded | tilted_ring | half  |  bar: plain | rounded  |  line: plain | filled
            $chOrientation = $settings['chart_orientation'] ?? 'vertical';// bar only: vertical | horizontal
            $chInnerRadius = max(0, min(80, intval($settings['chart_inner_radius'] ?? 0))); // 0 = pie, >0 = donut
            $chLegendPos   = $settings['chart_legend_pos']  ?? 'right';   // right | bottom | off
            $chLabelFormat = $settings['chart_label_format']?? 'none';    // none | percent | value | label_percent
            $chTiltSens    = max(0, min(10, intval($settings['chart_tilt_sens'] ?? 0))); // tilted-ring only, default 0 (off)
            $chAnimateEnt  = !isset($settings['chart_animate']) || !empty($settings['chart_animate']);
            $chVolume      = !empty($settings['chart_volume']);                  // extruded 3D depth
            // Self-healing migration: any chart that isn't pie+3D gets animation
            // forced ON, overriding stored '0' values. This fixes charts whose
            // chart_animate was incorrectly saved as false by an earlier bug
            // that hid the Animate checkbox when 3D was toggled on bar/line/radial.
            // Users can still turn animation off — the checkbox is visible now,
            // and once they explicitly uncheck + save, the new value sticks.
            // Pie+3D stays force-off (its rendering breaks all animation approaches).
            if ($chType === 'pie' && $chVolume) {
                $chAnimateEnt = false;
            } else {
                $chAnimateEnt = true;
            }
            // NOTE: the "Group <2% as Other" auto-aggregation setting was removed —
            // manual data entry makes it pointless (users wouldn't add rows just to
            // have them merged away). If that kind of long-tail rollup is needed
            // later for imported data, it comes back as its own opt-in pass.
            // Helper: shift a hex colour toward black by $factor (0..1). Used for
            // the extruded "side wall" shade on volumetric slices/bars/rings.
            if (!function_exists('impulse_chart_shade')) {
                function impulse_chart_shade($hex, $factor = 0.7) {
                    $hex = ltrim((string)$hex, '#');
                    if (strlen($hex) === 3) $hex = $hex[0].$hex[0].$hex[1].$hex[1].$hex[2].$hex[2];
                    if (strlen($hex) !== 6) return '#555555';
                    $r = hexdec(substr($hex, 0, 2));
                    $g = hexdec(substr($hex, 2, 2));
                    $b = hexdec(substr($hex, 4, 2));
                    $r = max(0, min(255, intval($r * $factor)));
                    $g = max(0, min(255, intval($g * $factor)));
                    $b = max(0, min(255, intval($b * $factor)));
                    return sprintf('#%02X%02X%02X', $r, $g, $b);
                }
            }
            // Palette fallback — any row without a color gets a default pastel shade.
            $chFallbackPalette = ['#9CC3E3','#B8D9B0','#F3B48A','#CDB3D4','#2E5C8A','#6FB38F','#E87E77','#D9C38F'];
            foreach ($chRows as $i => &$r) {
                if ($r['color'] === '') $r['color'] = $chFallbackPalette[$i % count($chFallbackPalette)];
            }
            unset($r);
            $chId = 'chart-' . ($el['id'] ?? wp_rand(1000, 9999));
            $chLegendAlign = $chLegendPos === 'bottom' ? 'column' : 'row';
            // Half-pie: force the flex container's height to match the half-pie's
            // SVG height, then align-items:flex-end puts the legend's bottom
            // exactly at the pie's flat baseline. Without this, the flex
            // container can grow from entrance/min-height wrappers higher up
            // the tree, and flex-end aligns to THAT (lower) bottom, leaving
            // the legend floating above the pie baseline.
            $_chHalf = ($chType === 'pie' && $chStyle === 'half');
            $_chAlignItems = $_chHalf ? 'flex-end' : 'center';
            $_chWrapH = $_chHalf ? (';height:' . intval(360 / 2) . 'px') : '';
            $chWrapStyle = 'width:100%;position:relative;display:flex;gap:32px;align-items:' . $_chAlignItems . ';justify-content:center;flex-direction:' . $chLegendAlign . $_chWrapH;
            // Bars mode is a flat HTML list — drop the flex-row gap/align meant for SVG + legend pairing.
            if ($chType === 'bars') {
                $chWrapStyle = 'width:100%;position:relative;display:block';
            }
            /* Entrance keyframes inlined per chart instance. They're identical
               for every chart, but emitting them inside the chart's own HTML
               (instead of relying solely on the static impulse-chart.css link)
               means the keyframes survive an `innerHTML` swap, which is how
               demo and other dynamic mounts inject chart variants at
               runtime. Browser deduplicates identical @keyframes definitions
               so the cost is negligible. */
            if ($chAnimateEnt) {
                wp_add_inline_style('instinctor-page-inline', ''
                   . '@keyframes impulse-chart-fadein{from{opacity:0}to{opacity:1}}'
                   . '@keyframes impulse-chart-rise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}'
                   . '@keyframes impulse-chart-pop{from{opacity:0;transform:scale(0.3)}to{opacity:1;transform:scale(1)}}'
                   . '@keyframes impulse-chart-stroke-fade{to{stroke-opacity:1}}'
                   . '@keyframes impulse-chart-bars-grow{from{transform:scaleX(0)}to{transform:scaleX(1)}}'
                   . '@keyframes impulse-chart-line-draw{from{stroke-dashoffset:3000}to{stroke-dashoffset:0}}'
                   . '');
            }
            echo '<div id="' . esc_attr($chId) . '" class="impulse-chart impulse-chart-' . esc_attr($chType) . ' impulse-chart-' . esc_attr($chStyle) . '" style="' . esc_attr($chWrapStyle) . '" data-chart-type="' . esc_attr($chType) . '" data-chart-style="' . esc_attr($chStyle) . '" data-tilt-sens="' . esc_attr($chTiltSens) . '" data-animate="' . esc_attr(($chAnimateEnt ? '1' : '0')) . '">';
            // ── CHART SVG ───────────────────────────────────────────────────────
            if ($chType === 'pie') {
                $chIsHalf  = ($chStyle === 'half');
                $chSize    = 360;
                // Half-pie: SVG viewBox height = exactly half, so the bottom of the
                // SVG coincides with the pie's flat baseline. The wrapper's flex
                // align-items:flex-end then places the legend's bottom flush with
                // the flat baseline as the user expects.
                $chSvgH    = $chIsHalf ? intval($chSize / 2) : $chSize;
                $chCx      = $chSize / 2;
                $chCy      = $chIsHalf ? ($chSize / 2) : ($chSize / 2);
                $chExplode = ($chStyle === 'exploded') ? 10 : 0;
                // Shrink radius to accommodate the explode offset so slices stay
                // inside the SVG viewBox (and therefore inside the CSS mask
                // wrapper, which clips everything outside its box).
                $chR       = $chSize / 2 - 4 - $chExplode;
                $chIR      = ($chR * $chInnerRadius) / 100;
                $chTotal   = 0; foreach ($chRows as $r) $chTotal += $r['value'];
                if ($chTotal <= 0) { echo '</div>'; return ob_get_clean(); }
                // Half-pie sweeps 180° from -180° (left edge) clockwise to 0° (right edge).
                // Full pie sweeps 360° starting at -90° (top).
                $chSpan    = $chIsHalf ? 180 : 360;
                $chStart   = $chIsHalf ? -180 : -90;
                // Tilted ring wraps SVG in a perspective container; ring flattens rotate math.
                // Volume mode wraps the pie in a tilted perspective so the extruded
                // stack reads as depth rather than a stack of flat duplicates.
                $chVolumeTilt = $chVolume && $chStyle !== 'tilted_ring';
                // CSS conic-gradient mask for clockwise radial sweep. Uses @property
                // so the --impulse-sweep-{id} angle animates smoothly. Applied on a
                // wrapper OUTSIDE the 3D tilt container, so the mask operates in
                // 2D compositor space and works reliably in both 2D and 3D modes.
                if ($chAnimateEnt) {
                    /* Emit keyframes INLINE in the chart's own HTML, not via
                       wp_add_inline_style. The latter pushes CSS into the page's
                       head stylesheet handle, which has already been flushed by
                       the time a body-content block renders (and is never
                       flushed at all when this output is injected at runtime
                       via innerHTML, e.g. in demo). Inline <style> in
                       the chart's output is parsed whenever the markup is
                       inserted — so the sweep keyframes always exist when the
                       pie wrapper that consumes them is rendered. */
                    wp_add_inline_style('instinctor-page-inline', '@property --impulse-sweep-' . esc_attr($chId) . '{syntax:"<angle>";initial-value:0deg;inherits:false}'
                       . '@keyframes impulse-pie-sweep-' . esc_attr($chId) . '{to{--impulse-sweep-' . esc_attr($chId) . ':360deg}}'
                       . '@keyframes impulse-pie-stroke-' . esc_attr($chId) . '{to{stroke-opacity:1}}');
                    // from 0deg = start at 12 o'clock and sweep clockwise — CSS
                    // conic-gradient's default origin is already at the top.
                    $_maskGrad = 'conic-gradient(from 0deg at center,#000 0deg,#000 var(--impulse-sweep-' . esc_attr($chId) . '),transparent var(--impulse-sweep-' . esc_attr($chId) . '),transparent 360deg)';
                    echo '<div class="impulse-pie-sweep-wrap" style="display:inline-block;-webkit-mask:' . esc_attr($_maskGrad) . ';mask:' . esc_attr($_maskGrad) . ';animation:impulse-pie-sweep-' . esc_attr($chId) . ' 1.4s cubic-bezier(0.65,0,0.35,1) forwards">';
                }
                if ($chStyle === 'tilted_ring' || $chVolumeTilt) {
                    $_tiltAngle = $chVolumeTilt ? -55 : -38;
                    echo '<div class="impulse-chart-stage" style="perspective:1400px;perspective-origin:center 30%;display:flex;align-items:center;justify-content:center;width:' . esc_attr($chSize) . 'px;height:' . esc_attr(intval($chSize * 0.8)) . 'px">';
                    if ($chVolumeTilt) {
                        echo '<div class="impulse-chart-tilt" style="transform:rotateX(' . esc_attr((float) $_tiltAngle) . 'deg);will-change:transform;transition:transform 0.25s ease-in-out">';
                    } else {
                        echo '<div class="impulse-chart-tilt" style="transform-style:preserve-3d;transform:rotateX(' . esc_attr((float) $_tiltAngle) . 'deg) rotateY(0deg);will-change:transform;transition:transform 0.25s ease-in-out">';
                    }
                }
                /* Pie slices scale 1.03 on hover (set inline by the
                   chart's hover JS). With radius 176 and centre at
                   (180, 180) in the original 360x360 viewBox, the
                   1.03 expansion pushes the slice's outer edge to
                   ~361.3px, just past the SVG's painting clip box.
                   SVG's `overflow: visible` CSS property doesn't
                   reliably lift the internal viewBox painting clip
                   across browsers, so the slice clips.
                   Fix: enlarge the viewBox + CSS dimensions by 16px
                   on each side, using a negative viewBox origin so
                   the pie stays centred at (180, 180) at the same
                   physical display size. The extra room comfortably
                   accommodates the hover expansion. Non-pie types
                   (bar, line, radial) don't have this expansion so
                   they keep the original SVG sizing. */
                $_pieMargin = 16;
                $_svgW = $chSize + $_pieMargin * 2;
                $_svgH = $chSvgH + $_pieMargin * 2;
                echo '<svg class="impulse-chart-svg" viewBox="' . esc_attr((-$_pieMargin)) . ' ' . esc_attr((-$_pieMargin)) . ' ' . esc_attr($_svgW) . ' ' . esc_attr($_svgH) . '" width="' . esc_attr($_svgW) . '" height="' . esc_attr($_svgH) . '" style="overflow:visible;display:block">';
                // Pie reveal sequence:
                //   Phase 1 (0 → 1.4s):   clock-hand sweep reveals slice fills
                //                         clockwise via an SVG <mask>.
                //   Phase 2 (1.4 → 1.9s): white divider strokes fade in.
                //   Phase 3 (1.9 → 2.5s): 3D extrusion fades in beneath the slices.
                // No mask — too many rendering traps in 3D context. Per-slice
                // stagger below gives a clockwise reveal without masks.
                // Extrusion pass — emit N darker layers below the main slices to
                // build visible side-wall depth. Wrapped in a <g> that fades in
                // at Phase 3 so the stack doesn't appear before the slices.
                if ($chVolume && $chAnimateEnt) {
                    // Extrusion via SMIL — fades in after sweep + strokes (~1.9s).
                    echo '<g opacity="0"><animate attributeName="opacity" from="0" to="1" begin="1900ms" dur="500ms" fill="freeze"/>';
                } elseif ($chVolume) {
                    echo '<g>';
                }
                if ($chVolume) {
                    $chDepth = 18;
                    for ($layer = $chDepth; $layer >= 1; $layer--) {
                        $shadeFactor = 0.45 + (0.35 * ($chDepth - $layer) / $chDepth); // 0.45..0.80
                        $chAngleL = $chStart;
                        foreach ($chRows as $_extI => $rr) {
                            if ($rr['value'] <= 0) continue;
                            $sweepL = $chSpan * $rr['value'] / $chTotal;
                            $a0L = deg2rad($chAngleL);
                            $a1L = deg2rad($chAngleL + $sweepL);
                            $aMidL = deg2rad($chAngleL + $sweepL / 2);
                            $oxL = $chExplode * cos($aMidL);
                            $oyL = $chExplode * sin($aMidL);
                            $cxL = $chCx + $oxL;
                            $cyL = $chCy + $oyL + $layer; // push down by $layer px
                            $x1L = $cxL + $chR * cos($a0L); $y1L = $cyL + $chR * sin($a0L);
                            $x2L = $cxL + $chR * cos($a1L); $y2L = $cyL + $chR * sin($a1L);
                            $largeL = $sweepL > 180 ? 1 : 0;
                            if ($chIR > 0) {
                                $ix1L = $cxL + $chIR * cos($a1L); $iy1L = $cyL + $chIR * sin($a1L);
                                $ix2L = $cxL + $chIR * cos($a0L); $iy2L = $cyL + $chIR * sin($a0L);
                                $pathL = 'M ' . $x1L . ' ' . $y1L
                                       . ' A ' . $chR . ' ' . $chR . ' 0 ' . $largeL . ' 1 ' . $x2L . ' ' . $y2L
                                       . ' L ' . $ix1L . ' ' . $iy1L
                                       . ' A ' . $chIR . ' ' . $chIR . ' 0 ' . $largeL . ' 0 ' . $ix2L . ' ' . $iy2L . ' Z';
                            } else {
                                $pathL = 'M ' . $cxL . ' ' . $cyL
                                       . ' L ' . $x1L . ' ' . $y1L
                                       . ' A ' . $chR . ' ' . $chR . ' 0 ' . $largeL . ' 1 ' . $x2L . ' ' . $y2L . ' Z';
                            }
                            // No individual animation on extrusion paths — the wrapping
                            // <g> fades the entire stack in together at Phase 3.
                            echo '<path class="impulse-chart-extrusion" data-slice-index="' . esc_attr($_extI) . '" d="' . esc_attr($pathL) . '" fill="' . esc_attr(impulse_chart_shade($rr['color'], $shadeFactor)) . '" stroke="none" style="pointer-events:none"></path>';
                            $chAngleL += $sweepL;
                        }
                    }
                    echo '</g>'; // close extrusion group
                }
                echo '<g>';
                $chAngle = $chStart;
                foreach ($chRows as $i => $r) {
                    if ($r['value'] <= 0) continue;
                    $sweep = $chSpan * $r['value'] / $chTotal;
                    $a0 = deg2rad($chAngle);
                    $a1 = deg2rad($chAngle + $sweep);
                    $aMid = deg2rad($chAngle + $sweep / 2);
                    $ox = $chExplode * cos($aMid);
                    $oy = $chExplode * sin($aMid);
                    $cx = $chCx + $ox;
                    $cy = $chCy + $oy;
                    $x1 = $cx + $chR * cos($a0); $y1 = $cy + $chR * sin($a0);
                    $x2 = $cx + $chR * cos($a1); $y2 = $cy + $chR * sin($a1);
                    $large = $sweep > 180 ? 1 : 0;
                    if ($chIR > 0) {
                        $ix1 = $cx + $chIR * cos($a1); $iy1 = $cy + $chIR * sin($a1);
                        $ix2 = $cx + $chIR * cos($a0); $iy2 = $cy + $chIR * sin($a0);
                        $path = 'M ' . $x1 . ' ' . $y1
                              . ' A ' . $chR . ' ' . $chR . ' 0 ' . $large . ' 1 ' . $x2 . ' ' . $y2
                              . ' L ' . $ix1 . ' ' . $iy1
                              . ' A ' . $chIR . ' ' . $chIR . ' 0 ' . $large . ' 0 ' . $ix2 . ' ' . $iy2
                              . ' Z';
                    } else {
                        $path = 'M ' . $cx . ' ' . $cy
                              . ' L ' . $x1 . ' ' . $y1
                              . ' A ' . $chR . ' ' . $chR . ' 0 ' . $large . ' 1 ' . $x2 . ' ' . $y2
                              . ' Z';
                    }
                    $pct = $chTotal > 0 ? ($r['value'] / $chTotal * 100) : 0;
                    // Wrap each slice in its own <g> — SMIL opacity animation on
                    // <g> works reliably, on <path> it was silently failing in
                    // the editor iframe context (verified via DIAG).
                    $_angleFromStart = $chAngle - $chStart;
                    $_sliceDelay    = round(($_angleFromStart / $chSpan) * 900);
                    $_sliceDur      = max(120, round(($sweep / $chSpan) * 900));
                    if ($chAnimateEnt) {
                        // CSS conic-gradient mask on wrapper handles the clockwise
                        // reveal of fills. Slice stroke fades in at 1.4s (after sweep).
                        echo '<path class="impulse-chart-slice" d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" stroke-opacity="0" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="transition:transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),filter 0.25s ease-in-out;transform-origin:' . esc_attr($chCx) . 'px ' . esc_attr($chCy) . 'px;cursor:default">'
                           . '<animate attributeName="stroke-opacity" from="0" to="1" begin="1400ms" dur="500ms" fill="freeze"/>'
                           . '</path>';
                    } else {
                        echo '<path class="impulse-chart-slice" d="' . esc_attr($path) . '" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="transition:transform 0.25s cubic-bezier(0.4, 0, 0.2, 1),filter 0.25s ease-in-out;transform-origin:' . esc_attr($chCx) . 'px ' . esc_attr($chCy) . 'px;cursor:default"></path>';
                    }
                    if ($chLabelFormat !== 'none' && $sweep >= 18) {
                        $lR = $chIR > 0 ? ($chR + $chIR) / 2 : $chR * 0.62;
                        $lx = $cx + $lR * cos($aMid);
                        $ly = $cy + $lR * sin($aMid);
                        $labelText = '';
                        if ($chLabelFormat === 'percent')           $labelText = number_format($pct, 0) . '%';
                        elseif ($chLabelFormat === 'value')         $labelText = (string)$r['value'];
                        elseif ($chLabelFormat === 'label_percent') $labelText = $r['label'] . ' ' . number_format($pct, 0) . '%';
                        if ($labelText !== '') {
                            echo '<text x="' . esc_attr($lx) . '" y="' . esc_attr($ly) . '" text-anchor="middle" dominant-baseline="middle" fill="#fff" font-size="12" font-weight="600" style="pointer-events:none;paint-order:stroke;stroke:rgba(0,0,0,0.35);stroke-width:2">' . esc_html($labelText) . '</text>';
                        }
                    }
                    $chAngle += $sweep;
                }
                echo '</g>'; // close slices group
                echo '</svg>';
                if ($chStyle === 'tilted_ring' || ($chVolume && $chStyle !== 'tilted_ring')) {
                    echo '</div></div>';
                }
                if ($chAnimateEnt) { echo '</div>'; } // close .impulse-pie-sweep-wrap
            } elseif ($chType === 'bar') {
                $chIsHoriz   = ($chOrientation === 'horizontal');
                $chMax = 0; foreach ($chRows as $r) if ($r['value'] > $chMax) $chMax = $r['value'];
                if ($chMax <= 0) { echo '</div>'; return ob_get_clean(); }
                $chN = count($chRows);
                $chGap = 8;
                if (!$chIsHoriz) {
                    // Vertical bars. Axis labels sit below the baseline with a clear
                    // gap so the label row reads as its own band, not glued to the bars.
                    $chW = 480; $chPadX = 24; $chPadY = 24; $chLabelH = 18; $chLabelGap = 16;
                    $chBarTop = $chPadY;
                    $chBarMaxH = 200;
                    $chBarBottom = $chBarTop + $chBarMaxH;
                    $chH = $chBarBottom + $chLabelGap + $chLabelH;
                    $chBarW = ($chW - $chPadX * 2 - $chGap * max(0, $chN - 1)) / max(1, $chN);
                    $chRound = ($chStyle === 'rounded') ? min(6, $chBarW / 4) : 0;
                    echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                    // Volume offsets for the 3D box look — isometric projection.
                    $chVd = $chVolume ? 8 : 0;  // depth offset in px (right + up)
                    foreach ($chRows as $i => $r) {
                        $h = $chBarMaxH * ($r['value'] / $chMax);
                        $x = $chPadX + $i * ($chBarW + $chGap);
                        $y = $chBarBottom - $h;
                        if ($chVolume) {
                            $sideColor = impulse_chart_shade($r['color'], 0.55);
                            $topColor  = impulse_chart_shade($r['color'], 1.20); // brighter
                            // Side face: parallelogram from top-right of front face to +depth offset.
                            $sideX1 = $x + $chBarW;        $sideY1 = $y;
                            $sideX2 = $sideX1 + $chVd;     $sideY2 = $y - $chVd;
                            $sideX3 = $sideX2;             $sideY3 = $y - $chVd + $h;
                            $sideX4 = $sideX1;             $sideY4 = $y + $h;
                            echo '<polygon points="' . esc_attr($sideX1) . ',' . esc_attr($sideY1) . ' ' . esc_attr($sideX2) . ',' . esc_attr($sideY2) . ' ' . esc_attr($sideX3) . ',' . esc_attr($sideY3) . ' ' . esc_attr($sideX4) . ',' . esc_attr($sideY4) . '" fill="' . esc_attr($sideColor) . '" stroke="none"></polygon>';
                            // Top face: parallelogram from top-left-front to top-right-back.
                            $topX1 = $x;                   $topY1 = $y;
                            $topX2 = $x + $chVd;           $topY2 = $y - $chVd;
                            $topX3 = $x + $chBarW + $chVd; $topY3 = $y - $chVd;
                            $topX4 = $x + $chBarW;         $topY4 = $y;
                            echo '<polygon points="' . esc_attr($topX1) . ',' . esc_attr($topY1) . ' ' . esc_attr($topX2) . ',' . esc_attr($topY2) . ' ' . esc_attr($topX3) . ',' . esc_attr($topY3) . ' ' . esc_attr($topX4) . ',' . esc_attr($topY4) . '" fill="' . esc_attr($topColor) . '" stroke="none"></polygon>';
                        }
                        // Bar entry: fade-only when Volume 3D is on (rect would
                        // otherwise translate through the side/top extrusion polygons).
                        $_barKf = $chVolume ? 'impulse-chart-fadein' : 'impulse-chart-rise';
                        $_barAnim = $chAnimateEnt ? 'animation:' . $_barKf . ' 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . ($i * 120) . 'ms both;' : '';
                        echo '<rect class="impulse-chart-slice" x="' . esc_attr($x) . '" y="' . esc_attr($y) . '" width="' . esc_attr($chBarW) . '" height="' . esc_attr($h) . '" rx="' . esc_attr($chRound) . '" ry="' . esc_attr($chRound) . '" fill="' . esc_attr($r['color']) . '" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="' . esc_attr($_barAnim) . 'transition:filter 0.2s ease-in-out;cursor:default"></rect>';
                        if ($chLabelFormat !== 'none') {
                            $chLblTxt = '';
                            if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                                $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value'];
                                $chLblTxt = $pctSum > 0 ? number_format($r['value'] / $pctSum * 100, 0) . '%' : '';
                            } elseif ($chLabelFormat === 'value') {
                                $chLblTxt = (string)$r['value'];
                            }
                            if ($chLabelFormat === 'label_percent' && $chLblTxt !== '') $chLblTxt = $r['label'] . ' ' . $chLblTxt;
                            if ($chLblTxt !== '') {
                                echo '<text x="' . esc_attr(($x + $chBarW / 2)) . '" y="' . esc_attr(($y - 6 - $chVd)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . esc_html($chLblTxt) . '</text>';
                            }
                        }
                        echo '<text x="' . esc_attr(($x + $chBarW / 2)) . '" y="' . esc_attr(($chBarBottom + $chLabelGap + 12)) . '" text-anchor="middle" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 12, '…')) . '</text>';
                    }
                    echo '</svg>';
                } else {
                    // Horizontal bars — each row a bar lying on its side, labels on left.
                    $chW = 480; $chLabelW = 90; $chPadX = 16; $chPadY = 16;
                    $chBarH = (280 - $chPadY * 2 - $chGap * max(0, $chN - 1)) / max(1, $chN);
                    $chBarH = max(16, $chBarH);
                    $chH = intval($chPadY * 2 + $chBarH * $chN + $chGap * max(0, $chN - 1));
                    $chBarLeft = $chPadX + $chLabelW + 8;
                    $chBarRight = $chW - $chPadX;
                    $chBarMaxW = $chBarRight - $chBarLeft;
                    $chRound = ($chStyle === 'rounded') ? min(6, $chBarH / 4) : 0;
                    echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                    $chVd = $chVolume ? 6 : 0;
                    foreach ($chRows as $i => $r) {
                        $w = $chBarMaxW * ($r['value'] / $chMax);
                        $y = $chPadY + $i * ($chBarH + $chGap);
                        echo '<text x="' . esc_attr(($chPadX + $chLabelW)) . '" y="' . esc_attr(($y + $chBarH / 2 + 4)) . '" text-anchor="end" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 18, '…')) . '</text>';
                        if ($chVolume) {
                            $sideColor = impulse_chart_shade($r['color'], 0.55);
                            $topColor  = impulse_chart_shade($r['color'], 1.20);
                            // Right side face (end-cap)
                            $hx = $chBarLeft + $w;
                            echo '<polygon points="' . esc_attr($hx) . ',' . esc_attr($y) . ' ' . esc_attr(($hx + $chVd)) . ',' . esc_attr(($y - $chVd)) . ' ' . esc_attr(($hx + $chVd)) . ',' . esc_attr(($y + $chBarH - $chVd)) . ' ' . esc_attr($hx) . ',' . esc_attr(($y + $chBarH)) . '" fill="' . esc_attr($sideColor) . '" stroke="none"></polygon>';
                            // Top face
                            echo '<polygon points="' . esc_attr($chBarLeft) . ',' . esc_attr($y) . ' ' . esc_attr(($chBarLeft + $chVd)) . ',' . esc_attr(($y - $chVd)) . ' ' . esc_attr(($hx + $chVd)) . ',' . esc_attr(($y - $chVd)) . ' ' . esc_attr($hx) . ',' . esc_attr($y) . '" fill="' . esc_attr($topColor) . '" stroke="none"></polygon>';
                        }
                        $_barKfH = $chVolume ? 'impulse-chart-fadein' : 'impulse-chart-rise';
                        $_barAnimH = $chAnimateEnt ? 'animation:' . $_barKfH . ' 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . ($i * 120) . 'ms both;' : '';
                        echo '<rect class="impulse-chart-slice" x="' . esc_attr($chBarLeft) . '" y="' . esc_attr($y) . '" width="' . esc_attr($w) . '" height="' . esc_attr($chBarH) . '" rx="' . esc_attr($chRound) . '" ry="' . esc_attr($chRound) . '" fill="' . esc_attr($r['color']) . '" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="' . esc_attr($_barAnimH) . 'transition:filter 0.2s ease-in-out;cursor:default"></rect>';
                        if ($chLabelFormat !== 'none') {
                            $chLblTxt = '';
                            if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                                $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value'];
                                $chLblTxt = $pctSum > 0 ? number_format($r['value'] / $pctSum * 100, 0) . '%' : '';
                            } elseif ($chLabelFormat === 'value') {
                                $chLblTxt = (string)$r['value'];
                            }
                            if ($chLblTxt !== '') {
                                echo '<text x="' . esc_attr(($chBarLeft + $w + 6)) . '" y="' . esc_attr(($y + $chBarH / 2 + 4)) . '" fill="#555" font-size="11" font-weight="600">' . esc_html($chLblTxt) . '</text>';
                            }
                        }
                    }
                    echo '</svg>';
                }
            } elseif ($chType === 'line') {
                // Line / area chart — points positioned by row order; y driven by value.
                // Style: plain (stroke only) | filled (area under line).
                $chW = 480; $chH = 260; $chPadX = 28; $chPadY = 24; $chLabelH = 18;
                $chMax = 0; foreach ($chRows as $r) if ($r['value'] > $chMax) $chMax = $r['value'];
                if ($chMax <= 0) { echo '</div>'; return ob_get_clean(); }
                $chN = count($chRows);
                $chLineTop    = $chPadY;
                $chLineBottom = $chH - $chPadY - $chLabelH;
                $chLineHeight = $chLineBottom - $chLineTop;
                $chLineLeft   = $chPadX;
                $chLineRight  = $chW - $chPadX;
                $chLineWidth  = $chLineRight - $chLineLeft;
                $chLineColor  = $chRows[0]['color'] ?: '#9CC3E3';
                $points = [];
                foreach ($chRows as $i => $r) {
                    $x = $chLineLeft + ($chN > 1 ? ($chLineWidth * $i / ($chN - 1)) : $chLineWidth / 2);
                    $y = $chLineBottom - $chLineHeight * ($r['value'] / $chMax);
                    $points[] = ['x' => $x, 'y' => $y, 'row' => $r, 'i' => $i];
                }
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chW) . ' ' . esc_attr($chH) . '" width="' . esc_attr($chW) . '" height="' . esc_attr($chH) . '" style="overflow:visible;display:block">';
                // Volume mode — define a vertical gradient (brighter top → darker bottom)
                // for the line stroke, plus a soft drop-shadow filter. Gives a ribbon feel.
                if ($chVolume) {
                    $_darkLine  = impulse_chart_shade($chLineColor, 0.55);
                    $_lightLine = impulse_chart_shade($chLineColor, 1.15);
                    $_gradId = 'impulse-chart-line-grad-' . esc_attr($chId);
                    $_shadowId = 'impulse-chart-line-shadow-' . esc_attr($chId);
                    echo '<defs>';
                    echo '<linearGradient id="' . esc_attr($_gradId) . '" x1="0" x2="0" y1="0" y2="1">';
                    echo '<stop offset="0%" stop-color="' . esc_attr($_lightLine) . '"/>';
                    echo '<stop offset="100%" stop-color="' . esc_attr($_darkLine) . '"/>';
                    echo '</linearGradient>';
                    echo '<filter id="' . esc_attr($_shadowId) . '" x="-20%" y="-20%" width="140%" height="140%">';
                    echo '<feGaussianBlur in="SourceAlpha" stdDeviation="3"/>';
                    echo '<feOffset dx="0" dy="4" result="off"/>';
                    echo '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.35"/></feComponentTransfer>';
                    echo '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
                    echo '</filter>';
                    echo '</defs>';
                }
                // Build polyline path
                $linePath = '';
                foreach ($points as $k => $p) {
                    $linePath .= ($k === 0 ? 'M ' : ' L ') . round($p['x'], 1) . ' ' . round($p['y'], 1);
                }
                // Line entry animations — path draws itself, area fades in after
                // draw completes, dots pop one at a time after that.
                $_lineDrawAnim = $chAnimateEnt ? 'animation:impulse-chart-line-draw 3500ms cubic-bezier(0.4, 0, 0.2, 1) both;' : '';
                $_lineAreaAnim = $chAnimateEnt ? 'animation:impulse-chart-fadein 900ms ease-in-out 3000ms both;' : '';
                if ($chStyle === 'filled') {
                    $areaPath = $linePath
                              . ' L ' . round(end($points)['x'], 1) . ' ' . $chLineBottom
                              . ' L ' . round($points[0]['x'], 1) . ' ' . $chLineBottom
                              . ' Z';
                    if ($chVolume) {
                        // Area uses its own vertical gradient fading to transparent.
                        $_areaGradId = 'impulse-chart-area-grad-' . esc_attr($chId);
                        echo '<defs><linearGradient id="' . esc_attr($_areaGradId) . '" x1="0" x2="0" y1="0" y2="1">'
                           . '<stop offset="0%" stop-color="' . esc_attr($chLineColor) . '" stop-opacity="0.55"/>'
                           . '<stop offset="100%" stop-color="' . esc_attr($chLineColor) . '" stop-opacity="0"/>'
                           . '</linearGradient></defs>';
                        echo '<path class="impulse-chart-line-area" d="' . esc_attr($areaPath) . '" fill="url(#' . esc_attr($_areaGradId) . ')" style="' . esc_attr($_lineAreaAnim) . '"></path>';
                    } else {
                        echo '<path class="impulse-chart-line-area" d="' . esc_attr($areaPath) . '" fill="' . esc_attr($chLineColor) . '" fill-opacity="0.18" style="' . esc_attr($_lineAreaAnim) . '"></path>';
                    }
                }
                if ($chVolume) {
                    // Thicker ribbon stroke, gradient fill, drop shadow.
                    echo '<path class="impulse-chart-line-path" d="' . esc_attr($linePath) . '" fill="none" stroke="url(#' . esc_attr($_gradId) . ')" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" filter="url(#' . esc_attr($_shadowId) . ')" style="' . esc_attr($_lineDrawAnim) . '"></path>';
                } else {
                    echo '<path class="impulse-chart-line-path" d="' . esc_attr($linePath) . '" fill="none" stroke="' . esc_attr($chLineColor) . '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="' . esc_attr($_lineDrawAnim) . '"></path>';
                }
                // Point markers + axis labels
                foreach ($points as $p) {
                    $r = $p['row'];
                    $pct = 0; $pctSum = 0; foreach ($chRows as $_r2) $pctSum += $_r2['value']; if ($pctSum > 0) $pct = $r['value'] / $pctSum * 100;
                    // Dot pops in after the line finishes drawing (1100ms baseline +
                    // 90ms per dot). Overshoot easing gives a bouncy feel.
                    $_dotAnim = $chAnimateEnt ? 'animation:impulse-chart-pop 380ms cubic-bezier(0.4, 0, 0.2, 1) ' . (3200 + $p['i'] * 180) . 'ms both;transform-origin:' . round($p['x'], 1) . 'px ' . round($p['y'], 1) . 'px;' : '';
                    echo '<circle class="impulse-chart-slice" cx="' . esc_attr(round($p['x'], 1)) . '" cy="' . esc_attr(round($p['y'], 1)) . '" r="5" fill="' . esc_attr($r['color']) . '" stroke="#fff" stroke-width="2" data-slice-index="' . esc_attr($p['i']) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" data-pct="' . number_format($pct, 1) . '" style="' . esc_attr($_dotAnim) . 'transition:r 0.2s ease-in-out, filter 0.2s ease-in-out;cursor:default"></circle>';
                    if ($chLabelFormat === 'value') {
                        echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . esc_attr(round($p['y'] - 10, 1)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . esc_html((string)$r['value']) . '</text>';
                    } elseif ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                        echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . esc_attr(round($p['y'] - 10, 1)) . '" text-anchor="middle" fill="#555" font-size="11" font-weight="600">' . number_format($pct, 0) . '%</text>';
                    }
                    echo '<text x="' . esc_attr(round($p['x'], 1)) . '" y="' . esc_attr(($chH - $chPadY / 2)) . '" text-anchor="middle" fill="#888" font-size="11">' . esc_html(mb_strimwidth($r['label'], 0, 12, '…')) . '</text>';
                }
                echo '</svg>';
            } elseif ($chType === 'bars') {
                // Compact horizontal bar list. Each row: label (left) → faded track
                // with coloured fill → optional value (right). Values are taken as
                // percentages (0-100) directly — no normalisation against the row
                // max — so the bar widths read as literal "this skill at 80%".
                echo '<div class="impulse-chart-bars-list" style="width:100%;display:flex;flex-direction:column;gap:14px;padding:4px 0">';
                foreach ($chRows as $i => $r) {
                    $pct     = max(0, min(100, $r['value']));
                    $valTxt  = '';
                    if ($chLabelFormat === 'percent' || $chLabelFormat === 'label_percent') {
                        $valTxt = number_format($pct, 0) . '%';
                    } elseif ($chLabelFormat === 'value') {
                        $valTxt = (string)$r['value'];
                    }
                    $rowDelay = $chAnimateEnt ? ($i * 120) : 0;
                    $fillAnim = $chAnimateEnt
                        ? 'animation:impulse-chart-bars-grow 800ms cubic-bezier(0.4, 0, 0.2, 1) ' . $rowDelay . 'ms both;transform-origin:left center;'
                        : '';
                    echo '<div class="impulse-chart-bars-row" style="display:flex;align-items:center;gap:14px">';
                    echo '<div class="impulse-chart-bars-label" style="flex:0 0 110px;font-size:14px;font-weight:600;color:inherit;line-height:1.2;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html($r['label']) . '</div>';
                    echo '<div class="impulse-chart-bars-track" style="flex:1 1 auto;position:relative;height:10px;background:rgba(0,0,0,0.1);overflow:hidden">';
                    echo '<div class="impulse-chart-slice" data-slice-index="' . esc_attr($i) . '" data-label="' . esc_attr($r['label']) . '" data-value="' . esc_attr((string)$r['value']) . '" style="position:absolute;left:0;top:0;bottom:0;width:' . esc_attr($pct) . '%;background:' . esc_attr($r['color']) . ';' . esc_attr($fillAnim) . '"></div>';
                    echo '</div>';
                    if ($valTxt !== '') {
                        echo '<div class="impulse-chart-bars-value" style="flex:0 0 auto;font-size:12px;color:inherit;opacity:0.6;font-weight:600;min-width:34px;text-align:right">' . esc_html($valTxt) . '</div>';
                    }
                    echo '</div>';
                }
                echo '</div>';
            } elseif ($chType === 'radial') {
                // Radial progress — single value (first entry), drawn as a filled arc
                // over a muted background ring. Value treated as percent (0-100).
                // User-configurable diameter + stroke thickness so dashboards and
                // hero KPIs can share one block type with different visual weights.
                $chSize = 280;
                $chCx = $chSize / 2; $chCy = $chSize / 2;
                $chRingDiameter = max(0, min(90, intval($settings['chart_ring_diameter'] ?? 70)));
                $chStrokeW = max(4, min(40, intval($settings['chart_ring_thickness'] ?? 18)));
                // Ring radius grows with diameter %: 90% = nearly touches the SVG edge.
                $chR = ($chSize / 2 - $chStrokeW / 2 - 6) * ($chRingDiameter / 90);
                if ($chR < $chStrokeW) $chR = $chStrokeW; // sanity clamp
                $first = $chRows[0];
                $val = max(0, min(100, floatval($first['value'])));
                // Full circle circumference; use stroke-dasharray to show progress.
                $circ = 2 * M_PI * $chR;
                $filled = $circ * ($val / 100);
                $gap = $circ - $filled;
                echo '<svg class="impulse-chart-svg" viewBox="0 0 ' . esc_attr($chSize) . ' ' . esc_attr($chSize) . '" width="' . esc_attr($chSize) . '" height="' . esc_attr($chSize) . '" style="overflow:visible;display:block">';
                // Volume mode: tube appearance — radial gradient across the ring
                // band (inner edge brighter, outer edge darker) via SVG pattern,
                // plus a soft drop shadow giving the ring lift off the background.
                if ($chVolume) {
                    $_ringDark  = impulse_chart_shade($first['color'], 0.55);
                    $_ringLight = impulse_chart_shade($first['color'], 1.18);
                    $_rGrad = 'impulse-chart-radial-grad-' . esc_attr($chId);
                    $_rShadow = 'impulse-chart-radial-shadow-' . esc_attr($chId);
                    // Linear gradient works as a band-cross because we rotate the
                    // stroke path — use one aligned to the ring radius via rotateY.
                    echo '<defs>';
                    echo '<radialGradient id="' . esc_attr($_rGrad) . '" cx="0.5" cy="0.5" r="0.5">'
                       . '<stop offset="' . esc_attr(round(max(0, ($chR - $chStrokeW / 2) / $chR) * 100)) . '%" stop-color="' . esc_attr($_ringLight) . '"/>'
                       . '<stop offset="' . esc_attr(round(min(100, ($chR + $chStrokeW / 2) / $chR) * 100)) . '%" stop-color="' . esc_attr($_ringDark) . '"/>'
                       . '</radialGradient>';
                    echo '<filter id="' . esc_attr($_rShadow) . '" x="-20%" y="-20%" width="140%" height="140%">';
                    echo '<feGaussianBlur in="SourceAlpha" stdDeviation="4"/>';
                    echo '<feOffset dx="0" dy="6" result="off"/>';
                    echo '<feComponentTransfer result="shadow"><feFuncA type="linear" slope="0.28"/></feComponentTransfer>';
                    echo '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
                    echo '</filter>';
                    echo '</defs>';
                }
                // Background ring
                echo '<circle cx="' . esc_attr($chCx) . '" cy="' . esc_attr($chCy) . '" r="' . esc_attr($chR) . '" fill="none" stroke="rgba(0,0,0,0.08)" stroke-width="' . esc_attr($chStrokeW) . '"></circle>';
                // Progress ring — rotated -90° so it starts at the top.
                // When entry animation is on, the ring is rendered empty (0 filled)
                // and the inline JS below sweeps it: 0 → full circle → target value.
                // data-circ / data-filled / data-gap carry the target values for the
                // JS to animate to. The CSS transition on stroke-dasharray (1.2s for
                // the sweep, 0.5s for the settle) is set in JS so non-animated charts
                // don't animate on later hover updates.
                $_dasharrayInit = $chAnimateEnt
                    ? '0 ' . round($circ, 2)
                    : round($filled, 2) . ' ' . round($gap, 2);
                // WAAPI animation attached below in the chart's <script> block
                // drives the dasharray growth. Ring stays at 12 o'clock via
                // the static SVG transform attr.
                $_ringCommonAttrs = 'cx="' . $chCx . '" cy="' . $chCy . '" r="' . $chR . '" fill="none" stroke-width="' . $chStrokeW . '" stroke-linecap="round" stroke-dasharray="' . $_dasharrayInit . '" transform="rotate(-90 ' . $chCx . ' ' . $chCy . ')"'
                    . ' data-slice-index="0" data-label="' . esc_attr($first['label']) . '" data-value="' . esc_attr((string)$first['value']) . '" data-pct="' . number_format($val, 1) . '"'
                    . ' data-circ="' . round($circ, 2) . '" data-filled="' . round($filled, 2) . '" data-gap="' . round($gap, 2) . '"';
                if ($chVolume) {
                    echo '<circle class="impulse-chart-slice" ' . esc_attr($_ringCommonAttrs) . ' stroke="url(#' . esc_attr($_rGrad) . ')" filter="url(#' . esc_attr($_rShadow) . ')" style="cursor:default"></circle>';
                } else {
                    echo '<circle class="impulse-chart-slice" ' . esc_attr($_ringCommonAttrs) . ' stroke="' . esc_attr($first['color']) . '" style="cursor:default"></circle>';
                }
                // Centre text — big value + small label beneath. Value text
                // inherits the ring's palette colour (first entry) so the
                // percentage reads as part of the palette rather than as a
                // fixed dark-grey baked in. Stays legible against light AND
                // dark backgrounds because it tracks the same hue as the ring.
                echo '<text x="' . esc_attr($chCx) . '" y="' . esc_attr($chCy) . '" text-anchor="middle" dominant-baseline="middle" fill="' . esc_attr($first['color']) . '" font-size="52" font-weight="700" style="pointer-events:none">' . number_format($val, 0) . '%</text>';
                if (!empty($first['label'])) {
                    echo '<text x="' . esc_attr($chCx) . '" y="' . esc_attr(($chCy + 36)) . '" text-anchor="middle" fill="#888" font-size="13" style="pointer-events:none">' . esc_html($first['label']) . '</text>';
                }
                echo '</svg>';
            }
            // ── LEGEND ──────────────────────────────────────────────────────────
            // Bar charts always label every bar on its axis, so a legend is a second
            // copy of the same information — skip it.
            // Radial charts show the category name under the big centre percentage,
            // so a legend would show "Category A / Category A" (centre + legend) —
            // skip it too. Legend is only useful for pie and line.
            if ($chLegendPos !== 'off' && $chType !== 'bar' && $chType !== 'radial' && $chType !== 'bars') {
                $legendRows = $chRows;
                $legendStyle = 'display:flex;flex-direction:column;gap:8px;min-width:0;max-width:260px';
                echo '<div class="impulse-chart-legend" style="' . esc_attr($legendStyle) . '">';
                foreach ($legendRows as $i => $r) {
                    echo '<div class="impulse-chart-legend-item" style="display:flex;align-items:center;gap:8px;font-size:12px;color:inherit" data-slice-index="' . esc_attr($i) . '">';
                    echo '<span style="width:16px;height:16px;border-radius:3px;background:' . esc_attr($r['color']) . ';flex-shrink:0;display:inline-block"></span>';
                    echo '<span style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis">' . esc_html($r['label']) . '</span>';
                    echo '</div>';
                }
                echo '</div>';
            }
            // Singleton-enqueue the static chart stylesheet on first chart
            // render per page. All keyframes (breathe / sway / radial-pulse /
            // fadein / rise / pop / stroke-fade / bars-grow / line-draw),
            // the mobile-stacking @media rule, and the idle-animation
            // gates live in assets/css/impulse-chart.css — they're
            // identical for every chart on every page, so emitting them
            // inline per-instance via wp_add_inline_style was wasteful.
            // The static CSS uses class + data-attr selectors:
            //   .impulse-chart:not([data-chart-type="bar"]) .impulse-chart-svg
            //   .impulse-chart-tilted_ring[data-tilt-sens="0"] .impulse-chart-tilt
            // so the per-chart conditionals (chart type, tilt-sens) that
            // used to live here translate into attribute checks the CSS
            // engine handles for free. The renderer already emits both
            // attributes on the wrapper div above.
            if (empty($GLOBALS['impulse_chart_css_emitted'])) {
                $GLOBALS['impulse_chart_css_emitted'] = true;
                \Instinctor\Core\Assets::enqueue('instinctor-chart');
            }
            echo '</div>'; // .impulse-chart

            // Chart interactions — hover highlight, tooltip, tilt for tilted_ring.
            // Entry animations are CSS/SMIL for most chart types.
            // Radial is the exception: WAAPI el.animate() drives its stroke-dasharray
            // — proven working approach from 2.1.135, restored after several
            // attempts at CSS/SMIL variants that failed to render in the editor iframe.
            ob_start();
            echo '(function(){var root=document.getElementById(' . wp_json_encode($chId) . ');if(!root)return;';
            echo 'if(root.dataset.animate==="1"&&root.dataset.chartType==="radial"&&typeof Element.prototype.animate==="function"&&!matchMedia("(prefers-reduced-motion:reduce)").matches){';
            echo '  var _radEl=root.querySelector("circle.impulse-chart-slice");';
            echo '  if(_radEl){';
            echo '    var _c=parseFloat(_radEl.getAttribute("data-circ"))||0;';
            echo '    var _f=parseFloat(_radEl.getAttribute("data-filled"))||0;';
            echo '    var _g=parseFloat(_radEl.getAttribute("data-gap"))||0;';
            echo '    if(_c>0){';
            // Arc grows directly from empty to its target value — no full-circle
            // overshoot, no bounce. Clean ease-in-out from 0 to filled.
            echo '      _radEl.animate([';
            echo '        {strokeDasharray:"0 "+_c},';
            echo '        {strokeDasharray:_f+" "+_g}';
            echo '      ],{duration:1200,easing:"cubic-bezier(0.4, 0, 0.2, 1)",fill:"both"});';
            echo '    }';
            echo '  }';
            echo '}';
            // Tooltip
            echo 'var tip=document.createElement("div");tip.style.cssText="position:fixed;pointer-events:none;background:rgba(29,35,39,0.92);color:#fff;padding:6px 10px;border-radius:4px;font-size:12px;font-weight:500;z-index:10000;opacity:0;transition:opacity 0.15s;white-space:nowrap;box-shadow:0 4px 16px rgba(0,0,0,0.25)";document.body.appendChild(tip);';
            echo 'function showTip(e,label,value,pct){tip.innerHTML=label+"<span style=\"opacity:0.7;margin-left:6px\">"+(pct?(pct+"%"):value)+"</span>";tip.style.left=(e.clientX+12)+"px";tip.style.top=(e.clientY-24)+"px";tip.style.opacity="1";}';
            echo 'function hideTip(){tip.style.opacity="0";}';
            echo 'root.querySelectorAll(".impulse-chart-slice").forEach(function(s){';
            echo '  s.addEventListener("mousemove",function(e){showTip(e,s.getAttribute("data-label"),s.getAttribute("data-value"),s.getAttribute("data-pct"));});';
            echo '  s.addEventListener("mouseleave",hideTip);';
            // Pie hover lift — slice scales up slightly
            echo '  s.addEventListener("mouseenter",function(){s.style.filter="brightness(1.1) drop-shadow(0 2px 8px rgba(0,0,0,0.15))";if(root.dataset.chartType==="pie")s.style.transform="scale(1.03)";});';
            echo '  s.addEventListener("mouseleave",function(){s.style.filter="";s.style.transform="";});';
            echo '});';
            // Legend highlight parity — hovering a legend item highlights matching slice
            echo 'root.querySelectorAll(".impulse-chart-legend-item").forEach(function(li){';
            echo '  li.addEventListener("mouseenter",function(){var i=li.getAttribute("data-slice-index");var sl=root.querySelector(".impulse-chart-slice[data-slice-index=\""+i+"\"]");if(sl){sl.style.filter="brightness(1.1) drop-shadow(0 2px 8px rgba(0,0,0,0.15))";if(root.dataset.chartType==="pie")sl.style.transform="scale(1.03)";}});';
            echo '  li.addEventListener("mouseleave",function(){var i=li.getAttribute("data-slice-index");var sl=root.querySelector(".impulse-chart-slice[data-slice-index=\""+i+"\"]");if(sl){sl.style.filter="";sl.style.transform="";}});';
            echo '});';
            // Tilt bias — only for tilted_ring style with tilt_sens > 0.
            echo 'var sens=parseInt(root.dataset.tiltSens||"0",10)/10;';
            echo 'if(sens>0&&root.dataset.chartStyle==="tilted_ring"){';
            echo '  var tilt=root.querySelector(".impulse-chart-tilt");';
            echo '  if(tilt){var baseRx=-38,mx=0,my=0,gx=0,gy=0,rx=baseRx,ry=0,rxT=baseRx,ryT=0,ease=null;';
            echo '    function setTarget(){rxT=baseRx+(my+gy)*sens*10;ryT=(mx+gx)*sens*20;if(!ease)ease=requestAnimationFrame(frame);}';
            echo '    function frame(){rx+=(rxT-rx)*0.1;ry+=(ryT-ry)*0.1;tilt.style.transform="rotateX("+rx.toFixed(2)+"deg) rotateY("+ry.toFixed(2)+"deg)";if(Math.abs(rxT-rx)>0.05||Math.abs(ryT-ry)>0.05){ease=requestAnimationFrame(frame);}else{ease=null;}}';
            echo '    root.addEventListener("mousemove",function(e){var r=root.getBoundingClientRect();mx=((e.clientX-r.left)/r.width)*2-1;my=((e.clientY-r.top)/r.height)*2-1;setTarget();});';
            echo '    root.addEventListener("mouseleave",function(){mx=0;my=0;setTarget();});';
            echo '    var bindGyro=function(){window.addEventListener("deviceorientation",function(e){var g=(e.gamma||0)/45,b=((e.beta||0)-30)/45;if(g>1)g=1;else if(g<-1)g=-1;if(b>1)b=1;else if(b<-1)b=-1;gx=g;gy=b;setTarget();});};';
            echo '    if(typeof DeviceOrientationEvent!=="undefined"&&typeof DeviceOrientationEvent.requestPermission==="function"){document.addEventListener("touchstart",function(){DeviceOrientationEvent.requestPermission().then(function(r){if(r==="granted")bindGyro();}).catch(function(){});},{once:true});}else if("ontouchstart" in window){bindGyro();}';
            echo '  }';
            echo '}';
            // Radial mouse tilt — rotates the ring back and forth with mouse X.
            // Uses the SVG transform attribute directly (WAAPI animates only
            // stroke-dasharray on the same element, so transform is free).
            echo 'if(sens>0&&root.dataset.chartType==="radial"){';
            echo '  var _rEl=root.querySelector("circle.impulse-chart-slice");';
            echo '  if(_rEl){';
            echo '    var _rCx=parseFloat(_rEl.getAttribute("cx"))||0;';
            echo '    var _rCy=parseFloat(_rEl.getAttribute("cy"))||0;';
            echo '    var _rBase=-90, _rMx=0, _rMxT=0, _rRaf=null;';
            echo '    function _rFrame(){_rMx+=(_rMxT-_rMx)*0.12;var _rot=_rBase+(_rMx*sens*20);_rEl.setAttribute("transform","rotate("+_rot.toFixed(2)+" "+_rCx+" "+_rCy+")");if(Math.abs(_rMxT-_rMx)>0.02){_rRaf=requestAnimationFrame(_rFrame);}else{_rRaf=null;}}';
            echo '    root.addEventListener("mousemove",function(e){var r=root.getBoundingClientRect();_rMxT=((e.clientX-r.left)/r.width)*2-1;if(!_rRaf)_rRaf=requestAnimationFrame(_rFrame);});';
            echo '    root.addEventListener("mouseleave",function(){_rMxT=0;if(!_rRaf)_rRaf=requestAnimationFrame(_rFrame);});';
            echo '  }';
            echo '}';
            echo '})();';
            $_inlineJs = ob_get_clean();
            wp_add_inline_script('instinctor-frontend-js', $_inlineJs, 'after');
            return ob_get_clean();

        // ================================================================
        // PRICE LIST
        // ================================================================

    return ob_get_clean();
}
