@@ -7,14 +7,18 @@
type PeriodType = components [ 'schemas' ][ 'PeriodType' ];
let { data , periodType } : { data : Point []; periodType : PeriodType } = $props ();
let active = $state < { bucket : number ; status : TransactionStatus } | null> ( null );
let activeBucket = $state < number | null> (null );
const width = 760 ;
const height = 300 ;
const pad = { top : 24 , right : 24 , bottom : 54 , left : 62 };
const plotWidth = width - pad . left - pad . right ;
const plotHeight = height - pad . top - pad . bottom ;
const hour = new Intl . NumberFormat ( 'fa-IR' , { minimumIntegerDigits : 2 , maximumFractionDigits : 0 , useGrouping : false });
const hour = new Intl . NumberFormat ( 'fa-IR' , {
minimumIntegerDigits : 2 ,
maximumFractionDigits : 0 ,
useGrouping : false ,
});
const date = new Intl . DateTimeFormat ( 'fa-IR-u-ca-persian' , { month : 'short' , day : 'numeric' });
const statusColors : Record < TransactionStatus , string > = {
@@ -23,6 +27,12 @@
Failed : '#e11d48' ,
Reversed : '#d97706' ,
};
const statusLinePatterns : Record < TransactionStatus , { dash ?: string ; offset ?: number }> = {
Paid : {},
Verified : { dash : '13 5' , offset : 0 },
Failed : { dash : '6 5' , offset : 2 },
Reversed : { dash : '2 6' , offset : 4 },
};
function countFor ( point : Point , status : TransactionStatus ) {
if ( status === 'Paid' ) return point . paidSessions ;
@@ -31,23 +41,44 @@
return point . reversedSessions ;
}
const maxCount = $derived ( Math . max ( 1 , ... data . flatMap (( point ) => statusOrder . map (( status ) => countFor ( point , status )))));
const series = $derived ( statusOrder . map (( status ) => ({
status ,
points : data.map (( point , bucket ) => ({
bucket ,
point ,
cou nt : countFor ( point , status ),
x : pad.left + ( bucket * plotWidth ) / Math . max ( 1 , data . length - 1 ) ,
y : pad.top + plotHeight - ( countFor ( point , status ) / maxCount ) * plotHeigh t,
const maxCount = $derived (
Math . max ( 1 , ... data . flatMap (( point ) => statusOrder . map (( status ) => countFor ( point , status )))),
);
const series = $derived (
statusOrder . map (( status ) => ({
status ,
poi nts : data.map (( point , bucket ) => ({
bucket ,
poin t ,
count : countFor ( point , status ),
x : pad.left + ( bucket * plotWidth ) / Math . max ( 1 , data . length - 1 ),
y : pad.top + plotHeight - ( countFor ( point , status ) / maxCount ) * plotHeight ,
})),
})),
})) );
);
const xTickIndexes = $derived . by (() => {
const tickCount = Math . min ( 6 , data . length );
if ( tickCount <= 1 ) return data . length ? [ 0 ] : [];
return [... new Set ( Array . from ({ length : tickCount }, ( _ , index ) => Math . round (( index * ( data . length - 1 )) / ( tickCount - 1 ))))];
return [
... new Set (
Array . from ({ length : tickCount }, ( _ , index ) =>
Math . round (( index * ( data . length - 1 )) / ( tickCount - 1 )),
),
),
];
});
const activePoint = $derived ( active === null ? null : series . find (({ status }) => status === active . status ) ? . points [ active . bucket ] ?? null );
const hoverZones = $derived (
data . map (( point , bucket ) => {
const x = series [ 0 ] ? . points [ bucket ] ? . x ?? pad . left ;
const previousX = series [ 0 ] ? . points [ bucket - 1 ] ? . x ;
const nextX = series [ 0 ] ? . points [ bucket + 1 ] ? . x ;
const start = previousX === undefined ? pad . left : ( previousX + x ) / 2 ;
const end = nextX === undefined ? width - pad . right : ( x + nextX ) / 2 ;
return { bucket , point , x , start , width : end - start };
}),
);
const activePoint = $derived ( activeBucket === null ? null : ( data [ activeBucket ] ?? null ));
const activeX = $derived ( activeBucket === null ? null : ( series [ 0 ] ? . points [ activeBucket ] ? . x ?? null ));
function bucketLabel ( point : Point ) {
if ( periodType === 'DAY' && point . localHour !== undefined ) return ` ${ hour . format ( point . localHour ) } :۰ ۰ ` ;
@@ -62,53 +93,210 @@
return points . map (( point , index ) => ` ${ index ? 'L' : 'M' } ${ point . x } ${ point . y } ` ). join ( ' ' );
}
function tooltipStyle ( point : NonNullable < typeof activePoint > ) {
const horizontal = ( point . x / width ) * 100 ;
const vertical = ( point . y / height ) * 100 ;
const below = vertical < 35 ;
return `left:clamp(6rem, ${ horizontal } %,calc(100% - 6rem));top: ${ below ? vertical + 5 : vertical - 4 } %;transform:translate(-50%, ${ below ? '0' : '-100%' } );` ;
function collisionRadius ( point : Point , status : TransactionStatus , bucket : number ) {
const count = countFor ( point , status ) ;
const collisions = statusOrder . filter (( candidate ) => countFor ( point , candidate ) === count ) ;
const ring = collisions . indexOf ( status ) ;
const base = activeBucket === bucket ? 5.5 : 3.25 ;
return base + ( collisions . length - ring - 1 ) * 2.75 ;
}
function tooltipStyle ( x : number ) {
return `left:clamp(7rem, ${ ( x / width ) * 100 } %,calc(100% - 7rem));top:0.75rem;transform:translateX(-50%);` ;
}
function bucketAria ( point : Point ) {
const values = statusOrder
. map (( status ) => {
const count = countFor ( point , status );
return ` ${ statusLabels [ status ] } ${ formatValue ( count ) } تراکنش ${ formatPercent ( share ( point , count )) } ` ;
})
. join ( '، ' );
return ` ${ bucketLabel ( point ) } ، ${ values } ` ;
}
</ script >
< div class = "space-y-5" >
< div class = "flex flex-wrap gap-x-5 gap-y-2 text-xs" aria-label = "راهنمای رنگ وضعیتها" >
{ #each statusOrder as status }
< span class = "inline-flex items-center gap-2" >< span class = "size-2.5 rounded-full" style = { `background: ${ statusColors [ status ] } ` } aria-hidden="true" ></ span > { statusLabels [ status ]} </ span >
< span class = "inline-flex items-center gap-2"
>< span class = "size-2.5 rounded-full" style = { `background: ${ statusColors [ status ] } ` } aria-hidden="true"
></ span > { statusLabels [ status ]} </ span
>
{ /each }
</ div >
<!-- svelte-ignore a11y_no_noninteractive_tabindex (the horizontally scrollable chart must be keyboard reachable) -->
< div class = "w-full overflow-x-auto overscroll-x-contain" dir = "ltr" role = "region" aria-label = "نمودار خطی روند وضعیت تراکنشها، قابل پیمایش افقی" tabindex = "0" >
< div class = "relative min-h-[310px] min-w-[760px] md:min-w-0" >
< div
class = "w-full overflow-x-auto overscroll-x-contain"
dir = "ltr"
role = "region"
aria-label = "نمودار خطی روند وضعیت تراکنشها، قابل پیمایش افقی"
tabindex = "0"
>
< div
class = "relative min-h-[310px] min-w-[760px] md:min-w-0"
role = "presentation"
onmouseleave = {() => ( activeBucket = null )}
>
{ #if data . length === 0 }
< div class = "text-muted-foreground grid h-[280px] place-items-center text-sm" role = "status" > دادهای برای این بازه ثبت نشده است.</ div >
< div class = "text-muted-foreground grid h-[280px] place-items-center text-sm" role = "status" >
دادهای برای این بازه ثبت نشده است.
</ div >
{ : else }
< svg viewBox = { `0 0 ${ width } ${ height } ` } class="h-auto min-h- [ 300px ] w-full overflow-visible " role = "img" aria-label = "روند زمانی تعداد تراکنشها در چهار وضعیت" >
< svg
viewBox = { `0 0 ${ width } ${ height } ` }
class="h-auto min-h- [ 300px ] w-full overflow-visible "
role = "img"
aria-label = "روند زمانی تعداد تراکنشها در چهار وضعیت"
>
{ #each [ 0 , 1 , 2 , 3 , 4 ] as row }
{ @const y = pad . top + ( row * plotHeight ) / 4 }
< line x1 = { pad . left } x2= { width - pad . right } y1 = { y } y2= { y } stroke = "var(--border)" stroke-dasharray = "4 6" />
< text x = { pad . left - 10 } y= { y + 4 } fill = "var(--muted-foreground)" font-size = "10" text-anchor = "end" > { formatValue ( Math . round ( maxCount * ( 1 - row / 4 )))} </ text >
< line
x1 = { pad . left }
x2= { width - pad . right }
y1 = { y }
y2= { y }
stroke = "var(--border)"
stroke-dasharray = "4 6"
/>
< text x = { pad . left - 10 } y= { y + 4 } fill = "var(--muted-foreground)" font-size = "10" text-anchor = "end"
> { formatValue ( Math . round ( maxCount * ( 1 - row / 4 )))} </ text
>
{ /each }
< line x1 = { pad . left } x2= { pad . left } y1 = { pad . top } y2= { height - pad . bottom } stroke = "var(--border)" />
< line x1 = { pad . left } x2= { width - pad . right } y1 = { height - pad . bottom } y2= { height - pad . bottom } stroke = "var(--border)" />
< line
x1 = { pad . left }
x2= { width - pad . right }
y1 = { height - pad . bottom }
y2= { height - pad . bottom }
stroke = "var(--border)"
/>
{ #each xTickIndexes as tickIndex }
{ @const tick = series [ 0 ] ? . points [ tickIndex ]}
{ #if tick } < text x = { tick . x } y= { height - pad . bottom + 20 } fill = "var(--muted-foreground)" font-size = "9" text-anchor = "middle" direction = "rtl" > { bucketLabel ( tick . point )} </ text > { /if }
{ #if tick } < text
x = { tick . x }
y= { height - pad . bottom + 20 }
fill = "var(--muted-foreground)"
font-size = "9"
text-anchor = "middle"
direction = "rtl" > { bucketLabel ( tick . point )} </ text
> { /if }
{ /each }
< text x = {( pad . left + width - pad . right ) / 2 } y= { height - 5 } fill = "var(--muted-foreground)" font-size = "10" text-anchor = "middle" direction = "rtl" > { periodType === 'DAY' ? 'ساعت' : 'زمان' } </ text >
< text x = {- ( ( pad . top + height - pad . bottom ) / 2 )} y="13" fill = "var(--muted-foreground)" font-size = "10" text-anchor = "middle" transform = "rotate(-90)" direction = "rtl" > تعداد تراکنش</ text >
< text
x = {( pad . left + width - pad . right ) / 2 }
y= { height - 5 }
fill = "var(--muted-foreground)"
font-size = "10"
text-anchor = "middle"
direction = "rtl" > { periodType === 'DAY' ? 'ساعت' : 'زمان' } </ text
>
< text
x = { - (( pad . top + height - pad . bottom ) / 2 )}
y="13"
fill = "var(--muted-foreground)"
font-size = "10"
text-anchor = "middle"
transform = "rotate(-90)"
direction = "rtl" > تعداد تراکنش</ text
>
{ #each series as statusSeries }
< path d = { linePath ( statusSeries . points )} fill="none" stroke = { statusColors [ statusSeries . status ]} stroke-width="2.5" stroke-linecap = "round" stroke-linejoin = "round" class = "status-line" />
{ #each statusSeries . points as point }
< circle cx = { point . x } cy= { point . y } r = { active ? . bucket === point . bucket && active . status === statusSeries . status ? 5.5 : 3.25 } fill="var(--card)" stroke = { statusColors [ statusSeries . status ]} stroke-width="2.5" tabindex = "0" role = "button" aria-label = { ` ${ bucketLabel ( point . point ) } ، ${ statusLabels [ statusSeries . status ] } ، ${ formatValue ( point . count ) } تراکنش، ${ formatPercent ( share ( point . point , point . count )) } ` } onmouseenter= {() => ( active = { bucket : point.bucket , status : statusSeries.status })} onmouseleave = {() => ( active = null )} onfocus= {() => ( active = { bucket : point.bucket , status : statusSeries.status })} onblur = {() => ( active = null )} / >
{ @const pattern = statusLinePatterns [ statusSeries . status ]}
< path
d = { linePath ( statusSeries . points )}
fill="none"
stroke = { statusColors [ statusSeries . status ]}
stroke-width="2.5"
stroke-dasharray = { pattern . dash }
stroke-dashoffset= { pattern . offset }
stroke-linecap = "round"
stroke-linejoin = "round"
class:status-line-solid = { statusSeries . status === 'Paid' }
class:status-line-patterned= { statusSeries . status !== 'Paid' }
/>
{ /each }
{ #each data as point , bucket }
{ #each statusOrder as status }
{ @const plotted = series . find (( candidate ) => candidate . status === status ) ? . points [ bucket ]}
{ #if plotted }
< circle
cx = { plotted . x }
cy= { plotted . y }
r = { collisionRadius ( point , status , bucket )}
fill= { activeBucket === bucket ? statusColors [ status ] : 'var(--card)' }
stroke = { statusColors [ status ]}
stroke-width= { activeBucket === bucket ? 3 : 2.5 }
class = "status-dot"
aria-hidden = "true"
/>
{ /if }
{ /each }
{ /each }
{ #if activeX !== null }
< line
x1 = { activeX }
x2= { activeX }
y1 = { pad . top }
y2= { height - pad . bottom }
stroke = "var(--foreground)"
stroke-width = "1"
stroke-dasharray = "3 5"
class = "bucket-guide"
aria-hidden = "true"
/>
{ /if }
{ #each hoverZones as zone }
< rect
x = { zone . start }
y= { pad . top }
width = { zone . width }
height= { plotHeight }
fill = "transparent"
tabindex = "0"
role = "button"
aria-label = { bucketAria ( zone . point )}
onmouseenter= {() => ( activeBucket = zone . bucket )}
onfocus = {() => ( activeBucket = zone . bucket )}
onblur= {() => ( activeBucket = null )}
/>
{ /each }
</ svg >
{ #if active !== null && activePoint }
< div class = "pointer-events-none absolute z-20 w-48 transition-[left,top,transform] duration-150 motion-reduce:transition-none" style = { tooltipStyle ( activePoint )} dir="rtl" role = "tooltip" >
< div class = "rounded-md bg-zinc-950 p-3 text-right text-xs leading-5 text-white shadow-xl" >< strong class = "block" > { bucketLabel ( activePoint . point )} </ strong >< span class = "block" > { statusLabels [ active . status ]} </ span >< span class = "block text-white/75" > { formatValue ( activePoint . count )} تراکنش · { formatPercent ( share ( activePoint . point , activePoint . count ))} </ span ></ div >
{ #if activePoint && activeX !== null }
< div
class = "pointer-events-none absolute z-20 w-56 transition-[left,top,transform] duration-200 ease-out motion-reduce:transition-none"
style = { tooltipStyle ( activeX )}
dir="rtl"
role = "tooltip"
>
< div class = "rounded-md bg-zinc-950 p-3 text-right text-xs leading-5 text-white shadow-xl" >
< strong class = "mb-2 block" > { bucketLabel ( activePoint )} </ strong >
< div class = "grid gap-1.5" >
{ #each statusOrder as status }
{ @const count = countFor ( activePoint , status )}
< div class = "flex items-center justify-between gap-3" >
< span class = "inline-flex min-w-0 items-center gap-2" >
< span
class = "size-2 shrink-0 rounded-full"
style = { `background: ${ statusColors [ status ] } ` }
aria-hidden="true"
></ span >
< span class = "truncate" > { statusLabels [ status ]} </ span >
</ span >
< strong class = "shrink-0 tabular-nums" >
{ formatValue ( count )} · { formatPercent ( share ( activePoint , count ))}
</ strong >
</ div >
{ /each }
</ div >
< div class = "mt-2 border-t border-white/15 pt-2 text-white/75" >
کل: { formatValue ( activePoint . totalSessions )} تراکنش
</ div >
</ div >
</ div >
{ /if }
{ /if }
@@ -116,14 +304,30 @@
</ div >
< details class = "border-border rounded-md border" >
< summary class = "focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2" > جدول دادههای روند وضعیت</ summary >
< summary
class = "focus-visible:ring-ring cursor-pointer rounded-md px-4 py-3 text-sm font-semibold focus-visible:ring-2"
> جدول دادههای روند وضعیت</ summary
>
< div class = "overflow-x-auto border-t" >
< table class = "w-full min-w-[760px] text-sm" >
< caption class = "sr-only" > تعداد تراکنشها به تفکیک زمان و وضعیت</ caption >
< thead class = "bg-muted/50" >< tr >< th class = "p-3 text-right" > زمان</ th > { #each statusOrder as status } < th class = "p-3 text-right" > { statusLabels [ status ]} </ th > { /each } < th class = "p-3 text-right" > کل</ th ></ tr ></ thead >
< thead class = "bg-muted/50"
>< tr
>< th class = "p-3 text-right" > زمان</ th > { #each statusOrder as status } < th class = "p-3 text-right"
> { statusLabels [ status ]} </ th
> { /each } < th class = "p-3 text-right" > کل</ th ></ tr
></ thead
>
< tbody >
{ #each data as point }
< tr class = "border-t" >< th class = "p-3 text-right font-medium" > { bucketLabel ( point )} </ th > { #each statusOrder as status }{ @const count = countFor ( point , status )} < td class = "p-3 tabular-nums" > { formatValue ( count )} < span class = "text-muted-foreground" > ({ formatPercent ( share ( point , count ))} )</ span ></ td > { /each } < td class = "p-3 font-semibold tabular-nums" > { formatValue ( point . totalSessions )} </ td ></ tr >
< tr class = "border-t"
>< th class = "p-3 text-right font-medium" > { bucketLabel ( point )} </ th
> { #each statusOrder as status }{ @const count = countFor ( point , status )} < td
class = "p-3 tabular-nums"
> { formatValue ( count )}
< span class = "text-muted-foreground" > ({ formatPercent ( share ( point , count ))} )</ span ></ td
> { /each } < td class = "p-3 font-semibold tabular-nums" > { formatValue ( point . totalSessions )} </ td ></ tr
>
{ /each }
</ tbody >
</ table >
@@ -132,7 +336,66 @@
</ div >
< style >
. status-line { stroke-dasharray : 1800 ; stroke-dashoffset : 1800 ; animation : draw-status-line 800 ms cubic-bezier ( 0.22 , 1 , 0.36 , 1 ) forwards ; }
@ keyframes draw-status-line { to { stroke-dashoffset : 0 ; } }
@ media ( prefers-reduced-motion : reduce ) { . status-line { animation : none ; stroke-dasharray : none ; stroke-dashoffset : 0 ; } }
. status-line-solid {
stroke-dasharray : 180 0;
stroke-dashoffset : 180 0;
animation : draw-status-line 800 ms cubic-bezier ( 0.22 , 1 , 0.36 , 1 ) forwards ;
}
. status-line-patterned {
animation : reveal-status-line 650 ms cubic-bezier ( 0.22 , 1 , 0.36 , 1 ) both ;
}
. status-dot {
transition :
r 180 ms cubic-bezier ( 0.22 , 1 , 0.36 , 1 ),
fill 180 ms ease-out ,
stroke-width 180 ms ease-out ;
}
[ role = 'tooltip' ] {
animation : tooltip-enter 160 ms ease-out both ;
}
. bucket-guide {
opacity : 0.09 ;
transition :
x 200 ms cubic-bezier ( 0.22 , 1 , 0.36 , 1 ),
opacity 160 ms ease-out ;
}
@ keyframes draw-status-line {
to {
stroke-dashoffset : 0 ;
}
}
@ keyframes reveal-status-line {
from {
opacity : 0 ;
}
to {
opacity : 1 ;
}
}
@ keyframes tooltip-enter {
from {
opacity : 0 ;
margin-top : 4 px ;
}
to {
opacity : 1 ;
margin-top : 0 ;
}
}
@ media ( prefers-reduced-motion : reduce ) {
. status-line-solid ,
. status-line-patterned {
animation : none ;
}
. status-line-solid {
stroke-dasharray : none ;
stroke-dashoffset : 0 ;
}
. status-dot ,
. bucket-guide ,
[ role = 'tooltip' ] {
transition : none ;
animation : none ;
}
}
</ style >