{{-- Filters --}}

{{ __('Filters') }}

{{ __('Clear filters') }}

{{ __('Filters narrow the selection: a profile must match every active filter.') }}

{{-- Location --}}
@if ($filterByLocation)
@endif
{{-- Registration date --}}
@if ($filterByRegistration)
@endif
{{-- 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
@if ($filterByLastLogin)
@if ($lastLoginPeriod === 'custom')
@endif

{{ __('Includes profiles that have never logged in. Banks have no login and are left out.') }}

@endif
@if ($filterByLastTransaction)
@if ($lastTransactionPeriod === 'custom')
@endif

{{ __('System entries such as migrations and rounding corrections do not count as activity.') }}

@endif
{{-- Counterparties --}}
@if ($filterByContacts)

{{ __('Only include profiles that have transacted with the selected profile(s).') }}

@if (count($selectedContactProfiles) > 0)
@foreach ($selectedContactProfiles as $index => $profile) {{ $profile['name'] }} @endforeach
@endif @endif
{{-- 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. --}}
{{ __('Anonymise profile names') }}
{{ __('Decimal notation') }}
@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. --}}

{{ __('Statistics') }}

{{ __('Statistics are calculated over the profiles matching your selection.') }}

{{ __('Show statistics') }} {{ __('Calculating...') }} {{ __('Select a period or a filter first, then calculate the tables and charts.') }}
@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. --}}
{{ __('Refresh') }} {{ __('Export PDF') }} {{ __('Preparing...') }}
{{-- What these numbers actually cover --}}
{{ __('Showing') }} @foreach ($this->activeFilterSummary() as $item) {{ $item['label'] }}: {{ $item['value'] }} @endforeach
{{-- 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. --}}
@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)
{{ $tile['label'] }}
{{ $tile['value'] }}
@endforeach
{{-- Economic --}}

{{ __('Economic activity') }}

{{ $this->sectionScope() }}

{{-- Only shown when the selection can actually have an "outside"; see split_is_meaningful in StatsService::economic(). --}} @if ($stats['economic']['split_is_meaningful'] ?? false)

{{ __('Internal:') }} {{ $this->formatAmount($stats['economic']['internal_volume']) }}  ·  {{ __('External:') }} {{ $this->formatAmount($stats['economic']['external_volume']) }}

{{ __('Internal transactions happen between two profiles inside the selection; external ones have one side outside it.') }}

@endif @if ($stats['economic']['by_type']->isNotEmpty())
@foreach ($stats['economic']['by_type'] as $type) @endforeach
{{ __('Transaction type') }} {{ __('Count') }} {{ __('Volume') }}
{{ $type['type_name'] }} {{ number_format($type['count'], 0, ',', '.') }} {{ $this->formatAmount($type['volume']) }}
@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)
{{-- The sized wrapper is what Chart.js measures (maintainAspectRatio is off), so the axis labels get room instead of being clipped. --}}
@else

{{ __('No data available for this chart.') }}

@endif @endif
{{-- Online --}}

{{ $this->isPartnerScoped() ? __('Partner profiles and activity') : __('Profiles and activity') }}

{{ $this->sectionScope() }}

@php // The hint belongs to the whole column, so it goes on every cell // as well as the header — a title on 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
@foreach ($columnLabels as $key => $label) @endforeach @foreach ($stats['online']['by_type'] as $typeName => $row) @endforeach
{{ $this->isPartnerScoped() ? __('Partner profile type') : __('Profile type') }}{{ $label }}
{{ __($typeName) }} {{ number_format($row['total'], 0, ',', '.') }} {{ number_format($row['active'], 0, ',', '.') }} {{ number_format($row['inactive'], 0, ',', '.') }} {{ $row['recent_logins'] === null ? '-' : number_format($row['recent_logins'], 0, ',', '.') }}
@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

{{ __('New registrations per month') }}

@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
@foreach ($registrationCharts as $chart) @php $regTotal = $registrationRows->sum(fn($row) => $row[$chart['field']] ?? 0); @endphp @if ($regTotal > 0)
{{ $chart['label'] }}
@else

{{ $chart['label'] }}: {{ __('No data available for this chart.') }}

@endif @endforeach
@else

{{ __('No profile in this selection registered during this period. The totals above cover the whole selection, whenever each profile signed up.') }}

@endif
@endif
{{-- Engagement --}}

{{ __('Engagement') }}

{{ $this->sectionScope() }}

@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
@foreach ($engagementMetrics as $metric)
{{ $metric['label'] }}
{{ number_format($stats['engagement'][$metric['field']], 0, ',', '.') }}
@endforeach
@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. --}}
@foreach ($engagementMetrics as $metric) @php $metricRows = collect($stats['engagement']['timeline']); $metricTotal = $metricRows->sum(fn($row) => $row[$metric['field']] ?? 0); @endphp @if ($metricTotal > 0)

{{ $metric['label'] }}

@else

{{ $metric['label'] }}: {{ __('No data available for this chart.') }}

@endif @endforeach
@else

{{ __('No data available for this chart.') }}

@endif
{{-- Balance distribution --}} @if (($stats['balance_distribution']['profiles_counted'] ?? 0) > 0)

{{ __('Balance distribution') }}

{{ $this->sectionScope(false) }}

@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

@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. --}}
@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)

{{ $chart['label'] }}

{{-- Taller than the timelines: the angled band labels need the extra room. --}}
@else {{-- No bordered box: an empty frame reads as a chart that failed to draw. --}}

{{ $chart['label'] }}: {{ __('No profiles of this type in the selection.') }}

@endif @endforeach
@endif {{-- Distribution --}}

{{ $this->isPartnerScoped() ? __('Most active partner profiles') : __('Most active profiles') }}

{{ $this->sectionScope() }}

@if (!empty($stats['distribution']['excluded_profiles']))

{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}

@endif @if ($stats['distribution']['top_profiles']->isEmpty())

{{ __('No activity in this selection.') }}

@else
@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) @endforeach @foreach ($stats['distribution']['top_profiles'] as $profile) @endforeach
{{ $this->profilesLabel(false) }} {{ __('Type') }}
{{ $anonymizeProfiles ? $profile['anonymous_name'] : $profile['name'] }} {{ __($profile['type']) }} {{ number_format($profile['paid_count'], 0, ',', '.') }} {{ number_format($profile['received_count'], 0, ',', '.') }} {{ $this->formatAmount($profile['paid_volume']) }} {{ $this->formatAmount($profile['received_volume']) }} {{ number_format($profile['partners'] ?? 0, 0, ',', '.') }} {{ number_format($profile['count'], 0, ',', '.') }}
@endif
@if ($stats['distribution']['by_location']->isNotEmpty())

{{ __('Activity by location') }}

{{ $this->sectionScope() }}

@if (!empty($stats['distribution']['excluded_profiles']))

{{ __(':profiles excluded from this table.', ['profiles' => implode(', ', $stats['distribution']['excluded_profiles'])]) }}

@endif
@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) @endforeach @foreach ($stats['distribution']['by_location'] as $row) @endforeach
{{ __('Location') }}
{{ $row['label'] ?: __('Location not specified') }} {{ number_format($row['paid_count'], 0, ',', '.') }} {{ number_format($row['received_count'], 0, ',', '.') }} {{ $this->formatAmount($row['paid_volume']) }} {{ $this->formatAmount($row['received_volume']) }} {{ number_format($row['count'], 0, ',', '.') }}
@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

{{ $this->isPartnerScoped() ? __('Partner profiles marked as inactive over time') : __('Profiles marked as inactive over time') }}

{{ $this->sectionScope() }}

{{-- 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. --}}
@foreach (\App\Services\StatsService::chartLegendNotes() as $note)

{{ $note }}

@endforeach
@if ($inactiveTotal > 0)
{{-- The balance line is an amount, so this chart needs the currency attributes the volume chart uses — without them formatHours() renders a bare number. --}}
@else {{-- No bordered box: an empty frame reads as a chart that failed to draw, where this is a real answer. --}}

{{ __('No profiles were marked as inactive in this selection.') }}

@endif
@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). --}}

{{ __('Trading network') }}

@if ($hasFilter)

{{ $this->sectionScope($networkPeriod === 'inherit') }}

@endif @if ($networkTimelapse && ($lapse = $this->networkTimelapseMonths())) {{-- The period dropdown is replaced during a time-lapse, so this names what the animation covers instead. --}}

{{ __('Time-lapse: :from to :till', [ 'from' => $lapse[0]['label'], 'till' => $this->timelapseEndDate()?->translatedFormat('j M Y'), ]) }}

@elseif ($networkPeriod !== 'inherit')

{{ __('Period for this graph:') }} {{ $this->networkPeriodLabel() }}

@endif
{{ $showNetwork ? __('Hide network') : __('Show network') }} {{-- 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. --}} {{ __('Loading... this may take a while.') }}
@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
{{-- 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
{{ __('Trading partners') }}
{{ $networkMinDegree }}
{{ $networkMaxDegree >= $ceiling ? '>' . $ceiling : $networkMaxDegree }}
{{-- Circulation only: the other views encode nothing about time, so the slider would have no effect there. --}} @if ($networkView === 'circulation')
{{ __('Define \'stopped\' at') }} @php $stoppedCeiling = \App\Http\Livewire\Admin\Stats::STOPPED_AFTER_CEILING; @endphp {{-- ">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) >{{ $stoppedCeiling }} {{ __('months') }} @else {{ $networkStoppedAfterMonths }} {{ __('months') }} @endif
@endif
{{ __('Hide the largest hub') }}
{{ __('Show') }} @foreach ($typeOptions as $type => $label) @endforeach
{{-- No wire:model: purely browser behaviour. Through Livewire every click would discard the computed layout. --}} {{-- These DO go through Livewire: they change which profiles the query resolves, so the graph has to be rebuilt. --}} {{-- 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. --}} @if ($this->isBankScoped())
{{ __('Incl. network outside current bank') }}
@endif
{{ __('Arrangement') }} @foreach ($layouts as $key => $label) @endforeach
{{ __('View') }} @foreach ($views as $key => $label) @endforeach

@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

{{-- 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. --}}
@if ($ns['shown_nodes'] >= 2) {{-- Searches the drawn graph, not the database. --}}
@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. --}}

{{ __('Time-lapse ends on this date.') }}

@else {{-- Overrides the period for this graph only. --}}
@if ($networkPeriod !== 'inherit')

{{ __('Applies to this graph only.') }}

@endif
@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. --}}
@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. --}}

@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

@else
{{-- 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). --}}
{{-- The question this view answers, above the runtime notes and sharing their left margin. --}}

{{ \App\Http\Livewire\Admin\Stats::networkViewCaption($networkView) }}

{{-- 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. --}}

{{-- 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. --}}

{{-- 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). --}}
{{-- 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. --}}
{{ __('Drawing the network...') }}
@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. --}}
{{-- 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. --}}
{{-- 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. --}}
{{-- 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. --}} {{-- 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. --}}
{{-- 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. --}}
{{-- 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. --}} {{-- Step: how far each frame moves on. The layers stay months either way; weekly only moves them in smaller, smoother steps. --}} {{-- Span: how far back the animation runs. Every frame accumulates, so this only sets where it starts, not what each frame contains. --}}
@endif
{{-- data-network-screen --}}
@php $legend = $this->networkLegend($network); @endphp
{{ __('Legend') }}
{{-- 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']))

{{ $legend['view_label'] ?? '' }}: {{ $legend['view_hint'] }}

@endif
{{ __('Colour') }}: {{ $legend['color_meaning'] }}
{{-- Clusters are computed in the browser, so their colours and sizes are filled in by JS. --}} @if ($networkView === 'cluster') @endif @if (!empty($legend['colors']))
@foreach ($legend['colors'] as $item) {{ $item['label'] }} @endforeach
@endif
{{ __('Dot size') }}: {{ $legend['size_meaning'] }}
{{ __('small to large') }}
{{ __('Line thickness') }}: {{ $legend['width_meaning'] }}
{{ __('few to many') }}
{{-- Node outline styles, when a toggle draws them. --}} @if (!empty($legend['node_styles']))
{{ __('Profile state') }}
@foreach ($legend['node_styles'] as $style) {{ $style['label'] }} @endforeach
@endif @if (!empty($legend['directions']))
{{ __('Transaction type') }}
@foreach ($legend['directions'] as $dir) {{-- Dashed for bookkeeping edges, matching the graph. --}} @if (!empty($dir['dashed'])) @else @endif {{ $dir['label'] }} @endforeach
{{ __('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.') }}
@endif

{{ __('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.') }}

@endif @endif