Page Menu
Home
WickedGov Phorge
Search
Configure Global Search
Log In
Files
F4102089
CSparkline.vue
No One
Temporary
Actions
Download File
Edit File
Delete File
View Transforms
Subscribe
Flag For Later
Award Token
Size
7 KB
Referenced Files
None
Subscribers
None
CSparkline.vue
View Options
<template>
<div class="ext-checkuser-CSparkline-container">
<svg
:id="`sparkline-${ id }`"
xmlns="http://www.w3.org/2000/svg"
class="ext-checkuser-CSparkline"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<title>
{{ title }}
</title>
</svg>
<div
v-if="tooltipData.visible"
class="ext-checkuser-CSparkline__tooltip"
:style="{
left: tooltipData.x + 'px',
top: tooltipData.y + 'px'
}"
>
{{ tooltipLabel }}
</div>
</div>
</template>
<script>
const { onMounted, reactive, computed } = require( 'vue' );
const DateFormatter = require( 'mediawiki.DateFormatter' );
const d3 = require( '../lib/d3/d3.min.js' );
let chart, sparkline, area, hoverDot = null;
let xScale, yScale = null;
// @vue/component
module.exports = exports = {
compilerOptions: { whitespace: 'condense' },
props: {
title: {
type: String,
required: true
},
id: {
type: String,
required: true
},
data: {
type: Object,
required: true
},
dimensions: {
type: Object,
required: true
},
xAccessor: {
type: String,
required: true
},
yAccessor: {
type: String,
required: true
}
},
setup( props ) {
const tooltipData = reactive( {
visible: false,
date: '',
edits: 0,
x: 0,
y: 0
} );
const tooltipLabel = computed( () => mw.msg(
'checkuser-userinfocard-chart-tooltip-label',
tooltipData.date,
tooltipData.edits
) );
// Create accessor functions from property names
const getXValue = ( d ) => d[ props.xAccessor ];
const getYValue = ( d ) => d[ props.yAccessor ];
/**
* Find the closest data point to the given x-value
*
* @param {Object|null} d0 - Previous data point
* @param {Object|null} d1 - Next data point
* @param {number} xValue - Target x-value to find closest point to
* @param {Function} xValueAccessor - Function to extract x-value from data point
* @return {Object|null} The closest data point or null if none available
*/
const findClosestDataPoint = ( d0, d1, xValue, xValueAccessor ) => {
// If only one data point exists, return it
if ( !d0 && d1 ) {
return d1;
}
if ( d0 && !d1 ) {
return d0;
}
// Calculate distances to both points
const distanceToD0 = Math.abs( xValue - xValueAccessor( d0 ) );
const distanceToD1 = Math.abs( xValueAccessor( d1 ) - xValue );
// Return the closest point
return distanceToD1 < distanceToD0 ? d1 : d0;
};
const handleMouseMove = ( event ) => {
const [ mouseX ] = d3.pointer( event );
const xValue = xScale.invert( mouseX );
const bisector = d3.bisector( getXValue ).left;
const index = bisector( props.data, xValue, 1 );
const d0 = props.data[ index - 1 ];
const d1 = props.data[ index ];
if ( !d0 && !d1 ) {
return;
}
const d = findClosestDataPoint( d0, d1, xValue, getXValue );
const x = xScale( getXValue( d ) );
const y = yScale( getYValue( d ) );
hoverDot
.attr( 'cx', x )
.attr( 'cy', y )
.style( 'display', 'block' );
tooltipData.visible = !!getYValue( d );
tooltipData.date = DateFormatter.formatDate( getXValue( d ) );
tooltipData.edits = getYValue( d );
const svgElement = chart.node();
const container = svgElement.parentElement;
// Use requestAnimationFrame to position after Vue renders the tooltip, to ensure
// the tooltip is positioned within the parent container
requestAnimationFrame( () => {
const tooltip = container.querySelector( '.ext-checkuser-CSparkline__tooltip' );
if ( !tooltip ) {
return;
}
const svgRect = svgElement.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
const tooltipRect = tooltip.getBoundingClientRect();
const pointX = ( svgRect.left - containerRect.left ) +
( x / props.dimensions.width ) *
svgRect.width;
const pointY = ( svgRect.top - containerRect.top ) +
( y / props.dimensions.height ) *
svgRect.height;
const halfWidth = tooltipRect.width / 2;
const tooltipX = Math.max(
0, Math.min( pointX - halfWidth, containerRect.width - tooltipRect.width )
);
const tooltipY = pointY - tooltipRect.height - 10;
tooltipData.x = tooltipX;
tooltipData.y = tooltipY;
} );
};
const handleMouseLeave = () => {
hoverDot.style( 'display', 'none' );
tooltipData.visible = false;
};
const plot = () => {
chart.attr( 'viewBox', `0 0 ${ props.dimensions.width } ${ props.dimensions.height }` );
const xDomain = d3.extent( props.data, getXValue );
xScale = d3.scaleTime()
.domain( xDomain )
.range( [ 0, props.dimensions.width ] );
// Get the maximum value from the Y data, or use 10 if all values are 0
// This ensures the chart displays an accurate representation of the data
// so the line doesn't stay in the middle of the graph
const maxY = d3.max( props.data, getYValue ) || 10;
// Use -10% on the lower yDomain so we see 0 value "lines" in the chart
// This issue happens in Safari
const yDomain = [ -0.1 * maxY, maxY ];
yScale = d3.scaleLinear()
.domain( yDomain )
// Flip svg Y-axis coordinate system and add a pixel on top to avoid cutting
// off anti-aliasing pixels. Do not add a pixel on the bottom, that would make the
// graph non-0-based, and it's rare for the pageviews to be 0.
.range( [ props.dimensions.height, 1 ] );
const lineGenerator = d3.line()
.x( ( d ) => xScale( getXValue( d ) ) )
.y( ( d ) => yScale( getYValue( d ) ) );
const areaGenerator = d3.area()
.x( ( d ) => xScale( getXValue( d ) ) )
.y1( ( d ) => yScale( getYValue( d ) ) )
.y0( props.dimensions.height );
sparkline
.data( [ props.data ] )
.attr( 'd', lineGenerator )
.attr( 'stroke-width', 1 )
.attr( 'stroke-linejoin', 'round' )
.attr( 'fill', 'none' );
area
.data( [ props.data ] )
.attr( 'd', areaGenerator );
};
onMounted( () => {
chart = d3.select( `#sparkline-${ props.id }` );
// Append order is relevant. Render the line over the area
area = chart.append( 'path' ).attr( 'class', 'ext-checkuser-CSparkline__area' );
sparkline = chart.append( 'path' ).attr( 'class', 'ext-checkuser-CSparkline__line' );
// Add hover dot (initially hidden)
hoverDot = chart.append( 'circle' )
.attr( 'class', 'ext-checkuser-CSparkline__hover-dot' )
.attr( 'r', 3 )
.style( 'display', 'none' );
plot();
} );
return {
tooltipData,
tooltipLabel,
handleMouseMove,
handleMouseLeave
};
}
};
</script>
<style lang="less">
@import 'mediawiki.skin.variables.less';
.ext-checkuser-CSparkline-container {
position: relative;
}
.ext-checkuser-CSparkline {
padding: @spacing-0 @spacing-25 @spacing-0 @spacing-12;
overflow: visible;
&__line {
stroke: @background-color-progressive--focus;
}
&__area {
fill: @background-color-progressive-subtle;
}
&__hover-dot {
fill: @background-color-progressive--focus;
stroke: @background-color-progressive--focus;
stroke-width: 1px;
}
// The following CSS was copied from Codex (https://gerrit.wikimedia.org/r/plugins/gitiles/design/codex/+/refs/heads/main/packages/codex/src/components/tooltip/Tooltip.less)
&__tooltip {
pointer-events: none;
white-space: nowrap;
background-color: @background-color-inverted;
color: @color-inverted;
position: absolute;
z-index: @z-index-tooltip;
width: @size-content-max;
max-width: @size-1600;
border-radius: @border-radius-base;
padding: @spacing-12 @spacing-35;
font-family: @font-family-system-sans;
font-size: @font-size-x-small;
font-weight: @font-weight-normal;
line-height: @line-height-small;
}
}
</style>
File Metadata
Details
Attached
Mime Type
text/html
Expires
Tue, Aug 18, 17:16 (3 w, 3 d ago)
Storage Engine
local-disk
Storage Format
Raw Data
Storage Handle
c3/64/52e6b2645d084e6ecf35e7a5b650
Default Alt Text
CSparkline.vue (7 KB)
Attached To
Mode
rMWPROD MediaWiki Production
Attached
Detach File
Event Timeline
Log In to Comment