Files

1690 lines
109 KiB
PHP

<div id="stats-component">
{{-- Filters --}}
<div class="mt-6 p-4 bg-theme-surface rounded-md border border-theme-border">
<div class="flex items-center justify-between mb-4">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">
{{ __('Filters') }}
</h3>
<x-jetstream.secondary-button wire:click="clearFilters" wire:loading.attr="disabled">
{{ __('Clear filters') }}
</x-jetstream.secondary-button>
</div>
<p class="text-sm text-theme-secondary mb-4">
{{ __('Filters narrow the selection: a profile must match every active filter.') }}
</p>
{{-- Location --}}
<div class="mb-4">
<x-checkbox id="stats-filter-location"
label="{{ __('Filter by profile location') }}"
wire:model.live="filterByLocation" />
@if ($filterByLocation)
<div class="mt-3" wire:ignore>
<livewire:mailings.location-filter wire:key="stats-location-filter" />
</div>
@endif
</div>
{{-- Registration date --}}
<div class="mb-4">
<x-checkbox id="stats-filter-registration"
label="{{ __('Filter by registration date') }}"
wire:model.live="filterByRegistration" />
@if ($filterByRegistration)
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm text-theme-primary mb-1">{{ __('Registered from') }}</label>
<x-input type="date" class="w-full" wire:model.live="registeredFrom" />
</div>
<div>
<label class="block text-sm text-theme-primary mb-1">{{ __('Registered until') }}</label>
<x-input type="date" class="w-full" wire:model.live="registeredTill" />
</div>
</div>
@endif
</div>
{{-- Inactivity: not logged in / not traded since --}}
@php
$inactivityPeriods = [
'since_one_year_ago' => __('Since one year ago'),
'previous_year' => __('Previous year'),
'previous_quarter' => __('Previous quarter'),
'previous_month' => __('Previous month'),
'custom' => __('Custom'),
'all_time' => __('All time'),
];
@endphp
<div class="mb-4">
<x-checkbox id="stats-filter-last-login"
label="{{ __('Filter by not logged in since') }}"
wire:model.live="filterByLastLogin" />
@if ($filterByLastLogin)
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<select wire:model.live="lastLoginPeriod"
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm sm:text-sm">
@foreach ($inactivityPeriods as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
@if ($lastLoginPeriod === 'custom')
<div>
<label class="block text-sm text-theme-primary mb-1">{{ __('Not logged in since') }}</label>
<x-input type="date" class="w-full" wire:model.live="lastLoginCustom" />
</div>
@endif
</div>
<p class="mt-2 text-xs text-theme-secondary">
{{ __('Includes profiles that have never logged in. Banks have no login and are left out.') }}
</p>
@endif
</div>
<div class="mb-4">
<x-checkbox id="stats-filter-last-transaction"
label="{{ __('Filter by no transaction since') }}"
wire:model.live="filterByLastTransaction" />
@if ($filterByLastTransaction)
<div class="mt-3 grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<select wire:model.live="lastTransactionPeriod"
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm sm:text-sm">
@foreach ($inactivityPeriods as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
</div>
@if ($lastTransactionPeriod === 'custom')
<div>
<label class="block text-sm text-theme-primary mb-1">{{ __('No transaction since') }}</label>
<x-input type="date" class="w-full" wire:model.live="lastTransactionCustom" />
</div>
@endif
</div>
<p class="mt-2 text-xs text-theme-secondary">
{{ __('System entries such as migrations and rounding corrections do not count as activity.') }}
</p>
@endif
</div>
{{-- Counterparties --}}
<div>
<x-checkbox id="stats-filter-contacts"
label="{{ __('Filter by transactions with selected profiles') }}"
wire:model.live="filterByContacts" />
@if ($filterByContacts)
<p class="mt-2 text-sm text-theme-secondary">
{{ __('Only include profiles that have transacted with the selected profile(s).') }}
</p>
<div class="mt-3" wire:ignore>
<livewire:profile.select-multiple-profiles
wire:key="stats-profiles-selector"
:label="__('Select profiles')"
:typesAvailable="[\App\Models\User::class, \App\Models\Organization::class, \App\Models\Bank::class]"
eventName="statsSelectedProfiles" />
</div>
@if (count($selectedContactProfiles) > 0)
<div class="mt-3 flex flex-wrap gap-2">
@foreach ($selectedContactProfiles as $index => $profile)
<span class="inline-flex items-center gap-2 px-2 py-1 text-xs font-medium rounded-full bg-theme-background border border-theme-border text-theme-primary">
{{ $profile['name'] }}
<button type="button" wire:click="removeContactProfile({{ $index }})" class="text-theme-secondary hover:text-theme-primary">
<x-icon name="x-circle" class="h-4 w-4" />
</button>
</span>
@endforeach
</div>
@endif
@endif
</div>
</div>
{{-- Display options, outside the conditional sections: they change how both
the stats tables and the network graph are labelled, so they must be
reachable before either section has been opened — like the filters. --}}
<div class="mt-6 flex flex-wrap items-center gap-6">
<div class="flex items-center gap-3"
title="{{ __('Shows numbered labels instead of names, in the most active profiles table and in the network graph. The same profile gets the same number in both, so a label found in one can be looked up in the other.') }}">
<x-toggle id="stats-anonymize" name="stats-anonymize" wire:model.live="anonymizeProfiles" />
<span class="text-sm text-theme-secondary cursor-help">{{ __('Anonymise profile names') }}</span>
</div>
<div class="flex items-center gap-3">
<x-toggle id="stats-decimal" name="stats-decimal" wire:model.live="decimalFormat" />
<span class="text-sm text-theme-secondary">{{ __('Decimal notation') }}</span>
</div>
</div>
@if (!$hasFilter)
{{-- Nothing selected yet: don't run a platform-wide query on page load.
Same structure as the network section below: heading, explanation,
button — so the two blocks read as equal sections. --}}
<div class="mt-10">
<div>
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Statistics') }}</h3>
<p class="text-xs text-theme-secondary">{{ __('Statistics are calculated over the profiles matching your selection.') }}</p>
</div>
<div class="mt-3">
<x-jetstream.secondary-button wire:click="showStats" wire:loading.attr="disabled" wire:target="showStats">
<span wire:loading.remove wire:target="showStats">{{ __('Show statistics') }}</span>
<span wire:loading wire:target="showStats">{{ __('Calculating...') }}</span>
</x-jetstream.secondary-button>
<span class="ml-3 text-xs text-theme-secondary">
{{ __('Select a period or a filter first, then calculate the tables and charts.') }}
</span>
</div>
</div>
@else
{{-- Toolbar. The display toggles used to live here; they now sit with
the filters, since they apply to the graph as well as the stats
and must stay reachable when neither section is open. --}}
<div class="mt-8 flex items-center justify-end">
<div class="flex items-center gap-3">
<x-jetstream.secondary-button wire:click="refreshStats" wire:loading.attr="disabled">
{{ __('Refresh') }}
</x-jetstream.secondary-button>
<x-jetstream.button data-network-pdf-trigger
x-on:click="window.exportStatsPdf && window.exportStatsPdf()"
wire:loading.attr="disabled" wire:target="exportPdf">
<span wire:loading.remove wire:target="exportPdf">{{ __('Export PDF') }}</span>
<span wire:loading wire:target="exportPdf">{{ __('Preparing...') }}</span>
</x-jetstream.button>
</div>
</div>
{{-- What these numbers actually cover --}}
<div class="mt-6 p-3 bg-theme-surface rounded-md border border-theme-border">
<div class="flex flex-wrap items-center gap-x-6 gap-y-2">
<span class="text-xs uppercase tracking-wide text-theme-secondary">{{ __('Showing') }}</span>
@foreach ($this->activeFilterSummary() as $item)
<span class="text-sm text-theme-primary">
<span class="text-theme-secondary">{{ $item['label'] }}:</span>
<strong>{{ $item['value'] }}</strong>
</span>
@endforeach
</div>
</div>
{{-- Summary tiles --}}
{{-- Five across on large screens: with lg:grid-cols-4 the fifth tile
drops onto a row of its own and reads as an afterthought. --}}
<div class="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
@php
$filteredByProfile = $filterByContacts && count($selectedContactProfiles) > 0;
$tiles = [
[
'label' => $this->profilesLabel(),
'value' => number_format($stats['profileCount'], 0, ',', '.'),
'hint' => __('How many profiles match your filters. These are the profiles every number on this page is based on.'),
],
[
'label' => __('Incomplete'),
// The share, not the count: a percentage is what makes
// the figure comparable between filters and periods.
'value' => (function () use ($stats) {
$incomplete = (int) ($stats['online']['incomplete'] ?? 0);
$total = (int) ($stats['profileCount'] ?? 0);
if ($total < 1) {
return '-';
}
return number_format(100 * $incomplete / $total, 1, ',', '.') . '%';
})(),
'hint' => __('Profiles that have not filled in enough of their description, tags, languages or location. Shown as a share of the profiles in this selection.'),
],
[
'label' => __('Transactions'),
'value' => number_format($stats['economic']['transaction_count'], 0, ',', '.'),
'hint' => $filteredByProfile
? __('How many payments were made with the selected profile(s) during the period. Payments between other profiles are not counted.')
: __('How many payments were made during the period by the profiles in your selection.'),
],
[
'label' => __('Total volume'),
'value' => $this->formatAmount($stats['economic']['total_volume']),
'hint' => __('All the time that changed hands, added up. Every payment is counted once, no matter who sent or received it.'),
],
[
'label' => __('Total balance'),
'value' => $this->formatAmount($stats['balance']),
'hint' => $filteredByProfile
? __('What the selected profile(s) hold right now: time earned minus time spent, over their whole history. The period does not change this.')
: __('What the profiles in your selection hold right now: time earned minus time spent, over their whole history. The period does not change this.'),
],
];
@endphp
@foreach ($tiles as $tile)
<div class="p-4 bg-theme-surface rounded-md border border-theme-border cursor-help"
title="{{ $tile['hint'] }}">
<div class="text-xs uppercase tracking-wide text-theme-secondary">
{{ $tile['label'] }}
</div>
<div class="mt-1 text-2xl font-semibold text-theme-primary">{{ $tile['value'] }}</div>
</div>
@endforeach
</div>
{{-- Economic --}}
<div class="mt-10">
<div class="cursor-help" title="{{ __('Time traded between profiles. Every payment counts once, whoever sent or received it.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Economic activity') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
</div>
{{-- Only shown when the selection can actually have an "outside";
see split_is_meaningful in StatsService::economic(). --}}
@if ($stats['economic']['split_is_meaningful'] ?? false)
<div class="mb-4 p-3 bg-theme-surface rounded-md border border-theme-border">
<p class="text-sm text-theme-primary">
<strong>{{ __('Internal:') }}</strong> {{ $this->formatAmount($stats['economic']['internal_volume']) }}
&nbsp;&middot;&nbsp;
<strong>{{ __('External:') }}</strong> {{ $this->formatAmount($stats['economic']['external_volume']) }}
</p>
<p class="text-sm text-theme-secondary mt-1">
{{ __('Internal transactions happen between two profiles inside the selection; external ones have one side outside it.') }}
</p>
</div>
@endif
@if ($stats['economic']['by_type']->isNotEmpty())
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-theme-border">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Transaction type') }}</th>
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Count') }}</th>
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Volume') }}</th>
</tr>
</thead>
<tbody class="divide-y divide-theme-border">
@foreach ($stats['economic']['by_type'] as $type)
<tr wire:key="stats-type-{{ $loop->index }}">
<td class="px-3 py-2 text-sm text-theme-primary">{{ $type['type_name'] }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($type['count'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ $this->formatAmount($type['volume']) }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
@if (count($stats['economic']['timeline'] ?? []) > 1)
@php
// Series are the transaction-type names carried on each row.
$volumeSeries = array_values(array_diff(
array_keys($stats['economic']['timeline'][0] ?? []),
['label', 'ym', 'count', 'volume']
));
@endphp
@php
// A timeline of all zeros is a real answer, but an empty grid
// reads as a broken chart — so drop the canvas and say it.
$hasVolume = collect($stats['economic']['timeline'])
->sum(fn($row) => $row['volume'] ?? 0) > 0;
@endphp
@if ($hasVolume)
<div class="mt-6 p-4 bg-theme-background rounded-lg border border-theme-border"
data-stats-volume-chart="{{ json_encode($stats['economic']['timeline']) }}"
data-decimal-format="{{ $decimalFormat ? '1' : '0' }}"
data-currency-symbol="{{ platform_currency_symbol() }}"
data-currency-position-end="{{ platform_trans('platform_currency_position_end', null, false) ? '1' : '0' }}"
data-currency-thousand-separator="{{ platform_trans('platform_currency_thousand_separator', null, '') }}"
data-series-colors="{{ json_encode($this->volumeSeriesColors($volumeSeries)) }}">
{{-- The sized wrapper is what Chart.js measures (maintainAspectRatio is
off), so the axis labels get room instead of being clipped. --}}
<div class="relative aspect-video">
<canvas id="statsVolumeChart"></canvas>
</div>
</div>
@else
<p class="mt-6 text-sm text-theme-secondary">
{{ __('No data available for this chart.') }}
</p>
@endif
@endif
</div>
{{-- Online --}}
<div class="mt-10">
<div class="cursor-help" title="{{ $this->isPartnerScoped()
? __('How many partner profiles are in the selection, how many are still active, and how many signed up each month.')
: __('How many profiles are in the selection, how many are still active, and how many signed up each month.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Partner profiles and activity') : __('Profiles and activity') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
</div>
@php
// The hint belongs to the whole column, so it goes on every cell
// as well as the header — a title on <col> is not shown by any
// browser, so there is no way to declare it once per column.
$columnHints = [
'total' => __('Every profile of this type in the current selection, whenever it signed up. The period does not narrow this.'),
'active' => __('Profiles that have not been marked inactive. Counted as of today, not over the period.'),
'inactive' => __('Profiles that have been marked inactive, for example after leaving the platform.'),
'logins' => __('Profiles that signed in at least once in the last 30 days. Each profile counts once, however often it signed in. A dash means this profile type does not record sign-ins.'),
];
$columnLabels = [
'total' => __('Total'),
'active' => __('Active'),
'inactive' => __('Inactive'),
'logins' => __('Recent active < 30d'),
];
@endphp
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-theme-border">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ $this->isPartnerScoped() ? __('Partner profile type') : __('Profile type') }}</th>
@foreach ($columnLabels as $key => $label)
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider cursor-help"
title="{{ $columnHints[$key] }}">{{ $label }}</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-theme-border">
@foreach ($stats['online']['by_type'] as $typeName => $row)
<tr wire:key="stats-online-{{ $typeName }}">
<td class="px-3 py-2 text-sm text-theme-primary">{{ __($typeName) }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['total'] }}">{{ number_format($row['total'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['active'] }}">{{ number_format($row['active'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['inactive'] }}">{{ number_format($row['inactive'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right cursor-help" title="{{ $columnHints['logins'] }}">{{ $row['recent_logins'] === null ? '-' : number_format($row['recent_logins'], 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@if (count($stats['online']['registrations'] ?? []) > 1)
@php
// The table above counts every profile in the selection
// regardless of when it signed up; the chart counts sign-ups
// per month. An all-zero timeline is therefore a real answer,
// but an empty grid reads as a broken chart — so say it.
$hasRegistrations = collect($stats['online']['registrations'])
->sum(fn($row) => $row['count'] ?? 0) > 0;
@endphp
<div class="mt-6">
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
{{ __('New registrations per month') }}
</h4>
@if ($hasRegistrations)
{{-- One chart per profile type, each on its own y-axis.
Users peak around 60 a month where organizations
peak at 2, so a shared axis flattens the latter to
nothing. --}}
@php
$registrationCharts = [
[
'field' => 'User',
'label' => __('Users'),
'canvas' => 'statsRegistrationsChart',
'attr' => 'data-stats-registrations-chart',
],
[
'field' => 'Organization',
'label' => __('Organizations'),
'canvas' => 'statsRegistrationsOrgChart',
'attr' => 'data-stats-registrations-org-chart',
],
];
$registrationRows = collect($stats['online']['registrations']);
$registrationColors = $this->registrationsSeriesColors();
@endphp
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
@foreach ($registrationCharts as $chart)
@php
$regTotal = $registrationRows->sum(fn($row) => $row[$chart['field']] ?? 0);
@endphp
@if ($regTotal > 0)
<div class="p-4 bg-theme-background rounded-lg border border-theme-border"
wire:key="stats-reg-chart-{{ $chart['field'] }}">
<h5 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
{{ $chart['label'] }}
</h5>
<div {{ $chart['attr'] }}="{{ json_encode($registrationRows) }}"
data-series-labels="{{ json_encode([$chart['field'] => $chart['label']]) }}"
data-series-colors="{{ json_encode([$chart['field'] => $registrationColors[$chart['field']] ?? null]) }}">
<div class="relative aspect-video">
<canvas id="{{ $chart['canvas'] }}"></canvas>
</div>
</div>
</div>
@else
<p class="text-sm text-theme-secondary">
{{ $chart['label'] }}: {{ __('No data available for this chart.') }}
</p>
@endif
@endforeach
</div>
@else
<p class="text-sm text-theme-secondary">
{{ __('No profile in this selection registered during this period. The totals above cover the whole selection, whenever each profile signed up.') }}
</p>
@endif
</div>
@endif
</div>
{{-- Engagement --}}
<div class="mt-10">
<div class="cursor-help" title="{{ __('What people did besides paying each other: posts written, calls placed, reactions given and chat messages sent.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Engagement') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
</div>
@php
// One definition drives the tiles and the charts below, so a
// label and its hint can never drift apart.
$engagementMetrics = [
[
'field' => 'posts',
'label' => __('Posts'),
'hint' => __('Articles and other posts published in this period by the profiles in your selection. Edits to an existing post are not counted again.'),
'canvas' => 'statsEngagementPostsChart',
'attr' => 'data-stats-engagement-posts-chart',
],
[
'field' => 'calls',
'label' => __('Calls'),
'hint' => __('Requests and offers placed in this period. Each call counts once when it is created, however many people respond to it.'),
'canvas' => 'statsEngagementCallsChart',
'attr' => 'data-stats-engagement-calls-chart',
],
[
'field' => 'reactions',
'label' => __('Reactions'),
'hint' => __('Likes and other reactions given to posts and calls in this period. One profile reacting to several items counts several times.'),
'canvas' => 'statsEngagementReactionsChart',
'attr' => 'data-stats-engagement-reactions-chart',
],
[
'field' => 'messages',
'label' => __('Messages'),
'hint' => __('Chat messages sent in this period. Every message counts, including several within one conversation.'),
'canvas' => 'statsEngagementMessagesChart',
'attr' => 'data-stats-engagement-messages-chart',
],
];
$engagementColors = $this->engagementSeriesColors();
@endphp
<div class="grid grid-cols-2 lg:grid-cols-4 gap-4">
@foreach ($engagementMetrics as $metric)
<div class="p-4 bg-theme-surface rounded-md border border-theme-border cursor-help"
title="{{ $metric['hint'] }}"
wire:key="stats-eng-{{ $metric['field'] }}">
<div class="text-xs uppercase tracking-wide text-theme-secondary">{{ $metric['label'] }}</div>
<div class="mt-1 text-xl font-semibold text-theme-primary">{{ number_format($stats['engagement'][$metric['field']], 0, ',', '.') }}</div>
</div>
@endforeach
</div>
@php
$hasEngagement = collect($stats['engagement']['timeline'] ?? [])
->sum(fn($row) => ($row['posts'] ?? 0) + ($row['calls'] ?? 0)
+ ($row['reactions'] ?? 0) + ($row['messages'] ?? 0)) > 0;
@endphp
@if ($hasEngagement)
{{-- One chart per metric, each on its own y-axis. Reactions peak
around 3.900 a month where posts peak at 2, so on a shared
axis the smaller series are well under a pixel. --}}
<div class="mt-6 grid grid-cols-1 lg:grid-cols-2 gap-4">
@foreach ($engagementMetrics as $metric)
@php
$metricRows = collect($stats['engagement']['timeline']);
$metricTotal = $metricRows->sum(fn($row) => $row[$metric['field']] ?? 0);
@endphp
@if ($metricTotal > 0)
<div class="p-4 bg-theme-background rounded-lg border border-theme-border"
wire:key="stats-eng-chart-{{ $metric['field'] }}">
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2 cursor-help"
title="{{ $metric['hint'] }}">{{ $metric['label'] }}</h4>
<div {{ $metric['attr'] }}="{{ json_encode($metricRows) }}"
data-series-labels="{{ json_encode([$metric['field'] => $metric['label']]) }}"
data-series-colors="{{ json_encode([$metric['field'] => $engagementColors[$metric['field']] ?? null]) }}">
<div class="relative aspect-video">
<canvas id="{{ $metric['canvas'] }}"></canvas>
</div>
</div>
</div>
@else
<p class="text-sm text-theme-secondary">
{{ $metric['label'] }}: {{ __('No data available for this chart.') }}
</p>
@endif
@endforeach
</div>
@else
<p class="mt-6 text-sm text-theme-secondary">
{{ __('No data available for this chart.') }}
</p>
@endif
</div>
{{-- Balance distribution --}}
@if (($stats['balance_distribution']['profiles_counted'] ?? 0) > 0)
<div class="mt-10">
<div class="cursor-help" title="{{ __('How many user and organization profiles sit in each balance range. It shows whether most people are near zero or spread into large credit and debt. Banks are left out because their balance mirrors everyone else\'s.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Balance distribution') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope(false) }}</p>
</div>
<p class="text-sm text-theme-secondary mb-3">
@if ($filterByContacts && count($selectedContactProfiles) > 0)
{{ __('Current balance of the :count user and organization trading partners of the selected profile(s), in hours. Banks are excluded.', ['count' => $stats['balance_distribution']['profiles_counted']]) }}
@else
{{ __('Current balance of the :count user and organization profiles in the selection, in hours. Banks are excluded.', ['count' => $stats['balance_distribution']['profiles_counted']]) }}
@endif
</p>
@php
// The chart reads one key per series off each band row, so
// fold the per-type counts into the bands here.
$balanceSeries = $stats['balance_distribution']['series'] ?? [];
$balanceRows = collect($stats['balance_distribution']['bands'])
->map(function ($band, $i) use ($balanceSeries) {
foreach ($balanceSeries as $type => $counts) {
$band[$type] = $counts[$i] ?? 0;
}
return $band;
})
->values();
@endphp
{{-- One chart per profile type, each with its own y-axis.
Organizations are far fewer than users, so on a shared axis
scaled to hundreds of users a bar of 1 organization is
sub-pixel and reads as missing data. --}}
@php
$balanceCharts = [
[
'field' => 'User',
'label' => __('Users'),
'canvas' => 'statsBalanceChart',
'attr' => 'data-stats-balance-chart',
],
[
'field' => 'Organization',
'label' => __('Organizations'),
'canvas' => 'statsBalanceOrgChart',
'attr' => 'data-stats-balance-org-chart',
],
];
$balanceColors = $this->balanceSeriesColors();
@endphp
{{-- Stacked, not side by side: at half width the 27 bands are cramped
and the angled labels crowd. Full width gives each chart
roughly twice the area. --}}
<div class="grid grid-cols-1 gap-6">
@foreach ($balanceCharts as $chart)
@php
$total = $balanceRows->sum(fn($row) => $row[$chart['field']] ?? 0);
// Trim each axis to its own series: the bands are
// computed across both types, so a band holding only
// an organization leaves empty bars on the user
// chart. One empty band is kept past the last
// occupied one, to show where the range ends.
$chartRows = $balanceRows->values();
$chartLast = $chartRows->count() - 1;
while (
$chartLast > 0
&& ($chartRows[$chartLast][$chart['field']] ?? 0) === 0
&& ($chartRows[$chartLast - 1][$chart['field']] ?? 0) === 0
) {
$chartLast--;
}
// Same at the leading end: a chart whose first bar
// sits in band five should not open with four empty
// ones.
$chartFirst = 0;
while (
$chartFirst < $chartLast
&& ($chartRows[$chartFirst][$chart['field']] ?? 0) === 0
&& ($chartRows[$chartFirst + 1][$chart['field']] ?? 0) === 0
) {
$chartFirst++;
}
$chartRows = $chartRows->slice($chartFirst, $chartLast - $chartFirst + 1)->values();
@endphp
@if ($total > 0)
<div class="p-4 bg-theme-background rounded-lg border border-theme-border">
<h4 class="text-xs font-medium text-theme-secondary uppercase tracking-wider mb-2">
{{ $chart['label'] }}
</h4>
<div {{ $chart['attr'] }}="{{ json_encode($chartRows) }}"
data-series-labels="{{ json_encode([$chart['field'] => $chart['label']]) }}"
data-series-colors="{{ json_encode([$chart['field'] => $balanceColors[$chart['field']] ?? null]) }}">
{{-- Taller than the timelines: the angled
band labels need the extra room. --}}
<div class="relative aspect-video">
<canvas id="{{ $chart['canvas'] }}"></canvas>
</div>
</div>
</div>
@else
{{-- No bordered box: an empty frame reads as a chart
that failed to draw. --}}
<p class="text-sm text-theme-secondary">
{{ $chart['label'] }}: {{ __('No profiles of this type in the selection.') }}
</p>
@endif
@endforeach
</div>
</div>
@endif
{{-- Distribution --}}
<div class="mt-10">
<div class="cursor-help" title="{{ __('The profiles with the highest traded time in the period, split into what they paid and what they received. The combined columns count a payment for both sides.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Most active partner profiles') : __('Most active profiles') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
</div>
@if (!empty($stats['distribution']['excluded_profiles']))
<p class="text-xs text-theme-secondary mb-3">
{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}
</p>
@endif
@if ($stats['distribution']['top_profiles']->isEmpty())
<p class="text-sm text-theme-secondary">{{ __('No activity in this selection.') }}</p>
@else
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-theme-border">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ $this->profilesLabel(false) }}</th>
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Type') }}</th>
@php
$sortable = [
['key' => 'paid_count', 'label' => __('Paid'), 'hint' => __('Payments sent by this profile.')],
['key' => 'received_count', 'label' => __('Received'), 'hint' => __('Payments received by this profile.')],
['key' => 'paid_volume', 'label' => __('Paid volume'), 'hint' => __('Time this profile paid out.')],
['key' => 'received_volume', 'label' => __('Received volume'), 'hint' => __('Time this profile received.')],
['key' => 'partners', 'label' => __('Trading partners'), 'hint' => __('Distinct profiles this one traded with. Can be higher than the partner count in the network graph, which leaves out removed profiles.')],
['key' => 'count', 'label' => __('Transactions'), 'hint' => __('Payments sent and received together. This is what the rows are ranked by.')],
];
@endphp
@foreach ($sortable as $column)
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">
<button type="button"
wire:click="sortTop('{{ $column['key'] }}')"
class="inline-flex items-center gap-1 uppercase tracking-wider hover:text-theme-primary {{ $topSort === $column['key'] ? 'text-theme-primary' : '' }}"
title="{{ $column['hint'] }}">
<span>{{ $column['label'] }}</span>
@if ($topSort === $column['key'])
<span aria-hidden="true">{{ $topDirection === 'desc' ? '▼' : '▲' }}</span>
@endif
</button>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-theme-border">
@foreach ($stats['distribution']['top_profiles'] as $profile)
<tr wire:key="stats-top-{{ $profile['type'] }}-{{ $profile['id'] }}">
<td class="px-3 py-2 text-sm text-theme-primary">{{ $anonymizeProfiles ? $profile['anonymous_name'] : $profile['name'] }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary">{{ __($profile['type']) }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['paid_count'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['received_count'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($profile['paid_volume']) }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($profile['received_volume']) }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($profile['partners'] ?? 0, 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($profile['count'], 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@endif
</div>
@if ($stats['distribution']['by_location']->isNotEmpty())
<div class="mt-10">
<div class="cursor-help" title="{{ __('Activity grouped by where the profiles are based, split into what they paid and received. The combined columns count a transaction for both places involved.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Activity by location') }}</h3>
<p class="text-xs text-theme-secondary mb-3">{{ $this->sectionScope() }}</p>
</div>
@if (!empty($stats['distribution']['excluded_profiles']))
<p class="text-xs text-theme-secondary mb-3">
{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}
</p>
@endif
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-theme-border">
<thead>
<tr>
<th class="px-3 py-3 text-left text-xs font-medium text-theme-secondary uppercase tracking-wider">{{ __('Location') }}</th>
@php
$locationSortable = [
['key' => 'paid_count', 'label' => __('Paid'), 'hint' => __('Payments sent by profiles based here.')],
['key' => 'received_count', 'label' => __('Received'), 'hint' => __('Payments received by profiles based here.')],
['key' => 'paid_volume', 'label' => __('Paid volume'), 'hint' => __('Time paid out by profiles based here.')],
['key' => 'received_volume', 'label' => __('Received volume'), 'hint' => __('Time received by profiles based here.')],
['key' => 'count', 'label' => __('Transactions'), 'hint' => __('Payments sent and received together. This is what the rows are ranked by.')],
];
@endphp
@foreach ($locationSortable as $column)
<th class="px-3 py-3 text-right text-xs font-medium text-theme-secondary uppercase tracking-wider">
<button type="button"
wire:click="sortLocation('{{ $column['key'] }}')"
class="inline-flex items-center gap-1 uppercase tracking-wider hover:text-theme-primary {{ $locationSort === $column['key'] ? 'text-theme-primary' : '' }}"
title="{{ $column['hint'] }}">
<span>{{ $column['label'] }}</span>
@if ($locationSort === $column['key'])
<span aria-hidden="true">{{ $locationDirection === 'desc' ? '▼' : '▲' }}</span>
@endif
</button>
</th>
@endforeach
</tr>
</thead>
<tbody class="divide-y divide-theme-border">
@foreach ($stats['distribution']['by_location'] as $row)
<tr wire:key="stats-loc-{{ $loop->index }}">
<td class="px-3 py-2 text-sm text-theme-primary">
{{ $row['label'] ?: __('Location not specified') }}
</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($row['paid_count'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ number_format($row['received_count'], 0, ',', '.') }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($row['paid_volume']) }}</td>
<td class="px-3 py-2 text-sm text-theme-secondary text-right">{{ $this->formatAmount($row['received_volume']) }}</td>
<td class="px-3 py-2 text-sm text-theme-primary text-right">{{ number_format($row['count'], 0, ',', '.') }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
</div>
@endif
{{-- Profiles marked as inactive, over time --}}
{{-- Always rendered, like every other section: an empty series is a
real answer ("nobody is marked inactive"), and a section that
silently disappears reads as a bug rather than as that answer. --}}
@php
$inactiveRows = collect($stats['online']['inactivations'] ?? []);
// Both series are cumulative, so the last row carries the totals.
// A chart needs at least two points to draw a line, and is worth
// drawing when either series has something to say.
$inactiveTotal = $inactiveRows->count() > 1
? (int) ($inactiveRows->last()['count'] ?? 0)
+ (int) ($inactiveRows->last()['incomplete'] ?? 0)
: 0;
$inactiveSeriesColors = $this->inactivationsSeriesColors();
$inactiveColor = $inactiveSeriesColors['count'] ?? null;
$inactiveBalanceColor = $inactiveSeriesColors['balance'] ?? null;
$incompleteColor = $inactiveSeriesColors['incomplete'] ?? null;
@endphp
<div class="mt-10">
<div class="cursor-help" title="{{ __('How many profiles stood marked as inactive at the end of each month, counted cumulatively. Profiles marked as inactive before the period are included in the starting figure, so the line reflects the full history rather than restarting at zero.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ $this->isPartnerScoped() ? __('Partner profiles marked as inactive over time') : __('Profiles marked as inactive over time') }}</h3>
<p class="text-xs text-theme-secondary">{{ $this->sectionScope() }}</p>
</div>
{{-- One paragraph per charted line, in the order they are drawn, so
a reader can find the note for the line they are looking at.
The inactivity rule comes from the platform's own config. --}}
<div class="mb-3 space-y-1">
@foreach (\App\Services\StatsService::chartLegendNotes() as $note)
<p class="text-xs text-theme-secondary">{{ $note }}</p>
@endforeach
</div>
@if ($inactiveTotal > 0)
<div class="p-4 bg-theme-background rounded-lg border border-theme-border">
{{-- The balance line is an amount, so this chart needs
the currency attributes the volume chart uses —
without them formatHours() renders a bare number. --}}
<div data-stats-inactivations-chart="{{ json_encode($inactiveRows) }}"
data-decimal-format="{{ $decimalFormat ? '1' : '0' }}"
data-currency-symbol="{{ platform_currency_symbol() }}"
data-currency-position-end="{{ platform_trans('platform_currency_position_end', null, false) ? '1' : '0' }}"
data-currency-thousand-separator="{{ platform_trans('platform_currency_thousand_separator', null, '') }}"
data-series-labels="{{ json_encode([
'count' => $this->isPartnerScoped() ? __('Partner profiles marked as inactive') : __('Profiles marked as inactive'),
'balance' => __('Total balance of their accounts'),
'incomplete' => $this->isPartnerScoped() ? __('Incomplete partner profiles') : __('Incomplete profiles'),
]) }}"
data-series-colors="{{ json_encode([
'count' => $inactiveColor,
'balance' => $inactiveBalanceColor,
'incomplete' => $incompleteColor,
]) }}"
data-footer-label="{{ __('Marked as inactive this month') }}">
<div class="relative aspect-video">
<canvas id="statsInactivationsChart"></canvas>
</div>
</div>
</div>
@else
{{-- No bordered box: an empty frame reads as a chart that
failed to draw, where this is a real answer. --}}
<p class="text-sm text-theme-secondary">{{ __('No profiles were marked as inactive in this selection.') }}</p>
@endif
</div>
@endif
{{-- Trading network. Deliberately outside the @if (!$hasFilter) branch:
the graph works independently of "Show statistics" and has its own
period selector. The other page filters (location, registration,
profiles) do apply here.
data-network-section is the anchor the JS uses to find controls that
sit above the graph wrapper (search, drag toggle, spread button). --}}
<div class="mt-10" data-network-section>
<div class="cursor-help" title="{{ __('Each profile is a dot, each trading relationship a line. Dot size follows the traded volume, line thickness the number of transactions.') }}">
<h3 class="text-sm font-semibold text-theme-primary uppercase tracking-wide">{{ __('Trading network') }}</h3>
@if ($hasFilter)
<p class="text-xs text-theme-secondary">{{ $this->sectionScope($networkPeriod === 'inherit') }}</p>
@endif
@if ($networkTimelapse && ($lapse = $this->networkTimelapseMonths()))
{{-- The period dropdown is replaced during a time-lapse, so
this names what the animation covers instead. --}}
<p class="text-xs text-theme-secondary">
{{ __('Time-lapse: :from to :till', [
'from' => $lapse[0]['label'],
'till' => $this->timelapseEndDate()?->translatedFormat('j M Y'),
]) }}
</p>
@elseif ($networkPeriod !== 'inherit')
<p class="text-xs text-theme-secondary">
{{ __('Period for this graph:') }} {{ $this->networkPeriodLabel() }}
</p>
@endif
</div>
<div class="mt-3">
<x-jetstream.secondary-button wire:click="toggleNetwork" wire:loading.attr="disabled" wire:target="toggleNetwork">
{{ $showNetwork ? __('Hide network') : __('Show network') }}
</x-jetstream.secondary-button>
{{-- Shown only while the request is in flight: the graph pulls in
~570 kB of lazily loaded JavaScript plus its own aggregation,
so the wait needs acknowledging rather than looking like
nothing happened. --}}
<span class="ml-3 text-xs text-theme-secondary"
wire:loading wire:target="toggleNetwork">
{{ __('Loading... this may take a while.') }}
</span>
</div>
@if ($showNetwork && $network)
@php
$ns = $network['stats'];
$networkPeriods = [
'inherit' => __('Same period as the page'),
'since_one_year_ago' => __('Since one year ago'),
'previous_year' => __('Previous year'),
'previous_quarter' => __('Previous quarter'),
'previous_month' => __('Previous month'),
'all_time' => __('All time'),
];
$typeOptions = [
'User' => __('Users'),
'Organization' => __('Organizations'),
'Bank' => __('Banks'),
];
$layouts = [
'force' => __('Force-directed'),
'circle' => __('Circle'),
'concentric' => __('Rings by connections'),
'tree' => __('Tree'),
'grid' => __('Grid'),
];
// From the component, so this list cannot drift from the one
// the legend and the PDF summary use.
$views = \App\Http\Livewire\Admin\Stats::networkViewOptions();
@endphp
<div class="mt-4 flex flex-wrap items-center gap-4">
{{-- Two sliders instead of fixed buttons: degree runs from 1 to
700, which no fixed set of steps covers. The top stop means
"no upper bound"; above 50 sit only a handful of hubs, and a
linear scale up to 700 would cram 99% of the nodes into the
first percent.
wire:model.change rather than .live: .change only fires on
release, so dragging does not cost a server round trip and a
new layout per pixel. (.lazy is Livewire 2.) --}}
@php
$ceiling = \App\Services\StatsService::DEGREE_CEILING;
@endphp
<div class="flex items-center gap-3">
<span class="text-xs text-theme-secondary whitespace-nowrap">{{ __('Trading partners') }}</span>
<div class="flex items-center gap-2">
<label class="text-xs text-theme-secondary" for="stats-network-min">{{ __('min') }}</label>
<input id="stats-network-min" type="range" min="0" max="{{ $ceiling }}" step="1"
wire:model.change="networkMinDegree" class="w-28 accent-theme-brand">
<span class="text-xs text-theme-primary font-semibold w-6 text-right">{{ $networkMinDegree }}</span>
</div>
<div class="flex items-center gap-2">
<label class="text-xs text-theme-secondary" for="stats-network-max">{{ __('max') }}</label>
<input id="stats-network-max" type="range" min="0" max="{{ $ceiling }}" step="1"
wire:model.change="networkMaxDegree" class="w-28 accent-theme-brand">
<span class="text-xs text-theme-primary font-semibold w-8 text-right">
{{ $networkMaxDegree >= $ceiling ? '>' . $ceiling : $networkMaxDegree }}
</span>
</div>
</div>
{{-- Circulation only: the other views encode nothing about
time, so the slider would have no effect there. --}}
@if ($networkView === 'circulation')
<div class="flex items-center gap-3"
title="{{ __('A profile counts as stopped when it has not paid anyone within this period, or when it is flagged inactive.') }}">
<span class="text-xs text-theme-secondary whitespace-nowrap cursor-help">{{ __('Define \'stopped\' at') }}</span>
@php $stoppedCeiling = \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING; @endphp
<input id="stats-network-stopped" type="range" min="1" max="{{ $stoppedCeiling }}" step="1"
wire:model.change="networkStoppedAfterMonths" class="w-28 accent-theme-brand">
<span class="text-xs text-theme-primary font-semibold whitespace-nowrap">
{{-- ">60 months" rather than "No limit": the top
stop means anything longer than the ceiling
counts as stopped, which "no limit" read as
the opposite of. --}}
@if ($networkStoppedAfterMonths >= $stoppedCeiling)
&gt;{{ $stoppedCeiling }} {{ __('months') }}
@else
{{ $networkStoppedAfterMonths }} {{ __('months') }}
@endif
</span>
</div>
@endif
<div class="flex items-center gap-2">
<x-toggle id="stats-network-hub" name="stats-network-hub" wire:model.live="networkHideHub" />
<span class="text-xs text-theme-secondary">{{ __('Hide the largest hub') }}</span>
</div>
<div class="flex items-center gap-3">
<span class="text-xs text-theme-secondary">{{ __('Show') }}</span>
@foreach ($typeOptions as $type => $label)
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer">
<input type="checkbox" wire:click="toggleNetworkType('{{ $type }}')"
@checked(in_array($type, $networkTypes, true))
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ $label }}
</label>
@endforeach
</div>
{{-- No wire:model: purely browser behaviour. Through Livewire
every click would discard the computed layout. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer">
<input type="checkbox" checked data-network-drag-neighbours
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ __('Drag trading partners along') }}
</label>
{{-- These DO go through Livewire: they change which profiles
the query resolves, so the graph has to be rebuilt. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
title="{{ __('Profiles closed by their owner. Their hours were transferred out on closure, so those flows only appear here.') }}">
<input type="checkbox" wire:model.live="networkIncludeRemoved"
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ __('Include removed profiles') }}
</label>
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
title="{{ __('Profiles marked inactive. Drawn with a red outline.') }}">
<input type="checkbox" wire:model.live="networkMarkInactive"
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ __('Highlight inactive profiles') }}
</label>
{{-- Time-lapse, with the other toggles that change what is
shown. Its own settings live in the playback panel inside
the graph, next to the controls they affect. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
title="{{ __('Step the graph through one month at a time, so a network that is growing or shrinking can be seen rather than inferred.') }}">
<input type="checkbox" wire:model.live="networkTimelapse"
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ __('Time-lapse') }}
</label>
@if ($this->isBankScoped())
<div class="flex items-center gap-2">
<x-toggle id="stats-network-edge" name="stats-network-edge" wire:model.live="networkEdge" />
<span class="text-xs text-theme-secondary">{{ __('Incl. network outside current bank') }}</span>
</div>
@endif
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-theme-secondary">{{ __('Arrangement') }}</span>
@foreach ($layouts as $key => $label)
<button type="button" wire:click="setNetworkLayout('{{ $key }}')"
class="px-2 py-1 text-xs rounded border {{ $networkLayout === $key ? 'border-theme-brand text-theme-primary font-semibold' : 'border-theme-border text-theme-secondary' }}">
{{ $label }}
</button>
@endforeach
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<span class="text-xs text-theme-secondary">{{ __('View') }}</span>
@foreach ($views as $key => $label)
<button type="button" wire:click="setNetworkView('{{ $key }}')"
class="px-2 py-1 text-xs rounded border {{ $networkView === $key ? 'border-theme-brand text-theme-primary font-semibold' : 'border-theme-border text-theme-secondary' }}">
{{ $label }}
</button>
@endforeach
</div>
<p class="mt-2 text-xs text-theme-secondary">
@if (($ns['max_degree'] ?? null) !== null && $ns['max_degree'] < \App\Services\StatsService::DEGREE_CEILING)
{{ __('Showing :shown of :total profiles with :min to :max trading partners.', [
'shown' => number_format($ns['shown_nodes'], 0, ',', '.'),
'total' => number_format($ns['total_nodes'], 0, ',', '.'),
'min' => $ns['min_degree'],
'max' => $ns['max_degree'],
]) }}
{{ __('The busiest profile has :count trading partners.', ['count' => $ns['highest_degree'] ?? 0]) }}
@else
{{ __('Showing :shown of :total profiles with at least :min trading partners.', [
'shown' => number_format($ns['shown_nodes'], 0, ',', '.'),
'total' => number_format($ns['total_nodes'], 0, ',', '.'),
'min' => $ns['min_degree'],
]) }}
@endif
</p>
{{-- Search field and period selector sit OUTSIDE the empty/non-empty
guard: with the period selector inside the else branch, the very
control needed to refill an empty graph would disappear. --}}
<div class="mt-3 mb-3 flex flex-wrap items-start gap-3">
@if ($ns['shown_nodes'] >= 2)
{{-- Searches the drawn graph, not the database. --}}
<div class="relative max-w-sm flex-1 min-w-[16rem]" data-network-search>
<div class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3">
<svg class="h-5 w-5 text-gray-400" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
</svg>
</div>
<input type="search" autocomplete="off" data-network-search-input
placeholder="{{ __('Search a profile in the graph') }}"
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-10 pr-3 leading-5 placeholder-gray-400 shadow-sm transition duration-150 ease-in-out focus:border-theme-border focus:placeholder-gray-700 sm:text-sm">
<div class="absolute z-30 mt-1 w-full rounded-md border border-theme-border bg-white shadow-lg"
data-network-search-results hidden></div>
</div>
@endif
@if ($networkTimelapse)
{{-- While the time-lapse is on, a period means nothing:
every frame accumulates from the first trade. The one
free choice is where the animation stops, so the
period dropdown gives way to that date. Span counts
back from it. The server clamps it to the range of real
trade, so an out-of-range pick snaps to the nearest
valid date rather than drawing an empty graph. --}}
@php
[$tradeFirst, $tradeLast] = $this->tradeRange();
@endphp
{{-- The same flatpickr as the page's own period filter:
stored Y-m-d, shown d-m-Y.
wire:ignore, like that one: flatpickr builds its own
visible input next to this one, and a Livewire morph
would otherwise strip it. The binding still works
through it - flatpickr fires both 'change' and 'input'
on the underlying input when a date is picked.
min/max stop an out-of-range pick at the source. That
matters here in particular: under wire:ignore the
picker could not show the server snapping a bad date
back into range.
Initialised by stats-network.js, because the package
only sets up pickers present at page load and this one
appears when the time-lapse is switched on. --}}
<div class="min-w-[13rem]"
title="{{ __('The date the time-lapse ends on. Span counts back from here.') }}">
<div wire:ignore data-network-timelapse-end>
<x-flatpickr
id="network-timelapse-end"
dateFormat="Y-m-d"
altFormat="d-m-Y"
:value="$networkTimelapseEnd"
:min-date="$tradeFirst?->toDateString()"
:max-date="$tradeLast?->toDateString()"
wire:model.live="networkTimelapseEnd"
placeholder="{{ __('Select end date') }}"
class="block w-full border-gray-300 rounded-md shadow-sm placeholder-theme-light"
/>
</div>
<p class="mt-1 text-xs text-theme-secondary">{{ __('Time-lapse ends on this date.') }}</p>
</div>
@else
{{-- Overrides the period for this graph only. --}}
<div class="min-w-[13rem]">
<select wire:model.live="networkPeriod"
class="block w-full rounded-md border border-theme-border bg-white py-2 pl-3 pr-8 leading-5 shadow-sm transition duration-150 ease-in-out focus:border-theme-border sm:text-sm">
@foreach ($networkPeriods as $key => $label)
<option value="{{ $key }}">{{ $label }}</option>
@endforeach
</select>
@if ($networkPeriod !== 'inherit')
<p class="mt-1 text-xs text-theme-secondary">{{ __('Applies to this graph only.') }}</p>
@endif
</div>
@endif
{{-- Vector export of the graph alone. No wire:click: a Livewire
round trip would rebuild the graph and discard the very
view the user is asking to export. --}}
<button type="button" data-network-svg
data-filename="trading-network"
title="{{ __('Download the graph exactly as shown, as an SVG vector file with editable text.') }}"
class="inline-flex items-center gap-1.5 self-start rounded-md border border-theme-border bg-theme-surface px-3 py-2 text-sm text-theme-primary shadow-sm hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
<x-icon name="arrow-down-tray" class="h-4 w-4" />
{{ __('SVG') }}
</button>
</div>
@if ($ns['shown_nodes'] < 2)
{{-- Two different causes with different remedies: the threshold
filters too strictly, or the selection holds no mutual trade
at all. "Lower the threshold" is useless when it is already
at 1. --}}
<p class="mt-3 text-sm text-theme-secondary">
@if ($ns['total_nodes'] > 0)
{{ __('Too little mutual trading in this selection to draw a network. Lower the minimum number of trading partners.') }}
@elseif ($filterByLastTransaction && $networkPeriod !== 'inherit')
{{-- This combination contradicts itself: profiles with
no transaction since X cannot, by definition, have
traded within a period inside X. --}}
{{ __('This graph period falls inside the inactivity filter, so these profiles have no transactions here by definition. Choose a longer graph period, or switch off "no transaction since".') }}
@else
{{ __('The profiles in this selection did not trade with each other, so there is no network to draw. Widen the filters or the period.') }}
@endif
</p>
@else
<div class="mt-3 p-4 bg-theme-background rounded-lg border border-theme-border"
data-stats-network="{{ json_encode($network) }}"
data-network-meta="{{ json_encode((function () use ($network) {
$l = $this->networkLegend($network);
return [
'caption' => $l['view_caption'] ?? '',
// The legend's explanation line, first in the SVG
// legend as it is on the page and in the PDF.
'hint' => !empty($l['view_hint'])
? ($l['view_label'] ?? '') . ': ' . $l['view_hint']
: null,
// Top right of the SVG export, like the posters.
'logo' => \App\Http\Livewire\Admin\Stats::networkExportLogo(),
'period' => \App\Http\Livewire\Admin\Stats::exportPeriodLabel(
$this->effectiveNetworkPeriodLabel(),
$network['stats']['last_date'] ?? null
),
'colorMeaning' => __('Colour') . ': ' . ($l['color_meaning'] ?? ''),
'colors' => $l['colors'] ?? [],
'sizeLabel' => __('Dot size'),
'sizeMeaning' => $l['size_meaning'] ?? '',
'widthLabel' => __('Line thickness'),
'widthMeaning' => $l['width_meaning'] ?? '',
// Only the currency-flow view populates these; the
// SVG legend was the one surface that never showed them.
'directions' => $l['directions'] ?? [],
'nodeStyles' => $l['node_styles'] ?? [],
'settings' => collect($this->networkSettingsSummary())
->map(fn ($r) => $r['label'] . ': ' . $r['value'])
->implode(' | '),
];
})()) }}"
data-hide-hub="{{ $networkHideHub ? '1' : '0' }}"
data-network-anonymize="{{ $anonymizeProfiles ? '1' : '0' }}"
data-network-mark-inactive="{{ $networkMarkInactive ? '1' : '0' }}"
data-network-timelapse="{{ $networkTimelapse ? '1' : '0' }}"
data-network-timelapse-months="{{ $networkTimelapse ? json_encode($this->networkTimelapseMonths()) : '[]' }}"
{{-- Changes what every frame contains without changing the
month list, so it needs its own place in the rebuild
signature. --}}
data-network-timelapse-cumulative="{{ $networkTimelapseCumulative ? '1' : '0' }}"
data-network-timelapse-step="{{ $networkTimelapseStep }}"
data-network-pseudonyms="{{ json_encode([
'User' => __('User'),
'Organization' => __('Organization'),
]) }}"
data-network-stopped-after="{{ $networkStoppedAfterMonths >= \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING ? '' : $networkStoppedAfterMonths * 30 }}"
{{-- The platform's own currency, so amounts render as the rest
of the site does rather than with a hardcoded suffix. --}}
data-currency="{{ json_encode([
'symbol' => platform_currency_symbol(),
'atEnd' => (bool) platform_trans('platform_currency_position_end', null, false),
'thousands' => platform_trans('platform_currency_thousand_separator', null, ''),
]) }}"
data-cluster-too-large="{{ __('Too many profiles to group into clusters (:count, limit :limit). Narrow the period or raise the minimum trading partners.') }}"
data-network-view="{{ $networkView }}"
data-network-layout="{{ $networkLayout }}"
data-network-labels="{{ json_encode([
'partners' => __('Trading partners'),
'hidden' => __('not shown'),
'drawFailed' => __('The network could not be drawn.'),
'loading' => __('Loading the network...'),
'preparing' => __('Preparing :count profiles...'),
'arranging' => __('Arranging :count profiles...'),
'clustering' => __('Finding clusters in :count profiles, this can take a moment...'),
'drawEmpty' => __('No network to draw for this selection.'),
'timelapseLoading' => __('Loading the time-lapse...'),
'timelapseFailed' => __('The time-lapse could not be loaded.'),
'timelapseEmpty' => __('Nothing to animate for this selection.'),
'layoutFallback' => __('The usual arrangement failed, so a simpler one is shown.'),
'timelapsePlay' => __('Play'),
'timelapsePause' => __('Pause'),
'paid' => __('Paid'),
'received' => __('Received'),
'total' => __('Total traded'),
'flowRole' => __('Hours'),
'stale' => __('Stagnant here'),
'flow' => [
'circulating' => __('Circulating'),
'deadEnd' => __('Dead end'),
'source' => __('Source'),
'isolated' => __('No flow'),
],
'noMatch' => __('No profile found in this graph'),
'noMatchHint' => __('Profiles below the minimum trading partners, or of a type that is not ticked, are not drawn in the graph.'),
'bank' => __('Bank network'),
'cluster' => __('Cluster'),
'profiles' => __('profiles'),
'more' => __('+ :count more'),
]) }}"
wire:key="stats-network-{{ $networkMinDegree }}-{{ $networkMaxDegree }}-{{ $networkHideHub ? 1 : 0 }}-{{ $networkView }}-{{ $networkEdge ? 1 : 0 }}-{{ implode(',', $networkTypes) }}-{{ $networkLayout }}-{{ $networkPeriod }}-{{ $networkIncludeRemoved ? 1 : 0 }}-{{ $networkMarkInactive ? 1 : 0 }}-{{ $networkStoppedAfterMonths }}">
{{-- Info text left, spread button right, on one row. The
paragraphs are hidden until they have something to say,
so the button keeps its place through min-h and
items-start rather than depending on their height. --}}
{{-- Full screen shows this box: the header row and the graph.
In full screen the header floats over the top of the
canvas, so hiding it never resizes the graph; it and the
playback panel fade out after 2s without movement and come
back on any pointer movement, tap or drag (stats-network.js). --}}
<div class="group relative [&:fullscreen]:bg-theme-surface" data-network-screen>
<div class="flex items-start justify-between gap-3 mb-2 min-h-[1.75rem]
group-fullscreen:absolute group-fullscreen:inset-x-0 group-fullscreen:top-0 group-fullscreen:z-30 group-fullscreen:m-0 group-fullscreen:px-4 group-fullscreen:pt-3 group-fullscreen:pb-2 group-fullscreen:bg-theme-surface/90 group-fullscreen:backdrop-blur-sm
transition-opacity duration-300 group-[.controls-hidden]:opacity-0 group-[.controls-hidden]:pointer-events-none"
data-network-header>
<div class="flex-1">
{{-- The question this view answers, above the runtime notes and
sharing their left margin. --}}
<p class="text-xs font-medium text-theme-primary mb-1">
{{ \App\Http\Livewire\Admin\Stats::networkViewCaption($networkView) }}
</p>
{{-- Filled by the circulation classifier, which runs
in the browser on the visible set - the server
cannot know the counts.
NOT hidden when empty, and given a fixed
height: during a time-lapse this line changes
on every frame, and letting it collapse
resized the graph container underneath it
frame by frame. It always has something to
say - at minimum how much is drawn. --}}
<p class="text-xs text-theme-secondary mb-1 min-h-[1rem]" data-network-stagnant
data-drawn-template="{{ __('Showing :nodes profiles and :edges connections.') }}"
data-template="{{ __('Of these, :count are not passing hours on (:never never have), holding :hours in total.') }}"></p>
{{-- Below the subtext, in the warning colour: the graph is
drawn but one view could not be computed.
Reserves its line for the same reason. --}}
<p class="text-xs text-yellow-600 mb-1 min-h-[1rem]" data-network-warning>
<span data-network-warning-text></span>
</p>
<p class="text-xs text-theme-primary" data-network-focus hidden
data-template="{{ __('Showing :name and its :count trading partners. Click the dot again, or the background, to show everything.') }}"></p>
<p class="text-xs text-theme-secondary" data-network-note hidden
data-template="{{ __(':name is hidden: with :count trading partners it would flatten the rest of the graph.') }}"></p>
</div>
<div class="shrink-0 flex items-center gap-1">
<button type="button" data-network-shrink
title="{{ __('Pull closer together') }}"
aria-label="{{ __('Pull closer together') }}"
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
<x-icon name="arrows-pointing-in" class="h-4 w-4" />
</button>
<button type="button" data-network-spread
title="{{ __('Spread out more') }}"
aria-label="{{ __('Spread out more') }}"
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
<x-icon name="arrows-pointing-out" class="h-4 w-4" />
</button>
{{-- Full screen for the graph's own box only (canvas and
playback), not the page. Esc leaves it. Hidden by the
script where the browser cannot do it (iPhone). --}}
<button type="button" data-network-fullscreen
title="{{ __('Full screen') }}"
aria-label="{{ __('Full screen') }}"
data-label-enter="{{ __('Full screen') }}"
data-label-exit="{{ __('Exit full screen (Esc)') }}"
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand">
{{-- Four corner brackets pointing out... --}}
<svg class="h-4 w-4" data-fullscreen-enter viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/>
</svg>
{{-- ...and pointing in, to leave. --}}
<svg class="h-4 w-4 hidden" data-fullscreen-exit viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M9 4v5H4M20 9h-5V4M15 20v-5h5M4 15h5v5"/>
</svg>
</button>
</div>
</div>
{{-- wire:ignore keeps the canvas out of Livewire's morph, so
the zoom, pan and any focused node survive a re-render
(exporting a PDF goes through the server and would
otherwise reset the view). The wrapper's data-*
attributes above are still updated, so changing a
setting rebuilds the graph as normal. --}}
{{-- The placeholder is rendered inside the host so it is
visible the moment the element exists. Cytoscape empties
the host when it mounts, which removes it - no JS needed
to hide it, and nothing to leave behind on failure. --}}
{{-- data-network-frame: what full screen shows. The browser
sizes it to the screen; its background must be set, or the
black full-screen backdrop shows through the canvas. --}}
<div class="relative w-full h-[560px] group-fullscreen:h-full" data-network-frame>
<div class="absolute inset-0" wire:ignore data-network-host>
<div class="flex h-full items-center justify-center" data-network-placeholder>
<span class="text-sm text-theme-secondary">{{ __('Drawing the network...') }}</span>
</div>
</div>
@if ($networkTimelapse)
{{-- Playback sits INSIDE the graph, bottom left:
it steers what the canvas shows, so it belongs
with the picture rather than up among the
filters that rebuild it.
Outside wire:ignore deliberately - Livewire
must keep this markup in step when the span or
window changes - but absolutely positioned over
the host, which is the element Cytoscape owns
and empties on mount.
Driven entirely by JS: a Livewire round trip
per frame would rebuild the graph and throw
away the union layout the animation needs. --}}
<div class="absolute bottom-3 left-3 z-10 flex flex-wrap items-center gap-x-3 gap-y-1.5
max-w-[calc(100%-1.5rem)]
rounded-md border border-theme-border bg-theme-surface/90
px-2 py-1.5 shadow-sm backdrop-blur-sm
transition-opacity duration-300 group-[.controls-hidden]:opacity-0 group-[.controls-hidden]:pointer-events-none"
data-network-timelapse-controls>
{{-- One wrapping row, playback first: the
settings follow the speed slider and drop
to a second line only when the panel runs
out of width. max-w keeps it inside the
graph rather than spilling past the edge. --}}
{{-- wire:ignore around everything the JS drives
- play, scrubber, month label, speed. They
sit outside the graph's own wire:ignore, so
any Livewire update that did not change the
graph put back the server's version: the
spinner reappeared, the scrubber jumped to 0
and the speed slider to its default, while
the player ran on and nothing restored them.
The JS now sets the busy state itself when a
rebuild starts. `contents` keeps the panel's
single wrapping row. Accumulate and Span stay
outside: those are Livewire settings. --}}
<div class="contents" wire:ignore data-timelapse-playback>
{{-- Both icons are rendered and one is hidden,
rather than having the JS write icon markup:
the SVG stays in Blade where the rest of the
icons live. The label travels on aria-label
and title, which the JS swaps with the
icons. --}}
<div class="flex items-center gap-2">
{{-- Starts disabled, with a spinner: the
frames are still being built when this
first renders, and a Play button that
looks ready but does nothing is worse
than one that says it is busy. The JS
enables it once the graph is drawn. --}}
<button type="button" data-timelapse-play disabled
title="{{ __('Loading the time-lapse...') }}"
aria-label="{{ __('Loading the time-lapse...') }}"
class="inline-flex items-center justify-center rounded-md border border-theme-border bg-theme-surface p-1.5 text-theme-primary hover:bg-theme-brand hover:text-white focus:outline-none focus:ring-2 focus:ring-theme-brand disabled:cursor-wait disabled:opacity-60 disabled:hover:bg-theme-surface disabled:hover:text-theme-primary">
<x-icon name="play" class="h-4 w-4 hidden" data-timelapse-icon-play />
<x-icon name="pause" class="h-4 w-4 hidden" data-timelapse-icon-pause />
<svg class="h-4 w-4 animate-spin" data-timelapse-icon-busy
viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10"
stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor"
d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z"></path>
</svg>
</button>
<input type="range" data-timelapse-scrubber min="0" max="0" step="1" value="0"
class="w-32 accent-theme-brand">
{{-- Fixed width for the longest label ("30 Sep 2026" on
weekly steps) and never wrapped or squeezed, so the
controls beside it do not shift as the date changes. --}}
<span class="text-xs text-theme-primary font-semibold w-24 shrink-0 whitespace-nowrap tabular-nums text-right"
data-timelapse-year></span>
</div>
{{-- A SPEED, so slow is on the left and fast
on the right. 0-100 on a log scale from 0.5
to 30 months a second - see speedFromSlider()
in stats-network.js. 36 is ~2.2 months a
second, the pace the slider used to default
to. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-secondary"
title="{{ __('Months shown per second. Slide right to play faster.') }}">
{{ __('Speed') }}
<input type="range" data-timelapse-speed
min="0" max="100" step="1" value="36"
class="w-20 accent-theme-brand">
<span class="w-10 text-right" data-timelapse-speed-value>2.2/s</span>
</label>
</div>
{{-- Off, each frame holds only its own month's
trade: a window exactly one frame long. Goes
through Livewire, because it changes what
the server puts in every frame. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary cursor-pointer"
title="{{ __('On: each frame shows all trade up to that month, so the network builds up. Off: each frame shows only that month\'s trade.') }}">
<input type="checkbox" wire:model.live="networkTimelapseCumulative"
class="rounded border-theme-border text-theme-brand focus:ring-theme-brand">
{{ __('Accumulate') }}
</label>
{{-- Step: how far each frame moves on. The layers
stay months either way; weekly only moves
them in smaller, smoother steps. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary"
title="{{ __('How far each frame moves on. The layers stay months either way: a weekly step only moves them more smoothly.') }}">
{{ __('Step') }}
<select wire:model.live="networkTimelapseStep"
class="rounded-md border-theme-border bg-theme-surface py-0.5 pl-1.5 pr-6 text-xs text-theme-primary focus:border-theme-brand focus:ring-theme-brand">
<option value="month">{{ __('Month') }}</option>
<option value="week">{{ __('Week') }}</option>
</select>
</label>
{{-- Span: how far back the animation runs.
Every frame accumulates, so this only sets
where it starts, not what each frame
contains. --}}
<label class="inline-flex items-center gap-1.5 text-xs text-theme-primary"
title="{{ __('How far back the animation runs.') }}">
{{ __('Span') }}
<select wire:model.live="networkTimelapseSpan"
class="rounded-md border-theme-border bg-theme-surface py-0.5 pl-1.5 pr-6 text-xs text-theme-primary focus:border-theme-brand focus:ring-theme-brand">
<option value="12">{{ __(':count year', ['count' => 1]) }}</option>
<option value="24">{{ __(':count years', ['count' => 2]) }}</option>
<option value="36">{{ __(':count years', ['count' => 3]) }}</option>
<option value="60">{{ __(':count years', ['count' => 5]) }}</option>
<option value="120">{{ __(':count years', ['count' => 10]) }}</option>
<option value="0">{{ __('All time') }}</option>
</select>
</label>
</div>
@endif
</div>
</div>{{-- data-network-screen --}}
</div>
@php
$legend = $this->networkLegend($network);
@endphp
<div class="mt-3 p-3 bg-theme-surface rounded-md border border-theme-border">
<div class="text-xs uppercase tracking-wide text-theme-secondary mb-2">{{ __('Legend') }}</div>
{{-- What the current view encodes. Volume and flow differ
in both dot size and line meaning, which the graph
alone does not reveal. --}}
@if (!empty($legend['view_hint']))
<p class="text-xs text-theme-primary mb-3">
<span class="font-semibold">{{ $legend['view_label'] ?? '' }}:</span>
{{ $legend['view_hint'] }}
</p>
@endif
<div class="flex flex-wrap gap-x-8 gap-y-3">
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Colour') }}: {{ $legend['color_meaning'] }}</div>
{{-- Clusters are computed in the browser, so their
colours and sizes are filled in by JS. --}}
@if ($networkView === 'cluster')
<div class="flex flex-wrap items-center gap-x-4 gap-y-1"
data-network-cluster-legend hidden></div>
@endif
@if (!empty($legend['colors']))
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@foreach ($legend['colors'] as $item)
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
<span class="inline-block w-3 h-3 rounded-full"
style="background-color: {{ $item['color'] }};{{ !empty($item['dashed']) ? ' border: 2px dashed #64748b;' : '' }}"></span>
{{ $item['label'] }}
</span>
@endforeach
</div>
@endif
</div>
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Dot size') }}: {{ $legend['size_meaning'] }}</div>
<div class="flex items-center gap-2 text-xs text-theme-secondary">
<span class="inline-block rounded-full bg-theme-primary" style="width: 6px; height: 6px;"></span>
<span class="inline-block rounded-full bg-theme-primary" style="width: 11px; height: 11px;"></span>
<span class="inline-block rounded-full bg-theme-primary" style="width: 17px; height: 17px;"></span>
<span class="ml-1">{{ __('small to large') }}</span>
</div>
</div>
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Line thickness') }}: {{ $legend['width_meaning'] }}</div>
<div class="flex items-center gap-2 text-xs text-theme-secondary">
<span class="inline-block bg-theme-primary" style="width: 22px; height: 1px;"></span>
<span class="inline-block bg-theme-primary" style="width: 22px; height: 3px;"></span>
<span class="inline-block bg-theme-primary" style="width: 22px; height: 6px;"></span>
<span class="ml-1">{{ __('few to many') }}</span>
</div>
</div>
{{-- Node outline styles, when a toggle draws them. --}}
@if (!empty($legend['node_styles']))
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Profile state') }}</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@foreach ($legend['node_styles'] as $style)
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
<span class="inline-block w-3 h-3 rounded-full"
style="border: 2px {{ $style['dashed'] ? 'dashed' : 'solid' }} {{ $style['color'] }};{{ $style['hollow'] ? '' : ' background-color: #94a3b8;' }}"></span>
{{ $style['label'] }}
</span>
@endforeach
</div>
</div>
@endif
@if (!empty($legend['directions']))
<div>
<div class="text-xs text-theme-secondary mb-1">{{ __('Transaction type') }}</div>
<div class="flex flex-wrap items-center gap-x-4 gap-y-1">
@foreach ($legend['directions'] as $dir)
<span class="inline-flex items-center gap-1.5 text-xs text-theme-primary">
{{-- Dashed for bookkeeping edges, matching the graph. --}}
@if (!empty($dir['dashed']))
<span class="inline-block" style="width: 22px; height: 0; border-top: 3px dashed {{ $dir['color'] }};"></span>
@else
<span class="inline-block" style="width: 22px; height: 3px; background-color: {{ $dir['color'] }};"></span>
@endif
{{ $dir['label'] }}
</span>
@endforeach
</div>
<div class="mt-1 text-xs text-theme-secondary">
{{ __('An arrow runs from payer to receiver, coloured by transaction type. A pair that traded two types gets an arrow for each, fanned out so they do not overlap.') }}
</div>
</div>
@endif
</div>
<p class="mt-2 text-xs text-theme-secondary">
{{ __('Hover over a dot to see its name and its trading partners.') }}
{{ __('Click a dot to show only that profile and the ones it trades with.') }}
{{ __('Drag a dot to spread the graph out; its trading partners move along unless you switch that off.') }}
{{ __('Use the search box to find a profile in this graph.') }}
</p>
</div>
@endif
@endif
</div>
</div>