Advanced Customization
Learn how to create sophisticated hockey visualizations using the customRender hook and advanced configuration options.
What is customRender?
The customRender hook gives you direct access to D3 selections and calculated position data, allowing you to add custom elements, interactions, and behaviors beyond the standard configuration.
customRender: (selection, dimensions, context) => {
// selection: D3 selection of event elements
// dimensions: { scale, padding, width, height }
// context: { position, data, index, container, layer }
const { position, data, index, container } = context;
// position: { svgX, svgY, dataX, dataY }
// data: your event data
// index: element index
// container: parent SVG group for adding siblings
};When to Use
Use customRender when you need to:
- Add labels, arrows, or decorative elements
- Create composite visualizations
- Add custom interactions beyond tooltips
- Build conditional visualizations
- Connect events with lines or paths
Don't use customRender for:
- Simple color/size changes (use config options)
- Adding basic attributes (use
customAttributes) - Entirely new layer types (extend
BaseLayer)
Player Name Labels
Add text labels showing player names next to each event.
import { Rink } from "d3-hockey";
import * as d3 from "d3";
const shots = [
{ coordinates: { x: 48, y: 15 }, player: "Ovechkin", type: "GOAL" },
{ coordinates: { x: 73, y: -10 }, player: "Backstrom", type: "SHOT" },
{ coordinates: { x: 70, y: 12 }, player: "Wilson", type: "SHOT" },
];
new Rink("#container").render().addEvents(shots, {
id: "labeled",
radius: 5,
tooltip: (d) => `<strong>${d.player}</strong><br/>
Event: ${d.type}<br/>
Location (${d.coordinates.x}, ${d.coordinates.y})`,
customRender: (selection, dimensions, context) => {
const { position, data, container } = context;
d3.select(container)
.append("text")
.attr("x", position.svgX + 10)
.attr("y", position.svgY - 10)
.attr("font-size", "11px")
.attr("font-weight", "bold")
.attr("fill", "#000")
.text(data.player);
},
});Speed Rings
Visualize shot speed with rings around each event.
const shotsWithSpeed = [
{ coordinates: { x: 45, y: 3 }, player: "Weber", speed: 108 },
{ coordinates: { x: 55, y: -8 }, player: "Subban", speed: 95 },
{ coordinates: { x: 70, y: 12 }, player: "Gallagher", speed: 82 },
];
new Rink("#container").render().addEvents(shotsWithSpeed, {
id: "speed",
radius: 5,
tooltip: (d) => `<strong>${d.player}</strong><br/>
Speed: ${d.speed} mph<br/>
Location: (${d.coordinates.x}, ${d.coordinates.y})`,
customRender: (selection, dimensions, context) => {
const { position, data, container } = context;
const ringRadius = ((data.speed - 60) / 60) * 20;
d3.select(container)
.append("circle")
.attr("cx", position.svgX)
.attr("cy", position.svgY)
.attr("r", ringRadius)
.attr("fill", "none")
.attr("stroke", "#2196F5")
.attr("stroke-width", 1.5)
.attr("stroke-dasharray", "3,3")
.attr("opacity", 0.6);
},
});Pulsating Goals
Make goals stand out with pulsating animations.
const events = [
{ coordinates: { x: 85, y: 5 }, type: "GOAL", player: "Matthews" },
{ coordinates: { x: 75, y: -8 }, type: "SHOT", player: "Marner" },
{ coordinates: { x: 70, y: 12 }, type: "SHOT", player: "Nylander" },
];
new Rink("#container").render().addEvents(events, {
id: "pulsating-goals",
radius: 6,
tooltip: (d) => `<strong>${d.player}</strong><br/>
Event: ${d.type}<br/>
Location: (${d.coordinates.x}, ${d.coordinates.y})`,
customRender: (selection, dimensions, context) => {
if (context.data.type !== "GOAL") return;
function pulse() {
selection
.transition()
.duration(1000)
.attr("opacity", 0.3)
.transition()
.duration(1000)
.attr("opacity", 1)
.on("end", pulse);
}
const animationDelay = context.layer.config.animate
? context.layer.config.animationDuration + 50
: 0;
setTimeout(pulse, animationDelay);
},
});Shot Sequence
Connect shots with lines to show passing or shot sequences.
const sequence = [
{ coordinates: { x: 50, y: 5 }, player: "A", sequence: 1 },
{ coordinates: { x: 65, y: 10 }, player: "B", sequence: 2 },
{ coordinates: { x: 80, y: 5 }, player: "C", sequence: 3, type: "GOAL" },
];
// Store positions for connecting lines
const positions = [];
new Rink("#container").render().addEvents(sequence, {
id: "sequence",
color: (d) => (d.type === "GOAL" ? "#00FF00" : "#FF4C00"),
radius: 5,
customRender: (selection, dimensions, context) => {
const { position, data, container, index } = context;
// Collect positions
positions.push({
x: position.svgX,
y: position.svgY,
sequence: data.sequence,
});
// Draw connecting lines after last element
if (index === sequence.length - 1) {
const parent = d3.select(container);
positions.sort((a, b) => a.sequence - b.sequence);
for (let i = 0; i < positions.length - 1; i++) {
const start = positions[i];
const end = positions[i + 1];
parent
.insert("line", ":first-child")
.attr("class", "sequence-line")
.attr("x1", start.x)
.attr("y1", start.y)
.attr("x2", end.x)
.attr("y2", end.y)
.attr("stroke", "#2196F3")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5")
.attr("opacity", 0.6);
}
}
},
});Click Interactions
Add custom click handlers to events.
const shots = [
{
coordinates: { x: 85, y: 5 },
player: "Kane",
shotType: "Wrist Shot",
speed: 95,
},
{
coordinates: { x: 75, y: -8 },
player: "Toews",
shotType: "Slap Shot",
speed: 102,
},
];
new Rink("#container").render().addEvents(shots, {
id: "clickable",
color: "#c8102e",
radius: 6,
customRender: (selection, dimensions, context) => {
const { data, layer } = context;
selection
.on("click", function (event) {
event.stopPropagation();
// Highlight clicked element
d3.selectAll("path.event-symbol").attr("stroke-width", 1);
d3.select(this).attr("stroke-width", 4).attr("stroke", "#000");
// Update details panel
d3.select("#shot-details").html(`
<h4 style="margin: 0 0 0.5rem 0;">${data.player}</h4>
<p style="margin: 0;">
<strong>Shot Type:</strong> ${data.shotType}<br/>
<strong>Speed:</strong> ${data.speed} MPH<br/>
<strong>Location:</strong> (${data.coordinates.x}, ${data.coordinates.y})
</p>
`);
})
.on("mouseenter", function () {
d3.select(this).attr("opacity", 0.7);
})
.on("mouseleave", function () {
d3.select(this).attr("opacity", 1);
});
},
});Conditional Decorations
Add different decorations based on event properties.
const events = [
{ coordinates: { x: 85, y: 5 }, type: "GOAL" },
{ coordinates: { x: 75, y: -8 }, type: "SHOT" },
{ coordinates: { x: 60, y: 12 }, type: "MISSED" },
];
new Rink("#container").render().addEvents(events, {
id: "conditional",
color: (d) => {
if (d.type === "GOAL") return "#00FF00";
if (d.type === "MISSED") return "#FF6600";
return "#0088FF";
},
radius: 5,
customRender: (selection, dimensions, context) => {
const { position, data, container } = context;
const parent = d3.select(container);
if (data.type === "GOAL") {
// Add star burst for goals
for (let i = 0; i < 8; i++) {
const angle = i * 45 * (Math.PI / 180);
const length = 15;
parent
.append("line")
.attr("class", "goal-burst")
.attr("x1", position.svgX)
.attr("y1", position.svgY)
.attr("x2", position.svgX + Math.cos(angle) * length)
.attr("y2", position.svgY + Math.sin(angle) * length)
.attr("stroke", "#FFD700")
.attr("stroke-width", 2)
.attr("opacity", 0.7);
}
} else if (data.type === "MISSED") {
// Add X mark for misses
parent
.append("path")
.attr("class", "miss-mark")
.attr(
"d",
`M ${position.svgX - 8},${position.svgY - 8} L ${position.svgX + 8},${position.svgY + 8} M ${position.svgX - 8},${position.svgY + 8} L ${position.svgX + 8},${position.svgY - 8}`,
)
.attr("stroke", "#F44336")
.attr("stroke-width", 2)
.attr("opacity", 0.5);
}
},
});Heat Trails
Create heat trail effects behind events.
const shots = [
{ coordinates: { x: 85, y: 5 }, danger: 0.8 },
{ coordinates: { x: 75, y: -10 }, danger: 0.5 },
{ coordinates: { x: 60, y: 12 }, danger: 0.3 },
];
new Rink("#container").render().addEvents(shots, {
id: "trails",
color: "#FF4C00",
radius: 5,
customRender: (selection, dimensions, context) => {
const { position, data, container } = context;
const parent = d3.select(container);
// Create gradient rings based on danger
const numRings = 3;
for (let i = 0; i < numRings; i++) {
parent
.insert("circle", ":first-child")
.attr("cx", position.svgX)
.attr("cy", position.svgY)
.attr("r", 5 + i * 8)
.attr("fill", "#FF4C00")
.attr("opacity", data.danger * 0.4 - i * 0.1)
.attr("class", "heat-trail");
}
},
});Tips & Best Practices
Context Object
The context parameter provides everything you need:
customRender: (selection, dimensions, context) => {
const { position, data, index, container, layer } = context;
// position.svgX/svgY - Calculated SVG pixel coordinates
// position.dataX/dataY - Original NHL coordinates
// data - Your event data (fully typed)
// index - Element index in the dataset
// container - Parent SVG group for adding siblings
// layer - Reference to EventLayer instance
};Adding Sibling Elements
Use the container to add elements alongside the symbol:
customRender: (selection, dimensions, context) => {
d3.select(context.container)
.append("text")
.attr("x", context.position.svgX)
.attr("y", context.position.svgY)
.text(context.data.player);
};Accessing Layer Configuration
The layer property gives you access to all layer config:
customRender: (selection, dimensions, context) => {
// Access layer configuration
const layerColor = context.layer.config.color;
const layerRadius = context.layer.config.radius;
// Use dimensions for scaling
const scale = dimensions.scale;
};Performance Considerations
customRender is called once per element on each render:
- Keep operations efficient
- Avoid expensive calculations
- Filter early:
if (context.data.type !== "GOAL") return; - Consider debouncing for large datasets