Skip to content

Commit f406eed

Browse files
committed
feat: Interactive Living Sparklines with optimized theming and performance (#8943)
1 parent db6931e commit f406eed

5 files changed

Lines changed: 275 additions & 46 deletions

File tree

apps/devrank/canvas/Sparkline.mjs

Lines changed: 164 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,35 @@
11
import Base from '../../../src/core/Base.mjs';
22
import Neo from '../../../src/Neo.mjs';
33

4+
const hasRaf = typeof requestAnimationFrame === 'function';
5+
46
/**
57
* @class DevRank.canvas.Sparkline
68
* @extends Neo.core.Base
79
* @singleton
810
*/
911
class Sparkline extends Base {
12+
static colors = {
13+
dark: {
14+
fillStart : 'rgba(62, 99, 221, 0.4)',
15+
fillEnd : 'rgba(62, 99, 221, 0.0)',
16+
line : '#3E63DD',
17+
marker : '#3E63DD',
18+
scanner : '#FFFFFF',
19+
textYear : '#AAAAAA',
20+
textValue : '#FFFFFF'
21+
},
22+
light: {
23+
fillStart : 'rgba(62, 99, 221, 0.4)',
24+
fillEnd : 'rgba(62, 99, 221, 0.0)',
25+
line : '#3E63DD',
26+
marker : '#3E63DD',
27+
scanner : '#000000',
28+
textYear : '#666666',
29+
textValue : '#000000'
30+
}
31+
}
32+
1033
static config = {
1134
/**
1235
* @member {String} className='DevRank.canvas.Sparkline'
@@ -18,7 +41,7 @@ class Sparkline extends Base {
1841
* @protected
1942
*/
2043
remote: {
21-
app: ['register', 'updateData']
44+
app: ['onMouseLeave', 'onMouseMove', 'register', 'updateData', 'updateSize']
2245
},
2346
/**
2447
* @member {Boolean} singleton=true
@@ -35,6 +58,35 @@ class Sparkline extends Base {
3558
/**
3659
* @param {Object} data
3760
* @param {String} data.canvasId
61+
*/
62+
onMouseLeave(data) {
63+
let item = this.items.get(data.canvasId);
64+
if (item) {
65+
item.mouseActive = false;
66+
this.draw(item); // Redraw to clear overlay
67+
}
68+
}
69+
70+
/**
71+
* @param {Object} data
72+
* @param {String} data.canvasId
73+
* @param {Number} data.x
74+
* @param {Number} data.y
75+
*/
76+
onMouseMove(data) {
77+
let item = this.items.get(data.canvasId);
78+
if (item) {
79+
item.mouseActive = true;
80+
item.mouseX = data.x;
81+
this.draw(item);
82+
}
83+
}
84+
85+
/**
86+
* @param {Object} data
87+
* @param {String} data.canvasId
88+
* @param {Number} [data.devicePixelRatio=1]
89+
* @param {String} [data.theme='light']
3890
* @param {String} data.windowId
3991
*/
4092
register(data) {
@@ -45,9 +97,13 @@ class Sparkline extends Base {
4597
if (canvas) {
4698
me.items.set(canvasId, {
4799
canvas,
48-
ctx : canvas.getContext('2d'),
49-
height: canvas.height,
50-
width : canvas.width
100+
ctx : canvas.getContext('2d'),
101+
devicePixelRatio: data.devicePixelRatio || 1,
102+
height : canvas.height,
103+
mouseActive : false,
104+
mouseX : 0,
105+
theme : data.theme || 'light',
106+
width : canvas.width
51107
});
52108
}
53109
}
@@ -67,11 +123,37 @@ class Sparkline extends Base {
67123
}
68124
}
69125

126+
/**
127+
* @param {Object} data
128+
* @param {String} data.canvasId
129+
* @param {Number} [data.devicePixelRatio]
130+
* @param {Number} data.height
131+
* @param {Number} data.width
132+
*/
133+
updateSize(data) {
134+
let me = this,
135+
item = me.items.get(data.canvasId);
136+
137+
if (item) {
138+
item.devicePixelRatio = data.devicePixelRatio || item.devicePixelRatio || 1;
139+
item.height = data.height;
140+
item.width = data.width;
141+
me.draw(item);
142+
}
143+
}
144+
70145
/**
71146
* @param {Object} item
72147
*/
73148
draw(item) {
74-
let {ctx, height, values, width} = item;
149+
let me = this,
150+
{ctx, devicePixelRatio, height, values, width, theme} = item,
151+
colors = me.constructor.colors[theme] || me.constructor.colors.light;
152+
153+
// Handle DPR Scaling
154+
item.canvas.width = width * devicePixelRatio;
155+
item.canvas.height = height * devicePixelRatio;
156+
ctx.scale(devicePixelRatio, devicePixelRatio);
75157

76158
if (!Array.isArray(values) || values.length < 2) {
77159
ctx.clearRect(0, 0, width, height);
@@ -82,7 +164,7 @@ class Sparkline extends Base {
82164
max = Math.max(...values),
83165
min = Math.min(...values),
84166
range = max - min || 1,
85-
padding = 4, // Increased padding for marker radius
167+
padding = 4,
86168
h = height - (padding * 2),
87169
stepX = width / (len - 1),
88170
points = [];
@@ -93,23 +175,23 @@ class Sparkline extends Base {
93175
points.push({
94176
x: index * stepX,
95177
y: height - padding - (normalized * h),
96-
val: val
178+
val: val,
179+
year: 2010 + index
97180
});
98181
});
99182

100183
ctx.clearRect(0, 0, width, height);
101184

102-
// 1. Create Gradient
185+
// 1. Draw Base Chart
186+
// Gradient
103187
let gradient = ctx.createLinearGradient(0, 0, 0, height);
104-
gradient.addColorStop(0, 'rgba(62, 99, 221, 0.4)'); // Primary #3E63DD
105-
gradient.addColorStop(1, 'rgba(62, 99, 221, 0.0)');
188+
gradient.addColorStop(0, colors.fillStart);
189+
gradient.addColorStop(1, colors.fillEnd);
106190

107-
// 2. Draw Area (Fill)
108191
ctx.beginPath();
109-
ctx.moveTo(points[0].x, height); // Start bottom-left
192+
ctx.moveTo(points[0].x, height);
110193
ctx.lineTo(points[0].x, points[0].y);
111194

112-
// Smooth curve loop
113195
for (let i = 0; i < len - 1; i++) {
114196
let p0 = points[i],
115197
p1 = points[i + 1],
@@ -123,12 +205,12 @@ class Sparkline extends Base {
123205
}
124206
}
125207

126-
ctx.lineTo(points[len - 1].x, height); // Close to bottom-right
208+
ctx.lineTo(points[len - 1].x, height);
127209
ctx.closePath();
128210
ctx.fillStyle = gradient;
129211
ctx.fill();
130212

131-
// 3. Draw Line (Stroke)
213+
// Line
132214
ctx.beginPath();
133215
ctx.moveTo(points[0].x, points[0].y);
134216

@@ -145,38 +227,81 @@ class Sparkline extends Base {
145227
}
146228
}
147229

148-
ctx.strokeStyle = '#3E63DD'; // Primary
149-
ctx.lineWidth = 2;
230+
ctx.strokeStyle = colors.line;
231+
ctx.lineWidth = 1;
150232
ctx.lineCap = 'round';
151233
ctx.lineJoin = 'round';
152234
ctx.stroke();
153235

154-
// 4. Draw Max Value Marker (if distinct)
155-
if (max > min) {
156-
let maxIndex = values.indexOf(max),
157-
maxPoint = points[maxIndex];
236+
// 2. Draw Interaction Overlay
237+
if (item.mouseActive) {
238+
// Find nearest point
239+
let nearestDist = Infinity,
240+
nearestPoint = null;
241+
242+
points.forEach(p => {
243+
let dist = Math.abs(p.x - item.mouseX);
244+
if (dist < nearestDist) {
245+
nearestDist = dist;
246+
nearestPoint = p;
247+
}
248+
});
249+
250+
if (nearestPoint) {
251+
// Scanner Line
252+
ctx.beginPath();
253+
ctx.moveTo(nearestPoint.x, 0);
254+
ctx.lineTo(nearestPoint.x, height);
255+
ctx.strokeStyle = colors.scanner;
256+
ctx.lineWidth = 1;
257+
ctx.setLineDash([2, 2]);
258+
ctx.stroke();
259+
ctx.setLineDash([]);
260+
261+
// Intersection Dot
262+
ctx.beginPath();
263+
ctx.arc(nearestPoint.x, nearestPoint.y, 3, 0, Math.PI * 2);
264+
ctx.fillStyle = colors.scanner;
265+
ctx.fill();
266+
ctx.beginPath();
267+
ctx.arc(nearestPoint.x, nearestPoint.y, 1.5, 0, Math.PI * 2);
268+
ctx.fillStyle = colors.line;
269+
ctx.fill();
270+
271+
// Text Label
272+
ctx.font = 'bold 10px sans-serif';
273+
ctx.textAlign = 'center';
274+
275+
let textY = 10;
276+
let x = nearestPoint.x;
158277

278+
// Adjust text alignment if near edges
279+
if (x < 30) {
280+
ctx.textAlign = 'left';
281+
x += 5;
282+
} else if (x > width - 30) {
283+
ctx.textAlign = 'right';
284+
x -= 5;
285+
}
286+
287+
// Draw Year
288+
ctx.fillStyle = colors.textYear;
289+
ctx.fillText(String(nearestPoint.year), x, textY);
290+
291+
// Draw Value
292+
let valueText = new Intl.NumberFormat().format(nearestPoint.val);
293+
ctx.fillStyle = colors.textValue;
294+
ctx.fillText(valueText, x, textY + 12);
295+
}
296+
} else {
297+
// Only draw End Point
298+
let lastPoint = points[len - 1];
159299
ctx.beginPath();
160-
ctx.arc(maxPoint.x, maxPoint.y, 2, 0, Math.PI * 2);
161-
ctx.fillStyle = '#3E63DD';
300+
ctx.arc(lastPoint.x, lastPoint.y, 1.5, 0, Math.PI * 2);
301+
ctx.fillStyle = colors.marker;
162302
ctx.fill();
163303
}
164-
165-
// 5. Draw End Point (Glowing)
166-
let lastPoint = points[len - 1];
167-
168-
// Glow
169-
ctx.beginPath();
170-
ctx.arc(lastPoint.x, lastPoint.y, 4, 0, Math.PI * 2);
171-
ctx.fillStyle = 'rgba(62, 99, 221, 0.3)';
172-
ctx.fill();
173-
174-
// Dot
175-
ctx.beginPath();
176-
ctx.arc(lastPoint.x, lastPoint.y, 2, 0, Math.PI * 2);
177-
ctx.fillStyle = '#3E63DD';
178-
ctx.fill();
179304
}
180305
}
181306

182-
export default Neo.setupClass(Sparkline);
307+
export default Neo.setupClass(Sparkline);

apps/devrank/view/GridContainer.mjs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,7 @@ class GridContainer extends BaseGridContainer {
2424
*/
2525
body: {
2626
bufferColumnRange: 3,
27-
bufferRowRange : 5,
28-
rowHeight : 60
27+
bufferRowRange : 5
2928
},
3029
/**
3130
* Default configs for each column
@@ -36,6 +35,10 @@ class GridContainer extends BaseGridContainer {
3635
defaultSortDirection: 'DESC',
3736
width : 150
3837
},
38+
/**
39+
* @member {Number} rowHeight=50
40+
*/
41+
rowHeight: 50,
3942
/**
4043
* @member {Object[]} store=Contributors
4144
* @reactive
@@ -117,8 +120,6 @@ class GridContainer extends BaseGridContainer {
117120
return {
118121
module: SparklineComponent,
119122
values: data,
120-
height: 40,
121-
style : {marginTop: '10px'},
122123
width : 140
123124
}
124125
}

0 commit comments

Comments
 (0)