@props([ 'title' => null, 'subtitle' => null, 'points' => [], 'labels' => [], 'navSeries' => [], 'benchmarkSeries' => [], 'navLabel' => 'NAV', 'benchmarkLabel' => null, 'value' => null, 'valueLabel' => null, 'trendValue' => null, 'trendLabel' => null, 'trendDirection' => null, 'autoTrend' => true, 'unit' => null, 'prefix' => null, 'suffix' => null, 'decimals' => 1, 'width' => null, 'min' => null, 'max' => null, 'height' => 300, 'variant' => 'panel', 'showHeader' => true, 'showLegend' => true, 'showPoints' => true, 'showGrid' => true, 'emptyText' => null, 'navColor' => 'var(--primary)', 'benchmarkColor' => 'var(--accent)', 'id' => null, ]) @php $title = $title ?: __('kview-visualization::visualization.chart.title'); $benchmarkLabel = $benchmarkLabel ?: __('kview-visualization::visualization.chart.benchmark'); $emptyText = $emptyText ?: __('kview-visualization::visualization.chart.empty'); $chartTitle = __('kview-visualization::visualization.chart.chart_title'); $variant = in_array($variant, ['panel', 'bare', 'compact'], true) ? $variant : 'panel'; $decimals = is_numeric($decimals) ? max(0, (int) $decimals) : 1; $chartWidth = max(720, min(1280, is_numeric($width) ? (int) $width : ($variant === 'compact' ? 760 : 1080))); $chartHeight = max(220, (int) $height); $pad = [ 'top' => $variant === 'compact' ? 18 : 24, 'right' => 26, 'bottom' => 44, 'left' => 56, ]; $plotWidth = $chartWidth - $pad['left'] - $pad['right']; $plotHeight = $chartHeight - $pad['top'] - $pad['bottom']; $toList = static function ($value): array { if ($value instanceof \Illuminate\Support\Collection) { return $value->values()->all(); } if ($value instanceof \Traversable) { return array_values(iterator_to_array($value, false)); } return is_array($value) ? array_values($value) : []; }; $toNumber = static function ($value): ?float { if ($value === null || $value === '') { return null; } if (is_numeric($value)) { return (float) $value; } if (! is_string($value)) { return null; } $normalized = trim(str_replace(["\xc2\xa0", ' ', '%', 'EUR', '$'], '', $value)); if (preg_match('/^-?\d+,\d+$/', $normalized)) { $normalized = str_replace(',', '.', $normalized); } return is_numeric($normalized) ? (float) $normalized : null; }; $valueFrom = static function ($item, array $keys) { if (! is_array($item) && ! is_object($item)) { return $item; } foreach ($keys as $key) { $value = data_get($item, $key); if ($value !== null && $value !== '') { return $value; } } return null; }; $labelFrom = static function ($item, int $index, array $labels): string { $label = null; if (is_array($item) || is_object($item)) { $label = data_get($item, 'label') ?? data_get($item, 'period') ?? data_get($item, 'quarter') ?? data_get($item, 'date') ?? data_get($item, 'x'); } $label ??= $labels[$index] ?? null; if ($label instanceof \DateTimeInterface) { return $label->format('M y'); } return $label !== null && $label !== '' ? (string) $label : (string) ($index + 1); }; $rawPoints = $toList($points); $labels = $toList($labels); $navSeries = $toList($navSeries); $benchmarkSeries = $toList($benchmarkSeries); if (empty($rawPoints)) { $count = max(count($labels), count($navSeries), count($benchmarkSeries)); $rawPoints = $count > 0 ? array_map(static fn ($index) => ['index' => $index], range(0, $count - 1)) : []; } $chartRows = []; foreach ($rawPoints as $index => $point) { $navItem = $navSeries[$index] ?? null; $benchmarkItem = $benchmarkSeries[$index] ?? null; $chartRows[] = [ 'label' => $labelFrom($point, $index, $labels), 'nav' => $toNumber( (is_array($point) || is_object($point)) ? $valueFrom($point, ['nav', 'nav_value', 'value', 'fund', 'fund_value', 'y']) : ($navItem !== null ? $valueFrom($navItem, ['nav', 'nav_value', 'value', 'fund', 'fund_value', 'y']) : $point) ), 'benchmark' => $toNumber( (is_array($point) || is_object($point)) ? $valueFrom($point, ['benchmark', 'benchmark_value', 'comparison', 'reference']) : $valueFrom($benchmarkItem, ['benchmark', 'benchmark_value', 'value', 'comparison', 'reference', 'y']) ), ]; if (! empty($navSeries)) { $chartRows[$index]['nav'] = $toNumber($valueFrom($navItem, ['nav', 'nav_value', 'value', 'fund', 'fund_value', 'y'])); } if (! empty($benchmarkSeries)) { $chartRows[$index]['benchmark'] = $toNumber($valueFrom($benchmarkItem, ['benchmark', 'benchmark_value', 'value', 'comparison', 'reference', 'y'])); } } $numericValues = []; $navValues = []; foreach ($chartRows as $row) { if ($row['nav'] !== null) { $numericValues[] = $row['nav']; $navValues[] = $row['nav']; } if ($row['benchmark'] !== null) { $numericValues[] = $row['benchmark']; } } $hasChart = count($chartRows) > 0 && count($numericValues) > 0; $hasNav = collect($chartRows)->contains(fn ($row) => $row['nav'] !== null); $hasBenchmark = collect($chartRows)->contains(fn ($row) => $row['benchmark'] !== null); $formatValue = static function (?float $number) use ($decimals, $prefix, $suffix, $unit): string { if ($number === null) { return '-'; } $formatted = number_format($number, $decimals, '.', ' '); $parts = []; if ($prefix) { $parts[] = $prefix; } $parts[] = $formatted; if ($unit) { $parts[] = $unit; } if ($suffix) { $parts[] = $suffix; } return trim(implode(' ', $parts)); }; $svgNumber = static function (float $number): string { return rtrim(rtrim(number_format($number, 2, '.', ''), '0'), '.'); }; $tooltipWidth = 180; $tooltipHeight = 68; $tooltipX = static function (float $x) use ($chartWidth, $tooltipWidth): float { return max(8, min($chartWidth - $tooltipWidth - 8, $x - ($tooltipWidth / 2))); }; $tooltipY = static function (float $y) use ($tooltipHeight): float { return $y > 86 ? $y - $tooltipHeight - 16 : $y + 18; }; $baselineFor = static function (array $coords): ?float { foreach ($coords as $point) { $value = (float) ($point['value'] ?? 0); if (abs($value) > 0.000001) { return $value; } } return null; }; $trendFrom = static function (?float $current, ?float $baseline): array { if ($current !== null && abs($current) <= 0.000001) { return ['label' => '0% depuis départ', 'direction' => 'neutral']; } if ($current === null || $baseline === null || abs($baseline) < 0.000001) { return ['label' => null, 'direction' => 'neutral']; } $change = (($current - $baseline) / abs($baseline)) * 100; $label = ($change > 0 ? '+' : '').number_format($change, 1, '.', '').'% depuis départ'; return [ 'label' => $label, 'direction' => $change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'), ]; }; $niceStep = static function (float $range, int $intervals = 4): float { if ($range <= 0) { return 1.0; } $roughStep = $range / max($intervals, 1); $power = 10 ** floor(log10($roughStep)); $fraction = $roughStep / $power; $niceFraction = match (true) { $fraction <= 1.5 => 1, $fraction <= 3 => 2, $fraction <= 7 => 5, default => 10, }; return $niceFraction * $power; }; $domainMin = null; $domainMax = null; $ticks = []; $navCoords = []; $benchmarkCoords = []; $xLabels = []; $tickValues = []; $navPolyline = ''; $benchmarkPolyline = ''; $navArea = ''; $baselineY = $pad['top'] + $plotHeight; if ($hasChart) { $rawMin = $toNumber($min) ?? min($numericValues); $rawMax = $toNumber($max) ?? max($numericValues); if ($rawMin === $rawMax) { $rawMin -= 1; $rawMax += 1; } $hasExplicitMin = $toNumber($min) !== null; $hasExplicitMax = $toNumber($max) !== null; $range = $rawMax - $rawMin; $padding = max($range * 0.12, abs($rawMax) * 0.015, 1); $domainMin = $hasExplicitMin ? (float) $toNumber($min) : ($rawMin - $padding); $domainMax = $hasExplicitMax ? (float) $toNumber($max) : ($rawMax + $padding); if (! $hasExplicitMin && $rawMin >= 0 && $domainMin < 0) { $domainMin = 0.0; } if (! $hasExplicitMax && $rawMax <= 0 && $domainMax > 0) { $domainMax = 0.0; } if (! $hasExplicitMin && ! $hasExplicitMax) { $tickDomainMin = $rawMin >= 0 ? 0.0 : $rawMin; $tickDomainMax = $rawMax <= 0 ? 0.0 : $rawMax; $tickStep = $niceStep(max(abs($tickDomainMax - $tickDomainMin), 1), 4); $maxTickLabels = 5; $domainMin = floor($tickDomainMin / $tickStep) * $tickStep; $domainMax = ceil($tickDomainMax / $tickStep) * $tickStep; if ($rawMin >= 0) { $domainMin = 0.0; } if ($rawMax <= 0) { $domainMax = 0.0; } if ($domainMin === $domainMax) { $domainMax += $tickStep; } $tickCount = (int) floor(($domainMax - $domainMin) / $tickStep) + 1; while ($tickCount > $maxTickLabels) { $tickStep *= 2; $domainMin = floor($domainMin / $tickStep) * $tickStep; $domainMax = ceil($domainMax / $tickStep) * $tickStep; if ($rawMin >= 0) { $domainMin = 0.0; } if ($rawMax <= 0) { $domainMax = 0.0; } $tickCount = (int) floor(($domainMax - $domainMin) / $tickStep) + 1; } for ($valueForTick = $domainMin; $valueForTick <= $domainMax + ($tickStep / 2); $valueForTick += $tickStep) { $tickValues[] = $valueForTick; } } $domainRange = max($domainMax - $domainMin, 1); $lastIndex = max(count($chartRows) - 1, 1); $labelStep = max(1, (int) ceil(count($chartRows) / 6)); foreach ($chartRows as $index => $row) { $x = $pad['left'] + ($index / $lastIndex) * $plotWidth; if ($row['nav'] !== null) { $y = $pad['top'] + $plotHeight - (($row['nav'] - $domainMin) / $domainRange) * $plotHeight; $navCoords[] = [ 'x' => $x, 'y' => $y, 'label' => $row['label'], 'value' => $row['nav'], 'display' => $formatValue($row['nav']), 'tooltip_x' => $tooltipX($x), 'tooltip_y' => $tooltipY($y), ]; } if ($row['benchmark'] !== null) { $y = $pad['top'] + $plotHeight - (($row['benchmark'] - $domainMin) / $domainRange) * $plotHeight; $benchmarkCoords[] = [ 'x' => $x, 'y' => $y, 'label' => $row['label'], 'value' => $row['benchmark'], 'display' => $formatValue($row['benchmark']), 'tooltip_x' => $tooltipX($x), 'tooltip_y' => $tooltipY($y), ]; } if ($index === 0 || $index === count($chartRows) - 1 || $index % $labelStep === 0) { $xLabels[] = [ 'x' => $x, 'label' => $row['label'], 'anchor' => $index === 0 ? 'start' : ($index === count($chartRows) - 1 ? 'end' : 'middle'), ]; } } $navTrendBaseline = $baselineFor($navCoords); foreach ($navCoords as $coordIndex => $point) { $trend = $trendFrom($point['value'], $navTrendBaseline); $navCoords[$coordIndex]['trend_label'] = $trend['label']; $navCoords[$coordIndex]['trend_direction'] = $trend['direction']; } $benchmarkTrendBaseline = $baselineFor($benchmarkCoords); foreach ($benchmarkCoords as $coordIndex => $point) { $trend = $trendFrom($point['value'], $benchmarkTrendBaseline); $benchmarkCoords[$coordIndex]['trend_label'] = $trend['label']; $benchmarkCoords[$coordIndex]['trend_direction'] = $trend['direction']; } $navPolyline = collect($navCoords) ->map(fn ($point) => $svgNumber($point['x']).','.$svgNumber($point['y'])) ->implode(' '); $benchmarkPolyline = collect($benchmarkCoords) ->map(fn ($point) => $svgNumber($point['x']).','.$svgNumber($point['y'])) ->implode(' '); if (count($navCoords) > 1) { $firstNav = $navCoords[0]; $lastNav = $navCoords[count($navCoords) - 1]; $navArea = $svgNumber($firstNav['x']).','.$svgNumber($baselineY).' '.$navPolyline.' '.$svgNumber($lastNav['x']).','.$svgNumber($baselineY); } if ($tickValues === []) { for ($index = 0; $index <= 4; $index++) { $tickValues[] = $domainMin + ($domainRange / 4) * $index; } } foreach ($tickValues as $valueForTick) { $ticks[] = [ 'value' => $valueForTick, 'y' => $pad['top'] + $plotHeight - (($valueForTick - $domainMin) / $domainRange) * $plotHeight, ]; } } $latestNav = count($navCoords) ? $navCoords[count($navCoords) - 1]['value'] : null; $resolvedValue = $value ?? ($latestNav !== null ? $formatValue($latestNav) : null); $resolvedValueLabel = $valueLabel ?: __('kview-visualization::visualization.chart.latest', ['series' => $navLabel]); $resolvedTrendValue = $trendValue; $resolvedTrendLabel = $trendLabel; $resolvedTrendDirection = $trendDirection; if ($autoTrend && $resolvedTrendValue === null && count($navValues) > 1 && $navValues[0] != 0.0) { $change = (($navValues[count($navValues) - 1] - $navValues[0]) / abs($navValues[0])) * 100; $resolvedTrendValue = ($change > 0 ? '+' : '').number_format($change, 1, '.', '').'%'; $resolvedTrendLabel = $resolvedTrendLabel ?: __('kview-visualization::visualization.chart.since_start'); $resolvedTrendDirection = $change > 0 ? 'up' : ($change < 0 ? 'down' : 'neutral'); } $resolvedTrendDirection = in_array($resolvedTrendDirection, ['up', 'down', 'neutral'], true) ? $resolvedTrendDirection : 'neutral'; $safeId = preg_replace('/[^A-Za-z0-9\-_:.]/', '-', $id ?: 'fund-performance-chart-'.substr(md5(($title ?: 'chart').serialize($chartRows).uniqid('', true)), 0, 10)); $gradientId = $safeId.'-nav-fill'; $visibleHeader = $showHeader && ($title || $subtitle || $resolvedValue || $resolvedTrendValue || $showLegend); $minimumChartWidth = min($chartWidth, max(620, 220 + (count($chartRows) * 78))); $rootStyle = trim('--fpc-nav-color: '.$navColor.'; --fpc-benchmark-color: '.$benchmarkColor.'; --fpc-min-chart-width: '.$minimumChartWidth.'px; '.($attributes->get('style') ?? '')); @endphp
except('style')->class([ 'fund-performance-chart', 'fund-performance-chart--'.$variant, ])->merge([ 'id' => $safeId, 'aria-labelledby' => $safeId.'-title', 'aria-describedby' => $safeId.'-data', 'style' => $rootStyle, ]) }} > @if ($visibleHeader)
@if ($title)

{{ $title }}

@else

{{ $chartTitle }}

@endif @if ($subtitle)

{{ $subtitle }}

@endif @if ($showLegend && $hasChart)
    @if ($hasNav)
  • {{ $navLabel }}
  • @endif @if ($hasBenchmark)
  • {{ $benchmarkLabel }}
  • @endif
@endif
@if ($resolvedValue || $resolvedTrendValue)
@if ($resolvedValue)
{{ $resolvedValueLabel }} {{ $resolvedValue }}
@endif @if ($resolvedTrendValue) @if ($resolvedTrendDirection === 'down') @elseif ($resolvedTrendDirection === 'up') @else @endif {{ $resolvedTrendValue }} @if ($resolvedTrendLabel) {{ $resolvedTrendLabel }} @endif @endif
@endif
@else
{{ $title ?: $chartTitle }}
@endif @if ($hasChart)
{{ __('kview-visualization::visualization.chart.description', [ 'series' => $hasBenchmark ? __('kview-visualization::visualization.chart.series_pair', ['first' => $navLabel, 'second' => $benchmarkLabel]) : $navLabel, 'count' => count($chartRows), ]) }} @if ($showGrid) @foreach ($ticks as $tick) {{ $formatValue($tick['value']) }} @endforeach @endif @foreach ($xLabels as $label) {{ $label['label'] }} @endforeach @if ($navArea) @endif @if ($hasBenchmark && $benchmarkPolyline) @endif @if ($hasNav && $navPolyline) @endif @if ($showPoints) @foreach ($benchmarkCoords as $point) {{ $point['label'] }} {{ $point['display'] }} {{ $benchmarkLabel }} @if ($point['trend_label']) {{ $point['trend_label'] }} @endif @endforeach @foreach ($navCoords as $point) {{ $point['label'] }} {{ $point['display'] }} {{ $navLabel }} @if ($point['trend_label']) {{ $point['trend_label'] }} @endif @endforeach @endif
@else
{{ $emptyText }}
@endif @if ($hasNav) @endif @if ($hasBenchmark) @endif @foreach ($chartRows as $row) @if ($hasNav) @endif @if ($hasBenchmark) @endif @endforeach
{{ __('kview-visualization::visualization.chart.data', ['title' => $title ?: $chartTitle]) }}
{{ __('kview-visualization::visualization.chart.period') }}{{ $navLabel }}{{ $benchmarkLabel }}
{{ $row['label'] }}{{ $formatValue($row['nav']) }}{{ $formatValue($row['benchmark']) }}